feat: implement local auth, email verification, password reset, session management
This commit is contained in:
parent
acb111d7ff
commit
060211f023
|
|
@ -9,15 +9,19 @@ POSTGRES_DB = os.getenv("POSTGRES_DB", "morozovka_portal")
|
||||||
POSTGRES_HOST = os.getenv("POSTGRES_HOST", "127.0.0.1")
|
POSTGRES_HOST = os.getenv("POSTGRES_HOST", "127.0.0.1")
|
||||||
POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
|
POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
|
||||||
|
|
||||||
|
# Добавляем psycopg2 в URL
|
||||||
DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
|
DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
|
||||||
|
|
||||||
# Настройки Gitea
|
GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000")
|
||||||
GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000") # Внутренний адрес для API
|
GITEA_PUBLIC_URL = os.getenv("GITEA_PUBLIC_URL", "https://git.morozovka.space")
|
||||||
GITEA_PUBLIC_URL = os.getenv("GITEA_PUBLIC_URL", "https://git.morozovka.space") # Для редиректа браузера
|
|
||||||
|
|
||||||
# Вставь сюда свои Client ID и Client Secret из Gitea
|
|
||||||
GITEA_CLIENT_ID = "66697efe-d88b-41fe-b3d5-6498d978e1fa"
|
GITEA_CLIENT_ID = "66697efe-d88b-41fe-b3d5-6498d978e1fa"
|
||||||
GITEA_CLIENT_SECRET = "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a"
|
GITEA_CLIENT_SECRET = "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a"
|
||||||
|
|
||||||
# Секретный ключ для куки-сессий Flask (можешь оставить этот)
|
|
||||||
WEB_SECRET = os.getenv("WEB_SECRET", "super-secret-morozovka-key-2026")
|
WEB_SECRET = os.getenv("WEB_SECRET", "super-secret-morozovka-key-2026")
|
||||||
|
|
||||||
|
# Настройки SMTP (если пусто, письма будут падать в консоль)
|
||||||
|
SMTP_SERVER = os.getenv("SMTP_SERVER", "")
|
||||||
|
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||||
|
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||||
|
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||||
|
SMTP_FROM = os.getenv("SMTP_FROM", "noreply@morozovka.space")
|
||||||
|
|
|
||||||
576
app/main.py
576
app/main.py
|
|
@ -1,19 +1,22 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from itsdangerous import URLSafeTimedSerializer
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory
|
from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
from .db import init_db, engine
|
from .db import init_db, engine
|
||||||
from . import config, auth, assistant
|
from . import config, auth, assistant
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from .models import User, Event, GlobalStatus, Project, PortalFile
|
from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = config.WEB_SECRET
|
app.secret_key = config.WEB_SECRET
|
||||||
|
serializer = URLSafeTimedSerializer(app.secret_key)
|
||||||
|
|
||||||
# Настройки для загрузки файлов (макс 100 МБ)
|
|
||||||
UPLOAD_FOLDER = '/var/lib/gitea/portal-files'
|
UPLOAD_FOLDER = '/var/lib/gitea/portal-files'
|
||||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
|
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
|
||||||
|
|
@ -26,6 +29,41 @@ with app.app_context():
|
||||||
db_session.add(GlobalStatus(status="free", message="Вне расписания"))
|
db_session.add(GlobalStatus(status="free", message="Вне расписания"))
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def validate_session():
|
||||||
|
if "session_id" in session:
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first()
|
||||||
|
if not db_sess:
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
def send_email(to_email, subject, body):
|
||||||
|
if not config.SMTP_SERVER:
|
||||||
|
print("\n" + "="*50, flush=True)
|
||||||
|
print(f"📧 EMAIL ДЛЯ: {to_email}", flush=True)
|
||||||
|
print(f"Тема: {subject}", flush=True)
|
||||||
|
print(body, flush=True)
|
||||||
|
print("="*50 + "\n", flush=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['From'] = config.SMTP_FROM
|
||||||
|
msg['To'] = to_email
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg.attach(MIMEText(body, 'plain'))
|
||||||
|
|
||||||
|
server = smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT)
|
||||||
|
server.starttls()
|
||||||
|
server.login(config.SMTP_USER, config.SMTP_PASSWORD)
|
||||||
|
server.send_message(msg)
|
||||||
|
server.quit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка отправки email: {e}", flush=True)
|
||||||
|
return False
|
||||||
|
|
||||||
def require_role(allowed_roles):
|
def require_role(allowed_roles):
|
||||||
if isinstance(allowed_roles, str):
|
if isinstance(allowed_roles, str):
|
||||||
allowed_roles = [allowed_roles]
|
allowed_roles = [allowed_roles]
|
||||||
|
|
@ -33,7 +71,7 @@ def require_role(allowed_roles):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if session.get("role") not in allowed_roles:
|
if session.get("role") not in allowed_roles:
|
||||||
return "403: Доступ запрещен. Недостаточно прав.", 403
|
return "403: Доступ запрещен.", 403
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
return decorator
|
return decorator
|
||||||
|
|
@ -48,16 +86,102 @@ def login_required(f):
|
||||||
|
|
||||||
@app.route("/healthz")
|
@app.route("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
return jsonify({"status": "ok", "service": "morozovka-portal"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
current_user = session.get("username")
|
return render_template("index.html", username=session.get("username"))
|
||||||
return render_template("index.html", username=current_user)
|
|
||||||
|
|
||||||
# --- АВТОРИЗАЦИЯ ---
|
# --- АУТЕНТИФИКАЦИЯ ---
|
||||||
@app.route("/login")
|
@app.route("/register", methods=["GET", "POST"])
|
||||||
|
def register():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username")
|
||||||
|
email = request.form.get("email")
|
||||||
|
password = request.form.get("password")
|
||||||
|
full_name = request.form.get("full_name")
|
||||||
|
organization = request.form.get("organization")
|
||||||
|
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
if db_session.exec(select(User).where(User.username == username)).first():
|
||||||
|
return render_template("register.html", error="Логин уже занят")
|
||||||
|
if db_session.exec(select(User).where(User.email == email)).first():
|
||||||
|
return render_template("register.html", error="Email уже используется")
|
||||||
|
|
||||||
|
new_user = User(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
password_hash=generate_password_hash(password),
|
||||||
|
full_name=full_name,
|
||||||
|
organization=organization,
|
||||||
|
role="student",
|
||||||
|
is_verified=False
|
||||||
|
)
|
||||||
|
db_session.add(new_user)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
token = serializer.dumps(email, salt='email-confirm')
|
||||||
|
confirm_url = url_for('verify_email', token=token, _external=True)
|
||||||
|
confirm_url = confirm_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
|
||||||
|
|
||||||
|
body = f"Привет, {full_name}!\n\nДля подтверждения почты перейди по ссылке:\n{confirm_url}\n"
|
||||||
|
send_email(email, "Подтверждение почты в Morozovka Portal", body)
|
||||||
|
|
||||||
|
return render_template("login.html", msg="Регистрация успешна. Проверьте почту для подтверждения.")
|
||||||
|
return render_template("register.html")
|
||||||
|
|
||||||
|
@app.route("/verify_email/<token>")
|
||||||
|
def verify_email(token):
|
||||||
|
try:
|
||||||
|
email = serializer.loads(token, salt='email-confirm', max_age=86400)
|
||||||
|
except Exception:
|
||||||
|
return "Ссылка недействительна или устарела.", 400
|
||||||
|
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
user = db_session.exec(select(User).where(User.email == email)).first()
|
||||||
|
if user:
|
||||||
|
user.is_verified = True
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
return redirect(url_for("login", msg="Почта подтверждена! Теперь можно войти."))
|
||||||
|
return "Пользователь не найден.", 404
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username")
|
||||||
|
password = request.form.get("password")
|
||||||
|
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
user = db_session.exec(select(User).where(User.username == username)).first()
|
||||||
|
|
||||||
|
if not user or not user.password_hash or not check_password_hash(user.password_hash, password):
|
||||||
|
return render_template("login.html", error="Неверный логин или пароль")
|
||||||
|
|
||||||
|
if not user.is_verified:
|
||||||
|
return render_template("login.html", error="Подтвердите почту перед входом")
|
||||||
|
|
||||||
|
session_token = str(uuid.uuid4())
|
||||||
|
db_session.add(UserSession(
|
||||||
|
session_token=session_token,
|
||||||
|
user_id=user.id,
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
user_agent=request.user_agent.string
|
||||||
|
))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
session["user_id"] = user.id
|
||||||
|
session["username"] = user.username
|
||||||
|
session["role"] = user.role
|
||||||
|
session["session_id"] = session_token
|
||||||
|
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
msg = request.args.get("msg")
|
||||||
|
return render_template("login.html", msg=msg)
|
||||||
|
|
||||||
|
@app.route("/login/gitea")
|
||||||
|
def login_gitea():
|
||||||
gitea_auth_url = (
|
gitea_auth_url = (
|
||||||
f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize"
|
f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize"
|
||||||
f"?client_id={config.GITEA_CLIENT_ID}&response_type=code"
|
f"?client_id={config.GITEA_CLIENT_ID}&response_type=code"
|
||||||
|
|
@ -70,236 +194,150 @@ def login():
|
||||||
def auth_callback():
|
def auth_callback():
|
||||||
code = request.args.get("code")
|
code = request.args.get("code")
|
||||||
if not code:
|
if not code:
|
||||||
return "Ошибка: Gitea не передала код авторизации.", 400
|
return "Ошибка авторизации Gitea", 400
|
||||||
|
|
||||||
token = auth.get_gitea_token(code)
|
token = auth.get_gitea_token(code)
|
||||||
if not token:
|
if not token:
|
||||||
return "Ошибка: не удалось получить токен от Gitea.", 400
|
return "Ошибка токена Gitea", 400
|
||||||
|
|
||||||
gitea_user = auth.get_gitea_user(token)
|
gitea_user = auth.get_gitea_user(token)
|
||||||
if not gitea_user:
|
if not gitea_user:
|
||||||
return "Ошибка: не удалось получить профиль пользователя.", 400
|
return "Ошибка профиля Gitea", 400
|
||||||
|
|
||||||
with Session(engine) as db_session:
|
with Session(engine) as db_session:
|
||||||
user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first()
|
user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first()
|
||||||
if not user:
|
if not user:
|
||||||
new_user = User(
|
user = db_session.exec(select(User).where(User.email == gitea_user.get("email"))).first()
|
||||||
gitea_id=gitea_user["id"],
|
if user:
|
||||||
username=gitea_user["login"],
|
user.gitea_id = gitea_user["id"]
|
||||||
full_name=gitea_user.get("full_name"),
|
else:
|
||||||
email=gitea_user.get("email"),
|
user = User(
|
||||||
role="admin" if gitea_user["login"] == "git_admin" else "student"
|
gitea_id=gitea_user["id"],
|
||||||
)
|
username=gitea_user["login"],
|
||||||
db_session.add(new_user)
|
full_name=gitea_user.get("full_name"),
|
||||||
db_session.commit()
|
email=gitea_user.get("email"),
|
||||||
user = new_user
|
role="admin" if gitea_user["login"] == "git_admin" else "student",
|
||||||
else:
|
is_verified=True
|
||||||
user.username = gitea_user["login"]
|
)
|
||||||
user.full_name = gitea_user.get("full_name")
|
db_session.add(user)
|
||||||
user.email = gitea_user.get("email")
|
|
||||||
db_session.add(user)
|
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
|
session_token = str(uuid.uuid4())
|
||||||
|
db_session.add(UserSession(
|
||||||
|
session_token=session_token,
|
||||||
|
user_id=user.id,
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
user_agent=request.user_agent.string
|
||||||
|
))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
session["user_id"] = user.id
|
session["user_id"] = user.id
|
||||||
session["username"] = user.username
|
session["username"] = user.username
|
||||||
session["role"] = user.role
|
session["role"] = user.role
|
||||||
|
session["session_id"] = session_token
|
||||||
|
|
||||||
return redirect(url_for("profile"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
if "session_id" in session:
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first()
|
||||||
|
if db_sess:
|
||||||
|
db_session.delete(db_sess)
|
||||||
|
db_session.commit()
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
# --- СБРОС ПАРОЛЯ ---
|
||||||
|
@app.route("/forgot_password", methods=["GET", "POST"])
|
||||||
|
def forgot_password():
|
||||||
|
if request.method == "POST":
|
||||||
|
email = request.form.get("email")
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
user = db_session.exec(select(User).where(User.email == email)).first()
|
||||||
|
if user:
|
||||||
|
token = serializer.dumps(email, salt='password-reset')
|
||||||
|
reset_url = url_for('reset_password', token=token, _external=True)
|
||||||
|
reset_url = reset_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
|
||||||
|
|
||||||
|
body = f"Для сброса пароля перейди по ссылке:\n{reset_url}\n"
|
||||||
|
send_email(email, "Сброс пароля Morozovka Portal", body)
|
||||||
|
|
||||||
|
return render_template("login.html", msg="Если такой email есть, инструкция отправлена.")
|
||||||
|
return render_template("forgot_password.html")
|
||||||
|
|
||||||
|
@app.route("/reset_password/<token>", methods=["GET", "POST"])
|
||||||
|
def reset_password(token):
|
||||||
|
try:
|
||||||
|
email = serializer.loads(token, salt='password-reset', max_age=3600)
|
||||||
|
except Exception:
|
||||||
|
return "Ссылка недействительна или устарела.", 400
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
new_password = request.form.get("password")
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
user = db_session.exec(select(User).where(User.email == email)).first()
|
||||||
|
if user:
|
||||||
|
user.password_hash = generate_password_hash(new_password)
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
return redirect(url_for("login", msg="Пароль успешно изменен!"))
|
||||||
|
return render_template("reset_password.html", token=token)
|
||||||
|
|
||||||
|
# --- УПРАВЛЕНИЕ СЕССИЯМИ ---
|
||||||
|
@app.route("/sessions")
|
||||||
|
@login_required
|
||||||
|
def sessions_list():
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
sessions = db_session.exec(
|
||||||
|
select(UserSession)
|
||||||
|
.where(UserSession.user_id == session.get("user_id"))
|
||||||
|
.order_by(UserSession.created_at.desc())
|
||||||
|
).all()
|
||||||
|
return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id"))
|
||||||
|
|
||||||
|
@app.route("/sessions/logout_all", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def logout_all():
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
user_sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all()
|
||||||
|
for s in user_sessions:
|
||||||
|
db_session.delete(s)
|
||||||
|
db_session.commit()
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("login", msg="Вы вышли со всех устройств."))
|
||||||
|
|
||||||
|
# --- ПРОФИЛЬ ---
|
||||||
@app.route("/profile")
|
@app.route("/profile")
|
||||||
@login_required
|
@login_required
|
||||||
def profile():
|
def profile():
|
||||||
with Session(engine) as db_session:
|
with Session(engine) as db_session:
|
||||||
user = db_session.get(User, session.get("user_id"))
|
user = db_session.get(User, session.get("user_id"))
|
||||||
if not user:
|
|
||||||
session.clear()
|
|
||||||
return redirect(url_for("login"))
|
|
||||||
return render_template("profile.html", user=user)
|
return render_template("profile.html", user=user)
|
||||||
|
|
||||||
@app.route("/logout")
|
# --- РАСПИСАНИЕ ---
|
||||||
def logout():
|
|
||||||
session.clear()
|
|
||||||
return redirect(url_for("index"))
|
|
||||||
|
|
||||||
# --- ФАЙЛОХРАНИЛИЩЕ ---
|
|
||||||
@app.route("/files")
|
|
||||||
@login_required
|
|
||||||
def files_list():
|
|
||||||
with Session(engine) as db_session:
|
|
||||||
# Сортируем по дате (новые сверху), джойним с User чтобы знать кто загрузил
|
|
||||||
files = db_session.exec(
|
|
||||||
select(PortalFile, User.username)
|
|
||||||
.join(User, PortalFile.uploaded_by == User.id)
|
|
||||||
.order_by(PortalFile.uploaded_at.desc())
|
|
||||||
).all()
|
|
||||||
return render_template("files.html", files=files)
|
|
||||||
|
|
||||||
@app.route("/files/upload", methods=["POST"])
|
|
||||||
@login_required
|
|
||||||
def file_upload():
|
|
||||||
if 'file' not in request.files:
|
|
||||||
return redirect(url_for('files_list'))
|
|
||||||
|
|
||||||
file = request.files['file']
|
|
||||||
if file.filename == '':
|
|
||||||
return redirect(url_for('files_list'))
|
|
||||||
|
|
||||||
if file:
|
|
||||||
original_name = file.filename
|
|
||||||
# Генерируем уникальное имя для хранения на диске (чтобы файлы с одинаковыми именами не перезаписывали друг друга)
|
|
||||||
ext = os.path.splitext(original_name)[1]
|
|
||||||
stored_name = f"{uuid.uuid4().hex}{ext}"
|
|
||||||
|
|
||||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name)
|
|
||||||
file.save(filepath)
|
|
||||||
|
|
||||||
size_bytes = os.path.getsize(filepath)
|
|
||||||
|
|
||||||
with Session(engine) as db_session:
|
|
||||||
new_file = PortalFile(
|
|
||||||
original_name=original_name,
|
|
||||||
stored_name=stored_name,
|
|
||||||
size_bytes=size_bytes,
|
|
||||||
uploaded_by=session.get("user_id")
|
|
||||||
)
|
|
||||||
db_session.add(new_file)
|
|
||||||
db_session.commit()
|
|
||||||
|
|
||||||
return redirect(url_for('files_list'))
|
|
||||||
|
|
||||||
@app.route("/files/download/<int:file_id>")
|
|
||||||
@login_required
|
|
||||||
def file_download(file_id):
|
|
||||||
with Session(engine) as db_session:
|
|
||||||
db_file = db_session.get(PortalFile, file_id)
|
|
||||||
if db_file:
|
|
||||||
return send_from_directory(
|
|
||||||
app.config['UPLOAD_FOLDER'],
|
|
||||||
db_file.stored_name,
|
|
||||||
as_attachment=True,
|
|
||||||
download_name=db_file.original_name
|
|
||||||
)
|
|
||||||
return "Файл не найден", 404
|
|
||||||
|
|
||||||
@app.route("/files/<int:file_id>/delete", methods=["POST"])
|
|
||||||
@login_required
|
|
||||||
def file_delete(file_id):
|
|
||||||
with Session(engine) as db_session:
|
|
||||||
db_file = db_session.get(PortalFile, file_id)
|
|
||||||
if db_file:
|
|
||||||
# Удалять может только админ или тот, кто загрузил
|
|
||||||
if session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id"):
|
|
||||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], db_file.stored_name)
|
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath) # Удаляем с диска
|
|
||||||
db_session.delete(db_file) # Удаляем из БД
|
|
||||||
db_session.commit()
|
|
||||||
return redirect(url_for("files_list"))
|
|
||||||
|
|
||||||
# --- ПРОЕКТЫ ---
|
|
||||||
@app.route("/projects")
|
|
||||||
def projects_list():
|
|
||||||
with Session(engine) as db_session:
|
|
||||||
projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all()
|
|
||||||
return render_template("projects.html", projects=projects, role=session.get("role"))
|
|
||||||
|
|
||||||
@app.route("/projects/new", methods=["GET", "POST"])
|
|
||||||
@require_role(["admin", "professor", "lead"])
|
|
||||||
def project_new():
|
|
||||||
if request.method == "POST":
|
|
||||||
title = request.form.get("title")
|
|
||||||
description = request.form.get("description")
|
|
||||||
status = request.form.get("status")
|
|
||||||
gitea_url = request.form.get("gitea_url")
|
|
||||||
is_public = request.form.get("is_public") == "on"
|
|
||||||
|
|
||||||
with Session(engine) as db_session:
|
|
||||||
new_proj = Project(
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
status=status,
|
|
||||||
gitea_url=gitea_url,
|
|
||||||
is_public=is_public,
|
|
||||||
owner_id=session.get("user_id")
|
|
||||||
)
|
|
||||||
db_session.add(new_proj)
|
|
||||||
db_session.commit()
|
|
||||||
return redirect(url_for("projects_list"))
|
|
||||||
return render_template("project_form.html")
|
|
||||||
|
|
||||||
@app.route("/projects/<int:project_id>/delete", methods=["POST"])
|
|
||||||
@require_role(["admin", "professor", "lead"])
|
|
||||||
def project_delete(project_id):
|
|
||||||
with Session(engine) as db_session:
|
|
||||||
proj = db_session.get(Project, project_id)
|
|
||||||
if proj:
|
|
||||||
if session.get("role") == "admin" or proj.owner_id == session.get("user_id"):
|
|
||||||
db_session.delete(proj)
|
|
||||||
db_session.commit()
|
|
||||||
return redirect(url_for("projects_list"))
|
|
||||||
|
|
||||||
# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ ---
|
|
||||||
@app.route("/admin/users")
|
|
||||||
@require_role("admin")
|
|
||||||
def admin_users():
|
|
||||||
with Session(engine) as db_session:
|
|
||||||
users = db_session.exec(select(User).order_by(User.username)).all()
|
|
||||||
return render_template("admin_users.html", users=users)
|
|
||||||
|
|
||||||
@app.route("/admin/users/<int:user_id>/role", methods=["POST"])
|
|
||||||
@require_role("admin")
|
|
||||||
def change_user_role(user_id):
|
|
||||||
new_role = request.form.get("role")
|
|
||||||
valid_roles = ["admin", "professor", "lead", "member", "student", "guest"]
|
|
||||||
if new_role not in valid_roles:
|
|
||||||
return "Ошибка: неверная роль", 400
|
|
||||||
|
|
||||||
with Session(engine) as db_session:
|
|
||||||
user = db_session.get(User, user_id)
|
|
||||||
if user:
|
|
||||||
if user.id == session.get("user_id") and new_role != "admin":
|
|
||||||
return "Ошибка: нельзя лишить самого себя прав администратора", 400
|
|
||||||
user.role = new_role
|
|
||||||
db_session.add(user)
|
|
||||||
db_session.commit()
|
|
||||||
return redirect(url_for("admin_users"))
|
|
||||||
|
|
||||||
# --- РАСПИСАНИЕ: ПУБЛИЧНАЯ ЧАСТЬ ---
|
|
||||||
@app.route("/schedule")
|
@app.route("/schedule")
|
||||||
def public_schedule():
|
def public_schedule():
|
||||||
with Session(engine) as db_session:
|
with Session(engine) as db_session:
|
||||||
current_status = db_session.exec(select(GlobalStatus)).first()
|
current_status = db_session.exec(select(GlobalStatus)).first()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
events = db_session.exec(
|
events = db_session.exec(select(Event).where(Event.is_public == True).where(Event.start_time >= now).order_by(Event.start_time).limit(10)).all()
|
||||||
select(Event)
|
|
||||||
.where(Event.is_public == True)
|
|
||||||
.where(Event.start_time >= now)
|
|
||||||
.order_by(Event.start_time)
|
|
||||||
.limit(10)
|
|
||||||
).all()
|
|
||||||
return render_template("schedule.html", status=current_status, events=events)
|
return render_template("schedule.html", status=current_status, events=events)
|
||||||
|
|
||||||
# --- АССИСТЕНТ: API ---
|
|
||||||
@app.route("/api/assistant", methods=["POST"])
|
@app.route("/api/assistant", methods=["POST"])
|
||||||
def api_assistant():
|
def api_assistant():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or "message" not in data:
|
if not data or "message" not in data: return jsonify({"error": "Нет сообщения"}), 400
|
||||||
return jsonify({"error": "Нет сообщения"}), 400
|
return jsonify({"reply": assistant.get_response(data["message"])})
|
||||||
reply = assistant.get_response(data["message"])
|
|
||||||
return jsonify({"reply": reply})
|
|
||||||
|
|
||||||
# --- РАСПИСАНИЕ: АДМИН-ПАНЕЛЬ ---
|
|
||||||
@app.route("/admin/schedule")
|
@app.route("/admin/schedule")
|
||||||
@require_role(["admin", "professor"])
|
@require_role(["admin", "professor"])
|
||||||
def admin_schedule():
|
def admin_schedule():
|
||||||
with Session(engine) as db_session:
|
with Session(engine) as db_session:
|
||||||
current_status = db_session.exec(select(GlobalStatus)).first()
|
current_status = db_session.exec(select(GlobalStatus)).first()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
events = db_session.exec(
|
events = db_session.exec(select(Event).where(Event.start_time >= now).order_by(Event.start_time)).all()
|
||||||
select(Event)
|
|
||||||
.where(Event.start_time >= now)
|
|
||||||
.order_by(Event.start_time)
|
|
||||||
).all()
|
|
||||||
return render_template("admin_schedule.html", status=current_status, events=events)
|
return render_template("admin_schedule.html", status=current_status, events=events)
|
||||||
|
|
||||||
@app.route("/admin/schedule/status", methods=["POST"])
|
@app.route("/admin/schedule/status", methods=["POST"])
|
||||||
|
|
@ -308,7 +346,6 @@ def update_status():
|
||||||
status_val = request.form.get("status")
|
status_val = request.form.get("status")
|
||||||
delay = int(request.form.get("delay_minutes") or 0)
|
delay = int(request.form.get("delay_minutes") or 0)
|
||||||
message = request.form.get("message")
|
message = request.form.get("message")
|
||||||
|
|
||||||
with Session(engine) as db_session:
|
with Session(engine) as db_session:
|
||||||
current_status = db_session.exec(select(GlobalStatus)).first()
|
current_status = db_session.exec(select(GlobalStatus)).first()
|
||||||
if current_status:
|
if current_status:
|
||||||
|
|
@ -325,26 +362,10 @@ def update_status():
|
||||||
def add_event():
|
def add_event():
|
||||||
title = request.form.get("title")
|
title = request.form.get("title")
|
||||||
event_type = request.form.get("event_type")
|
event_type = request.form.get("event_type")
|
||||||
start_str = request.form.get("start_time")
|
start_time = datetime.strptime(request.form.get("start_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
|
||||||
end_str = request.form.get("end_time")
|
end_time = datetime.strptime(request.form.get("end_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
|
||||||
location = request.form.get("location")
|
|
||||||
description = request.form.get("description")
|
|
||||||
is_public = request.form.get("is_public") == "on"
|
|
||||||
|
|
||||||
start_time = datetime.strptime(start_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
|
|
||||||
end_time = datetime.strptime(end_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
with Session(engine) as db_session:
|
with Session(engine) as db_session:
|
||||||
new_event = Event(
|
db_session.add(Event(title=title, event_type=event_type, start_time=start_time, end_time=end_time, location=request.form.get("location"), description=request.form.get("description"), is_public=request.form.get("is_public") == "on"))
|
||||||
title=title,
|
|
||||||
event_type=event_type,
|
|
||||||
start_time=start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
location=location,
|
|
||||||
description=description,
|
|
||||||
is_public=is_public
|
|
||||||
)
|
|
||||||
db_session.add(new_event)
|
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return redirect(url_for("admin_schedule"))
|
return redirect(url_for("admin_schedule"))
|
||||||
|
|
||||||
|
|
@ -357,3 +378,120 @@ def delete_event(event_id):
|
||||||
db_session.delete(event)
|
db_session.delete(event)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return redirect(url_for("admin_schedule"))
|
return redirect(url_for("admin_schedule"))
|
||||||
|
|
||||||
|
# --- ПРОЕКТЫ ---
|
||||||
|
@app.route("/projects")
|
||||||
|
def projects_list():
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all()
|
||||||
|
return render_template("projects.html", projects=projects, role=session.get("role"))
|
||||||
|
|
||||||
|
@app.route("/projects/new", methods=["GET", "POST"])
|
||||||
|
@require_role(["admin", "professor", "lead"])
|
||||||
|
def project_new():
|
||||||
|
if request.method == "POST":
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
db_session.add(Project(title=request.form.get("title"), description=request.form.get("description"), status=request.form.get("status"), gitea_url=request.form.get("gitea_url"), is_public=request.form.get("is_public") == "on", owner_id=session.get("user_id")))
|
||||||
|
db_session.commit()
|
||||||
|
return redirect(url_for("projects_list"))
|
||||||
|
return render_template("project_form.html")
|
||||||
|
|
||||||
|
@app.route("/projects/<int:project_id>/delete", methods=["POST"])
|
||||||
|
@require_role(["admin", "professor", "lead"])
|
||||||
|
def project_delete(project_id):
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
proj = db_session.get(Project, project_id)
|
||||||
|
if proj and (session.get("role") == "admin" or proj.owner_id == session.get("user_id")):
|
||||||
|
db_session.delete(proj)
|
||||||
|
db_session.commit()
|
||||||
|
return redirect(url_for("projects_list"))
|
||||||
|
|
||||||
|
# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ ---
|
||||||
|
@app.route("/admin/users")
|
||||||
|
@require_role("admin")
|
||||||
|
def admin_users():
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
users = db_session.exec(select(User).order_by(User.username)).all()
|
||||||
|
return render_template("admin_users.html", users=users)
|
||||||
|
|
||||||
|
@app.route("/admin/users/<int:user_id>/role", methods=["POST"])
|
||||||
|
@require_role("admin")
|
||||||
|
def change_user_role(user_id):
|
||||||
|
new_role = request.form.get("role")
|
||||||
|
valid_roles = ["admin", "professor", "lead", "member", "student", "guest"]
|
||||||
|
if new_role not in valid_roles: return "Ошибка", 400
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
user = db_session.get(User, user_id)
|
||||||
|
if user:
|
||||||
|
if user.id == session.get("user_id") and new_role != "admin": return "Нельзя снять с себя админа", 400
|
||||||
|
user.role = new_role
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
return redirect(url_for("admin_users"))
|
||||||
|
|
||||||
|
# --- НОВЫЙ РОУТ: УДАЛЕНИЕ ПОЛЬЗОВАТЕЛЯ ---
|
||||||
|
@app.route("/admin/users/<int:user_id>/delete", methods=["POST"])
|
||||||
|
@require_role("admin")
|
||||||
|
def delete_user(user_id):
|
||||||
|
if user_id == session.get("user_id"):
|
||||||
|
return "Ошибка: нельзя удалить самого себя", 400
|
||||||
|
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
# Сначала удаляем все сессии пользователя (чтобы не было ошибки внешнего ключа)
|
||||||
|
sessions = db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all()
|
||||||
|
for s in sessions:
|
||||||
|
db_session.delete(s)
|
||||||
|
|
||||||
|
# Затем удаляем самого пользователя
|
||||||
|
user = db_session.get(User, user_id)
|
||||||
|
if user:
|
||||||
|
db_session.delete(user)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
return redirect(url_for("admin_users"))
|
||||||
|
|
||||||
|
# --- ФАЙЛЫ ---
|
||||||
|
@app.route("/files")
|
||||||
|
@login_required
|
||||||
|
def files_list():
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
files = db_session.exec(select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id).order_by(PortalFile.uploaded_at.desc())).all()
|
||||||
|
return render_template("files.html", files=files)
|
||||||
|
|
||||||
|
@app.route("/files/upload", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def file_upload():
|
||||||
|
if 'file' not in request.files: return redirect(url_for('files_list'))
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '': return redirect(url_for('files_list'))
|
||||||
|
if file:
|
||||||
|
original_name = file.filename
|
||||||
|
ext = os.path.splitext(original_name)[1]
|
||||||
|
stored_name = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name)
|
||||||
|
file.save(filepath)
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
db_session.add(PortalFile(original_name=original_name, stored_name=stored_name, size_bytes=os.path.getsize(filepath), uploaded_by=session.get("user_id")))
|
||||||
|
db_session.commit()
|
||||||
|
return redirect(url_for('files_list'))
|
||||||
|
|
||||||
|
@app.route("/files/download/<int:file_id>")
|
||||||
|
@login_required
|
||||||
|
def file_download(file_id):
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
db_file = db_session.get(PortalFile, file_id)
|
||||||
|
if db_file:
|
||||||
|
return send_from_directory(app.config['UPLOAD_FOLDER'], db_file.stored_name, as_attachment=True, download_name=db_file.original_name)
|
||||||
|
return "Файл не найден", 404
|
||||||
|
|
||||||
|
@app.route("/files/<int:file_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def file_delete(file_id):
|
||||||
|
with Session(engine) as db_session:
|
||||||
|
db_file = db_session.get(PortalFile, file_id)
|
||||||
|
if db_file and (session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id")):
|
||||||
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], db_file.stored_name)
|
||||||
|
if os.path.exists(filepath): os.remove(filepath)
|
||||||
|
db_session.delete(db_file)
|
||||||
|
db_session.commit()
|
||||||
|
return redirect(url_for("files_list"))
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,23 @@ from datetime import datetime, timezone
|
||||||
|
|
||||||
class User(SQLModel, table=True):
|
class User(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
gitea_id: int = Field(unique=True, index=True)
|
gitea_id: Optional[int] = Field(default=None, unique=True, index=True) # Теперь может быть None, если регаемся локально
|
||||||
username: str = Field(index=True)
|
username: str = Field(unique=True, index=True)
|
||||||
|
password_hash: Optional[str] = None
|
||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = Field(default=None, unique=True)
|
||||||
|
organization: Optional[str] = None # ННГУ, Неймарк, Ядро и т.д.
|
||||||
role: str = Field(default="student")
|
role: str = Field(default="student")
|
||||||
is_active: bool = Field(default=True)
|
is_active: bool = Field(default=True)
|
||||||
|
is_verified: bool = Field(default=False)
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
class UserSession(SQLModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
session_token: str = Field(unique=True, index=True)
|
||||||
|
user_id: int = Field(foreign_key="user.id")
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
user_agent: Optional[str] = None
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
class Event(SQLModel, table=True):
|
class Event(SQLModel, table=True):
|
||||||
|
|
@ -41,11 +52,10 @@ class Project(SQLModel, table=True):
|
||||||
is_public: bool = Field(default=True)
|
is_public: bool = Field(default=True)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
# --- НОВАЯ МОДЕЛЬ ---
|
|
||||||
class PortalFile(SQLModel, table=True):
|
class PortalFile(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
original_name: str
|
original_name: str
|
||||||
stored_name: str # Имя на диске (уникальное)
|
stored_name: str
|
||||||
size_bytes: int
|
size_bytes: int
|
||||||
uploaded_by: int = Field(foreign_key="user.id")
|
uploaded_by: int = Field(foreign_key="user.id")
|
||||||
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@
|
||||||
select { padding: 6px; border-radius: 4px; background: #0d1117; color: white; border: 1px solid #30363d; }
|
select { padding: 6px; border-radius: 4px; background: #0d1117; color: white; border: 1px solid #30363d; }
|
||||||
button { padding: 6px 12px; background: #238636; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
button { padding: 6px 12px; background: #238636; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||||
button:hover { background: #2ea043; }
|
button:hover { background: #2ea043; }
|
||||||
|
button.danger { background: #da3633; }
|
||||||
|
button.danger:hover { background: #f85149; }
|
||||||
a { color: #58a6ff; text-decoration: none; }
|
a { color: #58a6ff; text-decoration: none; }
|
||||||
|
.actions-cell { display: flex; gap: 10px; align-items: center; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -35,7 +38,7 @@
|
||||||
<th>ФИО</th>
|
<th>ФИО</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Роль</th>
|
<th>Роль</th>
|
||||||
<th>Действие</th>
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -49,17 +52,27 @@
|
||||||
<span class="badge role-{{ u.role }}">{{ u.role }}</span>
|
<span class="badge role-{{ u.role }}">{{ u.role }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form action="/admin/users/{{ u.id }}/role" method="POST" style="display:flex; gap: 10px;">
|
<div class="actions-cell">
|
||||||
<select name="role">
|
<!-- Форма смены роли -->
|
||||||
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
|
<form action="/admin/users/{{ u.id }}/role" method="POST" style="display:flex; gap: 5px;">
|
||||||
<option value="professor" {% if u.role == 'professor' %}selected{% endif %}>professor</option>
|
<select name="role">
|
||||||
<option value="lead" {% if u.role == 'lead' %}selected{% endif %}>lead</option>
|
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
|
||||||
<option value="member" {% if u.role == 'member' %}selected{% endif %}>member</option>
|
<option value="professor" {% if u.role == 'professor' %}selected{% endif %}>professor</option>
|
||||||
<option value="student" {% if u.role == 'student' %}selected{% endif %}>student</option>
|
<option value="lead" {% if u.role == 'lead' %}selected{% endif %}>lead</option>
|
||||||
<option value="guest" {% if u.role == 'guest' %}selected{% endif %}>guest</option>
|
<option value="member" {% if u.role == 'member' %}selected{% endif %}>member</option>
|
||||||
</select>
|
<option value="student" {% if u.role == 'student' %}selected{% endif %}>student</option>
|
||||||
<button type="submit">Сменить</button>
|
<option value="guest" {% if u.role == 'guest' %}selected{% endif %}>guest</option>
|
||||||
</form>
|
</select>
|
||||||
|
<button type="submit">Сменить</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Форма удаления (нельзя удалить себя) -->
|
||||||
|
{% if u.id != session.get('user_id') %}
|
||||||
|
<form action="/admin/users/{{ u.id }}/delete" method="POST" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя {{ u.username }}?');">
|
||||||
|
<button type="submit" class="danger">Удалить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
32
app/templates/forgot_password.html
Normal file
32
app/templates/forgot_password.html
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Сброс пароля — Morozovka Portal</title>
|
||||||
|
<style>
|
||||||
|
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
|
||||||
|
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 300px; }
|
||||||
|
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
|
||||||
|
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
|
||||||
|
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
|
||||||
|
input { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
|
||||||
|
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
||||||
|
.links { margin-top: 15px; text-align: center; font-size: 0.85rem; }
|
||||||
|
.links a { color: #58a6ff; text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Сброс пароля</h2>
|
||||||
|
<p style="color: #8b949e; font-size: 0.85rem; text-align: center; margin-bottom: 15px;">Введите свой Email, и мы отправим ссылку для сброса.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="email" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Отправить ссылку</button>
|
||||||
|
</form>
|
||||||
|
<div class="links"><a href="/login">← Назад ко входу</a></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -39,14 +39,14 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>Скелет системы успешно запущен!</p>
|
<p>Платформа для команды и проектов</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a href="/login" class="btn">Войти через Gitea</a>
|
<a href="/login" class="btn">Войти / Регистрация</a>
|
||||||
<a href="/schedule" class="btn btn-schedule">Расписание</a>
|
<a href="/schedule" class="btn btn-schedule">Расписание</a>
|
||||||
<a href="/projects" class="btn btn-projects">Проекты</a>
|
<a href="/projects" class="btn btn-projects">Проекты</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="badge">Stage 3.2: File Storage OK</div>
|
<div class="badge">Stage 1 (Identity): Complete</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
50
app/templates/login.html
Normal file
50
app/templates/login.html
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Вход — Morozovka Portal</title>
|
||||||
|
<style>
|
||||||
|
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
|
||||||
|
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 300px; }
|
||||||
|
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
|
||||||
|
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
|
||||||
|
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
|
||||||
|
input { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
|
||||||
|
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; }
|
||||||
|
button:hover { background: #2ea043; }
|
||||||
|
.gitea-btn { background: #30363d; margin-top: 15px; display: block; text-align: center; text-decoration: none; padding: 10px; border-radius: 5px; color: white; font-weight: bold; font-size: 0.9rem; }
|
||||||
|
.gitea-btn:hover { background: #21262d; }
|
||||||
|
.links { margin-top: 15px; text-align: center; font-size: 0.85rem; }
|
||||||
|
.links a { color: #58a6ff; text-decoration: none; display: block; margin-bottom: 5px; }
|
||||||
|
.msg { color: #3fb950; text-align: center; margin-bottom: 15px; font-size: 0.9rem; }
|
||||||
|
.error { color: #f85149; text-align: center; margin-bottom: 15px; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Вход</h2>
|
||||||
|
{% if msg %}<div class="msg">{{ msg }}</div>{% endif %}
|
||||||
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Логин</label>
|
||||||
|
<input type="text" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Войти</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a href="/login/gitea" class="gitea-btn">Войти через Gitea</a>
|
||||||
|
|
||||||
|
<div class="links">
|
||||||
|
<a href="/register">Нет аккаунта? Регистрация</a>
|
||||||
|
<a href="/forgot_password">Забыли пароль?</a>
|
||||||
|
<a href="/" style="color: #8b949e;">← На главную</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
.field { margin-bottom: 15px; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
|
.field { margin-bottom: 15px; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
|
||||||
.label { color: #8b949e; font-size: 0.8rem; text-transform: uppercase; }
|
.label { color: #8b949e; font-size: 0.8rem; text-transform: uppercase; }
|
||||||
.value { font-size: 1.1rem; font-family: monospace; }
|
.value { font-size: 1.1rem; font-family: monospace; }
|
||||||
a { color: #58a6ff; }
|
a { color: #58a6ff; text-decoration: none; }
|
||||||
|
.btn-sessions { display: inline-block; margin-top: 15px; padding: 8px 15px; background: #21262d; border: 1px solid #30363d; border-radius: 5px; }
|
||||||
|
.btn-sessions:hover { background: #30363d; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -18,7 +20,7 @@
|
||||||
<h2>Личный кабинет</h2>
|
<h2>Личный кабинет</h2>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label">Логин в Gitea</div>
|
<div class="label">Логин</div>
|
||||||
<div class="value">{{ user.username }}</div>
|
<div class="value">{{ user.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -29,7 +31,14 @@
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label">Email</div>
|
<div class="label">Email</div>
|
||||||
<div class="value">{{ user.email or 'Не указано' }}</div>
|
<div class="value">{{ user.email or 'Не указано' }}
|
||||||
|
{% if user.is_verified %}<span style="color: #3fb950; font-size: 0.8rem;">✓ Подтвержден</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">Организация</div>
|
||||||
|
<div class="value">{{ user.organization or 'Не указана' }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -37,8 +46,11 @@
|
||||||
<div class="value">{{ user.role }}</div>
|
<div class="value">{{ user.role }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 20px;">
|
<div style="margin-top: 20px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
<a href="/">← На главную</a> | <a href="/logout" style="color: #f85149;">Выйти</a>
|
<div>
|
||||||
|
<a href="/">← На главную</a> | <a href="/logout" style="color: #f85149;">Выйти</a>
|
||||||
|
</div>
|
||||||
|
<a href="/sessions" class="btn-sessions">Безопасность (сессии)</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
59
app/templates/register.html
Normal file
59
app/templates/register.html
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Регистрация — Morozovka Portal</title>
|
||||||
|
<style>
|
||||||
|
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
|
||||||
|
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 350px; }
|
||||||
|
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
|
||||||
|
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
|
||||||
|
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
|
||||||
|
input, select { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
|
||||||
|
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; }
|
||||||
|
button:hover { background: #2ea043; }
|
||||||
|
.error { color: #f85149; text-align: center; margin-bottom: 15px; font-size: 0.9rem; }
|
||||||
|
.links { margin-top: 15px; text-align: center; font-size: 0.85rem; }
|
||||||
|
.links a { color: #58a6ff; text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Регистрация</h2>
|
||||||
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>ФИО</label>
|
||||||
|
<input type="text" name="full_name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Организация</label>
|
||||||
|
<select name="organization">
|
||||||
|
<option value="ННГУ">ННГУ</option>
|
||||||
|
<option value="Неймарк">Неймарк</option>
|
||||||
|
<option value="Ядро">Ядро</option>
|
||||||
|
<option value="Внешний">Внешний</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Логин</label>
|
||||||
|
<input type="text" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" name="email" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<input type="password" name="password" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Зарегистрироваться</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="links">
|
||||||
|
<a href="/login">Уже есть аккаунт? Войти</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
app/templates/reset_password.html
Normal file
28
app/templates/reset_password.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Новый пароль — Morozovka Portal</title>
|
||||||
|
<style>
|
||||||
|
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
|
||||||
|
.card { background: #161b22; padding: 30px; border-radius: 8px; border: 1px solid #30363d; width: 300px; }
|
||||||
|
h2 { color: #58a6ff; margin-top: 0; text-align: center; }
|
||||||
|
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
|
||||||
|
label { margin-bottom: 5px; color: #8b949e; font-size: 0.85rem; }
|
||||||
|
input { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; }
|
||||||
|
button { width: 100%; padding: 10px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Новый пароль</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Введите новый пароль</label>
|
||||||
|
<input type="password" name="password" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Сохранить пароль</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
51
app/templates/sessions.html
Normal file
51
app/templates/sessions.html
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Активные сессии — Morozovka Portal</title>
|
||||||
|
<style>
|
||||||
|
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
|
||||||
|
.container { max-width: 800px; margin: 0 auto; }
|
||||||
|
h2 { color: #58a6ff; }
|
||||||
|
table { width: 100%; border-collapse: collapse; background: #161b22; margin-top: 20px; }
|
||||||
|
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
|
||||||
|
th { background: #21262d; color: #8b949e; }
|
||||||
|
.current { background: rgba(35, 134, 54, 0.2); }
|
||||||
|
.btn-danger { background: #da3633; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 20px; }
|
||||||
|
.btn-danger:hover { background: #f85149; }
|
||||||
|
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<a href="/profile" class="back-link">← В профиль</a>
|
||||||
|
<h2>Управление устройствами</h2>
|
||||||
|
<p style="color: #8b949e;">Список всех мест, где выполнен вход в ваш аккаунт.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Устройство (User-Agent)</th>
|
||||||
|
<th>IP-адрес</th>
|
||||||
|
<th>Дата входа</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sessions %}
|
||||||
|
<tr class="{% if s.session_token == current_token %}current{% endif %}">
|
||||||
|
<td><small>{{ s.user_agent or 'Неизвестно' }}</small></td>
|
||||||
|
<td>{{ s.ip_address }}</td>
|
||||||
|
<td>{{ s.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||||
|
<td>{% if s.session_token == current_token %}<b style="color: #3fb950;">Текущая</b>{% else %}-{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<form action="/sessions/logout_all" method="POST" onsubmit="return confirm('Вы уверены, что хотите выйти на всех устройствах?');">
|
||||||
|
<button type="submit" class="btn-danger">Выйти везде</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user