import os import uuid import smtplib import pytz import threading 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, make_response from sqlalchemy import or_ from icalendar import Calendar, Event as ICalEvent from .db import init_db, engine from . import config, auth, assistant, gitea_api, llm_assistant from sqlmodel import Session, select from .models import ( User, Event, GlobalStatus, Project, PortalFile, UserSession, ProjectMember, EventSubscriber, ProjectTask, Workshop, WorkshopSession, WorkshopRegistration, WorkshopMaterial, WorkshopFeedback, Notification, ) from datetime import datetime, timezone, timedelta from functools import wraps app = Flask(__name__) app.secret_key = config.WEB_SECRET serializer = URLSafeTimedSerializer(app.secret_key) MSK = pytz.timezone("Europe/Moscow") # ══════════════════════════════════════════════════════════════════════ # БАЗОВЫЕ ХЕЛПЕРЫ И ДЕКОРАТОРЫ (должны быть ДО всех @app.route) # ══════════════════════════════════════════════════════════════════════ def to_msk(dt): if dt is None: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.astimezone(MSK) @app.template_filter("msk_strftime") def _msk_strftime(dt, fmt): if dt is None: return "" return to_msk(dt).strftime(fmt) @app.template_filter("date_ru") def _date_ru(d): if not d: return "" try: return datetime.strptime(str(d)[:10], "%Y-%m-%d").strftime("%d.%m.%Y") except Exception: return str(d) def require_role(allowed_roles): if isinstance(allowed_roles, str): allowed_roles = [allowed_roles] def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): if session.get("role") not in allowed_roles: return "403: Доступ запрещен.", 403 return f(*args, **kwargs) return decorated_function return decorator def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): if not session.get("user_id"): return redirect(url_for("login")) return f(*args, **kwargs) return decorated_function def parse_datetime(dt_str): if not dt_str: return None clean_str = dt_str.replace('T', ' ').strip() if len(clean_str) > 16: clean_str = clean_str[:16] naive = datetime.strptime(clean_str, "%Y-%m-%d %H:%M") return MSK.localize(naive).astimezone(timezone.utc) def normalize_tags(raw): if not raw: return None parts = {t.strip().lower() for t in raw.split(",") if t.strip()} return ",".join(sorted(parts)) if parts else None def status_label(status_val, delay): if status_val == "delayed": return f"Опаздываю на {delay} мин" return STATUS_LABELS.get(status_val, status_val) def event_summary_text(ev): start = to_msk(ev.start_time).strftime('%d.%m.%Y %H:%M') end = to_msk(ev.end_time).strftime('%H:%M') lines = [f"Название: {ev.title}", f"Тип: {ev.event_type}", f"Когда: {start} – {end}"] if ev.location: lines.append(f"Место: {ev.location}") if ev.description: lines.append(f"Описание: {ev.description}") return "\n".join(lines) def project_permissions(project, user_id, global_role, membership): is_admin = global_role == "admin" is_owner = user_id is not None and project.owner_id == user_id project_role = None if is_owner: project_role = "manager" elif membership: project_role = membership.role can_manage = is_admin or is_owner or project_role == "manager" can_edit = can_manage or project_role == "editor" can_delete = is_admin or is_owner can_upload = can_edit return { "is_admin": is_admin, "is_owner": is_owner, "project_role": project_role, "can_manage": can_manage, "can_edit": can_edit, "can_delete": can_delete, "can_upload": can_upload, } def workshop_permissions(workshop, user_id, global_role): is_admin = global_role in ("admin", "professor") is_lead = user_id is not None and workshop.lead_id == user_id can_manage = is_admin or is_lead return { "is_admin": global_role == "admin", "is_lead": is_lead, "can_manage": can_manage, "can_delete": global_role == "admin" or is_lead, } def _ws_registration(db_session, workshop_id, user_id): if not user_id: return None return db_session.exec( select(WorkshopRegistration) .where(WorkshopRegistration.workshop_id == workshop_id) .where(WorkshopRegistration.user_id == user_id) ).first() def _ws_session_helpers(db_session, workshop_id): return db_session.exec( select(WorkshopSession) .where(WorkshopSession.workshop_id == workshop_id) .order_by(WorkshopSession.start_time) ).all() def _ws_count_registered(db_session, workshop_id): regs = db_session.exec( select(WorkshopRegistration) .where(WorkshopRegistration.workshop_id == workshop_id) .where(WorkshopRegistration.status.in_(("registered", "attended"))) ).all() return len(regs) def get_all_tags(db_session): projects = db_session.exec(select(Project)).all() tags = set() for p in projects: if p.tags: for t in p.tags.split(","): t = t.strip().lower() if t: tags.add(t) return sorted(tags) def resolve_gitea_repo(form_data, current_url=None, current_repo=None): raw_repo = (form_data.get("gitea_repo") or "").strip() or None raw_url = (form_data.get("gitea_url") or "").strip() or None if raw_repo: raw_repo = raw_repo.strip("/") if "/" in raw_repo: return raw_repo if raw_url: parsed = gitea_api.extract_repo_from_url(raw_url) if parsed: return parsed return None def get_user_subscription(db_session, user): if not user: return None if not user.id and not user.email: return None conds = [] if user.id: conds.append(EventSubscriber.user_id == user.id) if user.email: conds.append(EventSubscriber.email == user.email) if not conds: return None return db_session.exec(select(EventSubscriber).where(or_(*conds))).first() # ══════════════════════════════════════════════════════════════════════ # КОНСТАНТЫ # ══════════════════════════════════════════════════════════════════════ STATUS_LABELS = { "free": "Свободен / Вне расписания", "on_place": "На месте", "away": "Вне вуза", "cancelled": "Отменено", } PROJECT_ROLE_RANK = {"viewer": 1, "editor": 2, "manager": 3} TASK_STATUSES = ["backlog", "todo", "in_progress", "review", "done"] TASK_STATUS_LABELS = { "backlog": "Backlog", "todo": "To Do", "in_progress": "In Progress", "review": "Review", "done": "Done", } TASK_PRIORITIES = ["low", "medium", "high", "critical"] WORKSHOP_CATEGORIES = ["soldering", "embroidery", "embedded", "linux", "other"] WORKSHOP_CATEGORY_LABELS = { "soldering": "Пайка", "embroidery": "Бисероплетение", "embedded": "Embedded", "linux": "Linux", "other": "Другое", } WORKSHOP_DIFFICULTIES = ["beginner", "intermediate", "advanced"] WORKSHOP_DIFFICULTY_LABELS = { "beginner": "Новичок", "intermediate": "Средний", "advanced": "Продвинутый", } WORKSHOP_STATUSES = ["draft", "published", "archived"] WORKSHOP_STATUS_LABELS = { "draft": "Черновик", "published": "Опубликован", "archived": "В архиве", } WORKSHOP_VISIBILITIES = ["public", "internal"] WORKSHOP_VISIBILITY_LABELS = { "public": "Публичный", "internal": "Внутренний", } # ══════════════════════════════════════════════════════════════════════ # НАСТРОЙКА ПРИЛОЖЕНИЯ И СТАРТ # ══════════════════════════════════════════════════════════════════════ UPLOAD_FOLDER = '/var/lib/gitea/portal-files' app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 with app.app_context(): init_db() with Session(engine) as db_session: status = db_session.exec(select(GlobalStatus)).first() if not status: db_session.add(GlobalStatus(status="free", message="Вне расписания")) 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")) # ══════════════════════════════════════════════════════════════════════ # EMAIL + УВЕДОМЛЕНИЯ (in-app) # ══════════════════════════════════════════════════════════════════════ def send_email(to_email, subject, body): if not config.SMTP_SERVER: print("\n" + "="*60, flush=True) print(f"📧 EMAIL ДЛЯ: {to_email}", flush=True) print(f"Тема: {subject}", flush=True) print(body, flush=True) print("="*60 + "\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 notify_user(user_id, title, body=None, link_url=None, kind="info"): if not user_id: return try: with Session(engine) as s: s.add(Notification(user_id=user_id, title=title, body=body, link_url=link_url, kind=kind)) s.commit() except Exception as e: print(f"[notify_user] {e}", flush=True) def notify_users(user_ids, title, body=None, link_url=None, kind="info"): for uid in set(uid for uid in user_ids if uid): notify_user(uid, title, body, link_url, kind) def _notify_subscribers_sync(subject, body, kind): try: with Session(engine) as db_session: subscribers = db_session.exec( select(EventSubscriber).where(EventSubscriber.is_confirmed == True) ).all() for sub in subscribers: if kind == "status" and not sub.notify_status: continue if kind == "event" and not sub.notify_events: continue unsubscribe_url = f"https://portal.morozovka.space/unsubscribe/{sub.unsubscribe_token}" full_body = ( body + "\n\n---\n" + "Открыть расписание: https://portal.morozovka.space/schedule\n" + f"Отписаться: {unsubscribe_url}\n" ) send_email(sub.email, subject, full_body) except Exception as e: print(f"[notify] Ошибка рассылки: {e}", flush=True) def notify_subscribers(subject, body, kind): threading.Thread(target=_notify_subscribers_sync, args=(subject, body, kind), daemon=True).start() # ══════════════════════════════════════════════════════════════════════ # БАЗОВЫЕ РОУТЫ # ══════════════════════════════════════════════════════════════════════ @app.route("/healthz") def healthz(): try: from sqlalchemy import text with engine.connect() as conn: conn.execute(text("SELECT 1")) return jsonify({ "status": "ok", "db": "ok", "llm": "enabled" if llm_assistant.is_enabled() else "disabled", }) except Exception as e: return jsonify({"status": "error", "db": str(e)}), 503 @app.route("/") def index(): return render_template("index.html", username=session.get("username")) @app.route("/api/public-status") def api_public_status(): with Session(engine) as db_session: s = db_session.exec(select(GlobalStatus)).first() if not s: return jsonify({"status": "free", "delay_minutes": 0, "message": None}) return jsonify({ "status": s.status, "delay_minutes": s.delay_minutes or 0, "message": s.message, "updated_at": s.updated_at.isoformat() if s.updated_at else None, }) # ══════════════════════════════════════════════════════════════════════ # УВЕДОМЛЕНИЯ — API # ══════════════════════════════════════════════════════════════════════ @app.route("/api/notifications") @login_required def api_notifications_list(): uid = session.get("user_id") with Session(engine) as s: items = s.exec( select(Notification).where(Notification.user_id == uid) .order_by(Notification.created_at.desc()).limit(20) ).all() unread = s.exec( select(Notification).where(Notification.user_id == uid).where(Notification.is_read == False) ).all() return jsonify({ "items": [{ "id": n.id, "title": n.title, "body": n.body, "link_url": n.link_url, "kind": n.kind, "is_read": n.is_read, "created_at": n.created_at.isoformat() + "Z", } for n in items], "unread": len(unread), }) @app.route("/api/notifications/count") @login_required def api_notifications_count(): uid = session.get("user_id") with Session(engine) as s: unread = s.exec( select(Notification).where(Notification.user_id == uid).where(Notification.is_read == False) ).all() return jsonify({"unread": len(unread)}) @app.route("/api/notifications//read", methods=["POST"]) @login_required def api_notifications_read(nid): uid = session.get("user_id") with Session(engine) as s: n = s.get(Notification, nid) if not n or n.user_id != uid: return jsonify({"ok": False}), 403 n.is_read = True s.add(n) s.commit() return jsonify({"ok": True}) @app.route("/api/notifications/read-all", methods=["POST"]) @login_required def api_notifications_read_all(): uid = session.get("user_id") with Session(engine) as s: for n in s.exec(select(Notification).where(Notification.user_id == uid).where(Notification.is_read == False)).all(): n.is_read = True s.add(n) s.commit() return jsonify({"ok": True}) @app.route("/notifications") @login_required def notifications_page(): uid = session.get("user_id") with Session(engine) as s: items = s.exec( select(Notification).where(Notification.user_id == uid) .order_by(Notification.created_at.desc()).limit(100) ).all() return render_template("notifications_page.html", notifications=items) # ══════════════════════════════════════════════════════════════════════ # АУТЕНТИФИКАЦИЯ # ══════════════════════════════════════════════════════════════════════ @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=True, ) db_session.add(new_user) db_session.commit() return render_template("login.html", msg="Регистрация успешна. Можете войти.") return render_template("register.html") @app.route("/verify_email/") 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(): 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")) return render_template("login.html", msg=request.args.get("msg")) @app.route("/login/gitea") def login_gitea(): gitea_auth_url = ( f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize" f"?client_id={config.GITEA_CLIENT_ID}&response_type=code" f"&redirect_uri={config.GITEA_REDIRECT_URI}" f"&scope=read:user" ) return redirect(gitea_auth_url) @app.route("/auth/callback") def auth_callback(): code = request.args.get("code") if not code: return "Ошибка авторизации Gitea", 400 token = auth.get_gitea_token(code) if not token: return "Ошибка токена Gitea", 400 gitea_user = auth.get_gitea_user(token) if not gitea_user: return "Ошибка профиля Gitea", 400 with Session(engine) as db_session: user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first() if not user: user = db_session.exec(select(User).where(User.email == gitea_user.get("email"))).first() if user: user.gitea_id = gitea_user["id"] else: existing_admin = db_session.exec(select(User).where(User.role == "admin")).first() bootstrap_role = "admin" if not existing_admin else "student" user = User( gitea_id=gitea_user["id"], username=gitea_user["login"], full_name=gitea_user.get("full_name"), email=gitea_user.get("email"), role=bootstrap_role, is_verified=True, ) db_session.add(user) 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["username"] = user.username session["role"] = user.role session["session_id"] = session_token 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).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") send_email(email, "Сброс пароля", f"Для сброса пароля перейди по ссылке:\n{reset_url}\n") return render_template("login.html", msg="Если такой email есть, инструкция отправлена.") return render_template("forgot_password.html") @app.route("/reset_password/", 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": 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(request.form.get("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: for s in db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all(): db_session.delete(s) db_session.commit() session.clear() return redirect(url_for("login", msg="Вы вышли со всех устройств.")) @app.route("/profile") @login_required def profile(): with Session(engine) as db_session: user = db_session.get(User, session.get("user_id")) subscription = get_user_subscription(db_session, user) return render_template("profile.html", user=user, subscription=subscription) # ══════════════════════════════════════════════════════════════════════ # ПОДПИСКИ # ══════════════════════════════════════════════════════════════════════ @app.route("/subscribe/toggle", methods=["POST"]) @login_required def subscribe_toggle(): action = request.form.get("action") notify_status = request.form.get("notify_status") == "on" notify_events = request.form.get("notify_events") == "on" next_url = request.form.get("next") or url_for("profile") if not next_url.startswith("/"): next_url = url_for("profile") with Session(engine) as db_session: user = db_session.get(User, session.get("user_id")) if not user or not user.email: return redirect(next_url) existing = get_user_subscription(db_session, user) if action == "unsubscribe": if existing: db_session.delete(existing) db_session.commit() return redirect(next_url) if existing: existing.email = user.email existing.user_id = user.id existing.notify_status = notify_status existing.notify_events = notify_events existing.is_confirmed = True existing.confirm_token = None db_session.add(existing) else: db_session.add(EventSubscriber( email=user.email, user_id=user.id, unsubscribe_token=str(uuid.uuid4()), is_confirmed=True, confirm_token=None, notify_status=notify_status, notify_events=notify_events, )) db_session.commit() return redirect(next_url) @app.route("/subscribe/confirm/") def subscribe_confirm(token): with Session(engine) as db_session: sub = db_session.exec(select(EventSubscriber).where(EventSubscriber.confirm_token == token)).first() if not sub: return render_template("subscribe_status.html", title="Ссылка недействительна", message="Возможно, ссылка уже использована или устарела.", ok=False) sub.is_confirmed = True sub.confirm_token = None db_session.add(sub) db_session.commit() return render_template("subscribe_status.html", title="Подписка подтверждена!", message=f"Вы будете получать уведомления на {sub.email}.", ok=True, unsubscribe_token=sub.unsubscribe_token) @app.route("/unsubscribe/", methods=["GET", "POST"]) def unsubscribe(token): with Session(engine) as db_session: sub = db_session.exec(select(EventSubscriber).where(EventSubscriber.unsubscribe_token == token)).first() if not sub: return render_template("subscribe_status.html", title="Ссылка недействительна", message="Такой подписки нет.", ok=False) if request.method == "POST": db_session.delete(sub) db_session.commit() return render_template("subscribe_status.html", title="Вы отписаны", message="Больше не будет присылать уведомления.", ok=True) return render_template("subscribe_status.html", title="Отписаться от уведомлений?", message=f"Вы перестанете получать письма на {sub.email}.", ok=True, show_confirm_form=True, confirm_action=url_for("unsubscribe", token=token)) # ══════════════════════════════════════════════════════════════════════ # РАСПИСАНИЕ # ══════════════════════════════════════════════════════════════════════ @app.route("/schedule") def public_schedule(): with Session(engine) as db_session: current_status = db_session.exec(select(GlobalStatus)).first() now = datetime.now(timezone.utc) events = db_session.exec( select(Event).where(Event.is_public == True) .where(Event.start_time >= now - timedelta(days=180)) .where(Event.start_time <= now + timedelta(days=365)) .order_by(Event.start_time) ).all() my_subscription = None current_user = None if session.get("user_id"): current_user = db_session.get(User, session["user_id"]) if current_user: my_subscription = get_user_subscription(db_session, current_user) events_json = [ {"id": e.id, "title": e.title, "type": e.event_type, "start": to_msk(e.start_time).isoformat(), "end": to_msk(e.end_time).isoformat(), "location": e.location, "description": e.description} for e in events ] return render_template("schedule.html", status=current_status, events_json=events_json, my_subscription=my_subscription, current_user=current_user, msg=request.args.get("msg"), error=request.args.get("error")) @app.route("/schedule/export.ics") def export_ics(): with Session(engine) as db_session: events = db_session.exec(select(Event).where(Event.is_public == True)).all() cal = Calendar() cal.add('prodid', '-//Morozovka Portal//mxm.dk//') cal.add('version', '2.0') for ev in events: ievent = ICalEvent() ievent.add('summary', ev.title) ievent.add('dtstart', ev.start_time.astimezone(pytz.utc)) ievent.add('dtend', ev.end_time.astimezone(pytz.utc)) if ev.location: ievent.add('location', ev.location) if ev.description: ievent.add('description', ev.description) cal.add_component(ievent) response = make_response(cal.to_ical()) response.headers["Content-Disposition"] = "attachment; filename=morozovka_schedule.ics" response.headers["Content-Type"] = "text/calendar; charset=utf-8" return response @app.route("/api/assistant", methods=["POST"]) def api_assistant(): data = request.get_json() or {} message = (data.get("message") or "").strip() if not message: return jsonify({"error": "Нет сообщения"}), 400 history = data.get("history") or [] if llm_assistant.is_enabled(): try: reply = llm_assistant.chat(message, history) if reply: return jsonify({"reply": reply, "source": "llm"}) except Exception as e: print(f"[llm] fallback to regex: {e}", flush=True) return jsonify({"reply": assistant.get_response(message), "source": "regex"}) @app.route("/admin/schedule") @require_role(["admin", "professor"]) def admin_schedule(): with Session(engine) as db_session: current_status = db_session.exec(select(GlobalStatus)).first() events = db_session.exec(select(Event).order_by(Event.start_time.desc()).limit(50)).all() return render_template("admin_schedule.html", status=current_status, events=events) @app.route("/admin/schedule/status", methods=["POST"]) @require_role(["admin", "professor"]) def update_status(): status_val = request.form.get("status") delay = int(request.form.get("delay_minutes") or 0) message = request.form.get("message") with Session(engine) as db_session: current_status = db_session.exec(select(GlobalStatus)).first() if current_status: old_label = status_label(current_status.status, current_status.delay_minutes) current_status.status = status_val current_status.delay_minutes = delay current_status.message = message current_status.updated_at = datetime.now(timezone.utc) db_session.add(current_status) db_session.commit() new_label = status_label(status_val, delay) subject = f"[Расписание] Статус: {new_label}" body = f"Новый статус: {new_label}\nБыло: {old_label}\n" if message: body += f"Сообщение: {message}\n" body += f"Обновлено: {datetime.now(MSK).strftime('%d.%m.%Y %H:%M')}\n" notify_subscribers(subject, body, kind="status") return redirect(url_for("admin_schedule")) @app.route("/admin/schedule/event/add", methods=["POST"]) @require_role(["admin", "professor"]) def add_event(): title = request.form.get("title") event_type = request.form.get("event_type") start_time = parse_datetime(request.form.get("start_time")) end_time = parse_datetime(request.form.get("end_time")) with Session(engine) as db_session: ev = 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") db_session.add(ev) db_session.commit() db_session.refresh(ev) if ev.is_public: notify_subscribers(f"[Расписание] Новое событие: {ev.title}", "Добавлено новое событие в расписание.\n\n" + event_summary_text(ev), kind="event") return redirect(url_for("admin_schedule")) @app.route("/admin/schedule/event//edit", methods=["GET", "POST"]) @require_role(["admin", "professor"]) def edit_event(event_id): with Session(engine) as db_session: event = db_session.get(Event, event_id) if not event: return "Событие не найдено", 404 if request.method == "POST": event.title = request.form.get("title") event.event_type = request.form.get("event_type") event.start_time = parse_datetime(request.form.get("start_time")) event.end_time = parse_datetime(request.form.get("end_time")) event.location = request.form.get("location") event.description = request.form.get("description") event.is_public = request.form.get("is_public") == "on" event.manually_edited = True db_session.add(event) db_session.commit() if event.is_public: notify_subscribers(f"[Расписание] Изменено: {event.title}", "Событие в расписании изменено.\n\n" + event_summary_text(event), kind="event") return redirect(url_for("admin_schedule")) return render_template("admin_event_edit.html", event=event) @app.route("/admin/schedule/event//delete", methods=["POST"]) @require_role(["admin", "professor"]) def delete_event(event_id): with Session(engine) as db_session: event = db_session.get(Event, event_id) if event: title_snapshot = event.title start_snapshot = event.start_time was_public = event.is_public db_session.delete(event) db_session.commit() if was_public: body = ( f"Событие отменено/удалено из расписания.\n\n" f"Название: {title_snapshot}\n" f"Было запланировано: {to_msk(start_snapshot).strftime('%d.%m.%Y %H:%M')}\n" ) notify_subscribers(f"[Расписание] Удалено: {title_snapshot}", body, kind="event") return redirect(url_for("admin_schedule")) @app.route("/admin/schedule/import", methods=["GET", "POST"]) @require_role(["admin", "professor"]) def import_ics(): if request.method == "POST": if 'file' not in request.files: return "Файл не загружен", 400 file = request.files['file'] if file.filename == '': return "Файл не выбран", 400 if file and file.filename.endswith('.ics'): try: cal = Calendar.from_ical(file.read()) count = 0 with Session(engine) as db_session: for component in cal.walk(): if component.name == "VEVENT": summary = str(component.get('summary', 'Без названия')) dtstart_prop = component.get('dtstart') dtend_prop = component.get('dtend') if not dtstart_prop or not dtend_prop: continue dtstart = dtstart_prop.dt dtend = dtend_prop.dt if not isinstance(dtstart, datetime): dtstart = datetime.combine(dtstart, datetime.min.time()) if not isinstance(dtend, datetime): dtend = datetime.combine(dtend, datetime.min.time()) if dtstart.tzinfo is None: dtstart = dtstart.replace(tzinfo=timezone.utc) if dtend.tzinfo is None: dtend = dtend.replace(tzinfo=timezone.utc) new_event = Event( title=summary, event_type='lecture', start_time=dtstart.astimezone(timezone.utc), end_time=dtend.astimezone(timezone.utc), location=str(component.get('location', '')), description=str(component.get('description', '')), is_public=True ) db_session.add(new_event) count += 1 db_session.commit() if count > 0: notify_subscribers(f"[Расписание] Импортировано событий: {count}", f"В расписание добавлено событий: {count}.\n", kind="event") return redirect(url_for("admin_schedule")) except Exception as e: return f"Ошибка при чтении файла .ics: {e}", 400 return render_template("admin_schedule_import.html") # ══════════════════════════════════════════════════════════════════════ # ПРОЕКТЫ # ══════════════════════════════════════════════════════════════════════ @app.route("/projects") def projects_list(): user_role = session.get("role") user_id = session.get("user_id") current_tag = (request.args.get("tag") or "").strip().lower() current_status = (request.args.get("status") or "").strip() with Session(engine) as db_session: all_projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() member_map = {} if user_id: for m in db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all(): member_map[m.project_id] = m.role all_tags = get_all_tags(db_session) filtered = [] for p in all_projects: if p.visibility == "public": filtered.append(p) elif p.visibility == "internal" and user_id: filtered.append(p) elif p.visibility == "private" and user_id and (p.owner_id == user_id or p.id in member_map or user_role == "admin"): filtered.append(p) if current_status: filtered = [p for p in filtered if p.status == current_status] if current_tag: def has_tag(p): if not p.tags: return False return current_tag in [t.strip().lower() for t in p.tags.split(",")] filtered = [p for p in filtered if has_tag(p)] return render_template("projects.html", projects=filtered, role=user_role, user_id=user_id, member_map=member_map, all_tags=all_tags, current_tag=current_tag, current_status=current_status) @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: new_proj = Project( title=request.form.get("title"), description=request.form.get("description"), status=request.form.get("status"), visibility=request.form.get("visibility"), gitea_url=(request.form.get("gitea_url") or "").strip() or None, gitea_repo=resolve_gitea_repo(request.form), tags=normalize_tags(request.form.get("tags")), start_date=(request.form.get("start_date") or "").strip() or None, end_date=(request.form.get("end_date") or "").strip() or None, contact_person=(request.form.get("contact_person") or "").strip() or None, owner_id=session.get("user_id"), ) db_session.add(new_proj) db_session.flush() proj_id = new_proj.id db_session.add(ProjectMember(project_id=proj_id, user_id=session.get("user_id"), role="manager")) db_session.commit() return redirect(url_for("project_detail", project_id=proj_id)) return render_template("project_form.html", project=None, is_edit=False) @app.route("/projects/") def project_detail(project_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 membership = None if user_id: membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) ).first() perms = project_permissions(proj, user_id, user_role, membership) if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership: return "403: Это приватный проект", 403 if proj.visibility == "internal" and not user_id: return redirect(url_for("login")) members = db_session.exec( select(ProjectMember, User).join(User, ProjectMember.user_id == User.id) .where(ProjectMember.project_id == project_id).order_by(User.username) ).all() files = db_session.exec( select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id) .where(PortalFile.project_id == project_id).order_by(PortalFile.uploaded_at.desc()) ).all() task_counts = {s: 0 for s in TASK_STATUSES} for t in db_session.exec(select(ProjectTask).where(ProjectTask.project_id == project_id)).all(): if t.status in task_counts: task_counts[t.status] += 1 return render_template("project_detail.html", project=proj, members=members, files=files, perms=perms, user_id=user_id, role=user_role, active_tab="overview", task_counts=task_counts) @app.route("/projects//gitea-stats") def project_gitea_stats(project_id): with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return jsonify({"ok": False, "error": "not_found", "message": "Проект не найден"}), 404 user_id = session.get("user_id") user_role = session.get("role") membership = None if user_id: membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) ).first() perms = project_permissions(proj, user_id, user_role, membership) if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership: return jsonify({"ok": False, "error": "forbidden"}), 403 repo = proj.gitea_repo if not repo and proj.gitea_url: repo = gitea_api.extract_repo_from_url(proj.gitea_url) if not repo: return jsonify({"ok": False, "error": "no_repo", "message": "У проекта не указан репозиторий."}) return jsonify(gitea_api.get_project_stats(repo)) @app.route("/projects//edit", methods=["GET", "POST"]) @login_required def project_edit(project_id): with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) ).first() perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) if not perms["can_manage"]: return "403: У вас нет прав на редактирование проекта", 403 if request.method == "POST": proj.title = request.form.get("title") proj.description = request.form.get("description") proj.status = request.form.get("status") proj.visibility = request.form.get("visibility") proj.gitea_url = (request.form.get("gitea_url") or "").strip() or None proj.gitea_repo = resolve_gitea_repo(request.form, proj.gitea_url, proj.gitea_repo) proj.tags = normalize_tags(request.form.get("tags")) proj.start_date = (request.form.get("start_date") or "").strip() or None proj.end_date = (request.form.get("end_date") or "").strip() or None proj.contact_person = (request.form.get("contact_person") or "").strip() or None db_session.add(proj) db_session.commit() return redirect(url_for("project_detail", project_id=proj.id)) return render_template("project_form.html", project=proj, is_edit=True) @app.route("/projects//members/add", methods=["POST"]) @login_required def project_add_member(project_id): username = (request.form.get("username") or "").strip() role = request.form.get("role") or "viewer" if role not in PROJECT_ROLE_RANK: role = "viewer" with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) ).first() perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) if not perms["can_manage"]: return "403: Нет прав", 403 user_to_add = db_session.exec(select(User).where(User.username == username)).first() if not user_to_add: return redirect(url_for("project_detail", project_id=project_id)) existing = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_to_add.id) ).first() if not existing: db_session.add(ProjectMember(project_id=project_id, user_id=user_to_add.id, role=role)) db_session.commit() notify_user(user_to_add.id, f"Вас добавили в проект «{proj.title}»", f"Роль: {role}", link_url=url_for("project_detail", project_id=project_id), kind="info") return redirect(url_for("project_detail", project_id=project_id)) @app.route("/projects//members//role", methods=["POST"]) @login_required def project_change_member_role(project_id, user_id): new_role = request.form.get("role") or "viewer" if new_role not in PROJECT_ROLE_RANK: return "Ошибка: неверная роль", 400 with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 my_membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) ).first() perms = project_permissions(proj, session.get("user_id"), session.get("role"), my_membership) if not perms["can_manage"]: return "403: Нет прав", 403 if user_id == proj.owner_id: return "Нельзя менять роль владельца", 400 target = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) ).first() if target: target.role = new_role db_session.add(target) db_session.commit() return redirect(url_for("project_detail", project_id=project_id)) @app.route("/projects//members//remove", methods=["POST"]) @login_required def project_remove_member(project_id, user_id): with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 my_membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) ).first() perms = project_permissions(proj, session.get("user_id"), session.get("role"), my_membership) if not perms["can_manage"]: return "403: Нет прав", 403 if user_id == proj.owner_id: return "Нельзя удалить владельца", 400 to_remove = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) ).first() if to_remove: db_session.delete(to_remove) db_session.commit() return redirect(url_for("project_detail", project_id=project_id)) @app.route("/projects//delete", methods=["POST"]) @login_required def project_delete(project_id): with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: print(f"[project_delete] Проект {project_id} не найден", flush=True) return redirect(url_for("projects_list")) user_id = session.get("user_id") user_role = session.get("role") membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) ).first() perms = project_permissions(proj, user_id, user_role, membership) if not perms["can_delete"]: return f"403: Удалять проект может только владелец (id={proj.owner_id}) или админ. Вы — id={user_id}, роль={user_role}.", 403 try: files = db_session.exec(select(PortalFile).where(PortalFile.project_id == project_id)).all() for f in files: f.project_id = None db_session.add(f) db_session.flush() tasks = db_session.exec(select(ProjectTask).where(ProjectTask.project_id == project_id)).all() for t in tasks: db_session.delete(t) db_session.flush() members = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all() for m in members: db_session.delete(m) db_session.flush() db_session.delete(proj) db_session.commit() except Exception as e: db_session.rollback() print(f"[project_delete] ОШИБКА при удалении {project_id}: {e}", flush=True) import traceback traceback.print_exc() return f"Ошибка удаления: {e}", 500 return redirect(url_for("projects_list")) @app.route("/projects//files/upload", methods=["POST"]) @login_required def project_file_upload(project_id): with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) ).first() perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) if not perms["can_upload"]: return "403: Нет прав на загрузку файлов", 403 try: file = request.files.get('file') except Exception as e: print(f"[project_file_upload] Ошибка парсинга: {e}", flush=True) return "Ошибка разбора запроса", 400 if not file or file.filename == '': return redirect(url_for("project_detail", project_id=project_id)) 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) try: file.save(filepath) size_bytes = os.path.getsize(filepath) except Exception as e: print(f"[project_file_upload] Ошибка сохранения: {e}", flush=True) return "Ошибка сохранения файла", 500 try: with Session(engine) as db_session: db_session.add(PortalFile( original_name=original_name, stored_name=stored_name, size_bytes=size_bytes, uploaded_by=session.get("user_id"), project_id=project_id, )) db_session.commit() except Exception as e: print(f"[project_file_upload] Ошибка БД: {e}", flush=True) try: os.remove(filepath) except Exception: pass return "Ошибка записи в БД", 500 return redirect(url_for("project_detail", project_id=project_id)) # ══════════════════════════════════════════════════════════════════════ # КАНБАН # ══════════════════════════════════════════════════════════════════════ @app.route("/projects//board") def project_board(project_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 membership = None if user_id: membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id) ).first() perms = project_permissions(proj, user_id, user_role, membership) if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership: return "403: Это приватный проект", 403 if proj.visibility == "internal" and not user_id: return redirect(url_for("login")) members = db_session.exec( select(ProjectMember, User).join(User, ProjectMember.user_id == User.id) .where(ProjectMember.project_id == project_id).order_by(User.username) ).all() tasks = db_session.exec( select(ProjectTask).where(ProjectTask.project_id == project_id) .order_by(ProjectTask.position, ProjectTask.id) ).all() user_ids = set() for t in tasks: if t.assignee_id: user_ids.add(t.assignee_id) if t.created_by: user_ids.add(t.created_by) users_map = {} if user_ids: for u in db_session.exec(select(User).where(User.id.in_(user_ids))).all(): users_map[u.id] = u columns = {s: [] for s in TASK_STATUSES} for t in tasks: if t.status in columns: columns[t.status].append({ "task": t, "assignee": users_map.get(t.assignee_id) if t.assignee_id else None, "author": users_map.get(t.created_by), }) task_counts = {s: len(columns[s]) for s in TASK_STATUSES} return render_template("project_board.html", project=proj, members=members, columns=columns, perms=perms, user_id=user_id, role=user_role, active_tab="board", task_counts=task_counts, TASK_STATUSES=TASK_STATUSES, TASK_STATUS_LABELS=TASK_STATUS_LABELS, TASK_PRIORITIES=TASK_PRIORITIES) @app.route("/projects//tasks/new", methods=["POST"]) @login_required def task_new(project_id): with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) ).first() perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) if not perms["can_edit"]: return "403: Нет прав", 403 title = (request.form.get("title") or "").strip() if not title: return redirect(url_for("project_board", project_id=project_id)) status = request.form.get("status") or "backlog" if status not in TASK_STATUSES: status = "backlog" priority = request.form.get("priority") or "medium" if priority not in TASK_PRIORITIES: priority = "medium" assignee_id = request.form.get("assignee_id") assignee_id = int(assignee_id) if assignee_id and assignee_id.isdigit() else None max_pos = db_session.exec( select(ProjectTask).where(ProjectTask.project_id == project_id).where(ProjectTask.status == status) ).all() position = max([t.position for t in max_pos], default=-1) + 1 task = ProjectTask( project_id=project_id, title=title, description=(request.form.get("description") or "").strip() or None, status=status, priority=priority, assignee_id=assignee_id, created_by=session.get("user_id"), due_date=(request.form.get("due_date") or "").strip() or None, position=position, ) db_session.add(task) db_session.commit() if assignee_id and assignee_id != session.get("user_id"): notify_user(assignee_id, f"Вам назначена задача: {title}", f"Проект: {proj.title}", link_url=url_for("project_board", project_id=project_id), kind="info") return redirect(url_for("project_board", project_id=project_id)) @app.route("/projects//tasks//edit", methods=["GET", "POST"]) @login_required def task_edit(project_id, task_id): with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) ).first() perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) if not perms["can_edit"]: return "403: Нет прав", 403 task = db_session.get(ProjectTask, task_id) if not task or task.project_id != project_id: return "Задача не найдена", 404 if request.method == "POST": old_assignee = task.assignee_id task.title = (request.form.get("title") or task.title).strip() task.description = (request.form.get("description") or "").strip() or None new_status = request.form.get("status") or task.status if new_status in TASK_STATUSES: task.status = new_status new_priority = request.form.get("priority") or task.priority if new_priority in TASK_PRIORITIES: task.priority = new_priority assignee = request.form.get("assignee_id") new_assignee = int(assignee) if assignee and assignee.isdigit() else None task.assignee_id = new_assignee task.due_date = (request.form.get("due_date") or "").strip() or None task.updated_at = datetime.now(timezone.utc) db_session.add(task) db_session.commit() if new_assignee and new_assignee != old_assignee and new_assignee != session.get("user_id"): notify_user(new_assignee, f"Вам назначена задача: {task.title}", f"Проект: {proj.title}", link_url=url_for("project_board", project_id=project_id), kind="info") return redirect(url_for("project_board", project_id=project_id)) members = db_session.exec( select(ProjectMember, User).join(User, ProjectMember.user_id == User.id) .where(ProjectMember.project_id == project_id).order_by(User.username) ).all() return render_template("task_edit.html", project=proj, task=task, members=members, TASK_STATUSES=TASK_STATUSES, TASK_STATUS_LABELS=TASK_STATUS_LABELS, TASK_PRIORITIES=TASK_PRIORITIES) @app.route("/projects//tasks//delete", methods=["POST"]) @login_required def task_delete(project_id, task_id): with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) ).first() perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) if not perms["can_manage"]: return "403: Удалять задачи может только менеджер проекта", 403 task = db_session.get(ProjectTask, task_id) if task and task.project_id == project_id: db_session.delete(task) db_session.commit() return redirect(url_for("project_board", project_id=project_id)) @app.route("/projects//tasks//move", methods=["POST"]) @login_required def task_move(project_id, task_id): data = request.get_json(silent=True) or {} new_status = data.get("status") new_position = data.get("position", 0) if new_status not in TASK_STATUSES: return jsonify({"error": "invalid status"}), 400 with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return jsonify({"error": "project not found"}), 404 membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id")) ).first() perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) if not perms["can_edit"]: return jsonify({"error": "no permission"}), 403 task = db_session.get(ProjectTask, task_id) if not task or task.project_id != project_id: return jsonify({"error": "task not found"}), 404 old_status = task.status siblings = db_session.exec( select(ProjectTask) .where(ProjectTask.project_id == project_id) .where(ProjectTask.status == new_status) .where(ProjectTask.id != task_id) .order_by(ProjectTask.position, ProjectTask.id) ).all() siblings.insert(max(0, min(int(new_position), len(siblings))), task) for i, t in enumerate(siblings): t.position = i if t.id == task_id: t.status = new_status t.updated_at = datetime.now(timezone.utc) db_session.add(t) db_session.flush() if old_status != new_status: olds = db_session.exec( select(ProjectTask) .where(ProjectTask.project_id == project_id) .where(ProjectTask.status == old_status) .order_by(ProjectTask.position, ProjectTask.id) ).all() for i, t in enumerate(olds): t.position = i db_session.add(t) db_session.commit() return jsonify({"ok": True}) # ══════════════════════════════════════════════════════════════════════ # МАСТЕР-КЛАССЫ # ══════════════════════════════════════════════════════════════════════ @app.route("/workshops") def workshops_list(): user_id = session.get("user_id") user_role = session.get("role") current_cat = (request.args.get("category") or "").strip() current_status = (request.args.get("status") or "").strip() with Session(engine) as db_session: all_ws = db_session.exec(select(Workshop).order_by(Workshop.created_at.desc())).all() visible = [] for w in all_ws: if w.status == "draft" and not (user_role in ("admin", "professor") or w.lead_id == user_id): continue if w.visibility == "internal" and not user_id: continue visible.append(w) reg_counts = {} for w in visible: reg_counts[w.id] = _ws_count_registered(db_session, w.id) next_sessions = {} now = datetime.now(timezone.utc) for w in visible: sessions = _ws_session_helpers(db_session, w.id) future = [s for s in sessions if s.start_time >= now] next_sessions[w.id] = future[0] if future else (sessions[-1] if sessions else None) lead_ids = {w.lead_id for w in visible} leads_map = {} if lead_ids: for u in db_session.exec(select(User).where(User.id.in_(lead_ids))).all(): leads_map[u.id] = u filtered = visible if current_cat: filtered = [w for w in filtered if w.category == current_cat] if current_status: filtered = [w for w in filtered if w.status == current_status] can_create = user_role in ("admin", "professor", "lead") return render_template("workshops.html", workshops=filtered, reg_counts=reg_counts, next_sessions=next_sessions, leads_map=leads_map, current_cat=current_cat, current_status=current_status, can_create=can_create, WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES, WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS) @app.route("/workshops/new", methods=["GET", "POST"]) @require_role(["admin", "professor", "lead"]) def workshop_new(): if request.method == "POST": with Session(engine) as db_session: ws = Workshop( title=request.form.get("title"), description=request.form.get("description"), program=request.form.get("program"), lead_id=session.get("user_id"), category=request.form.get("category") or "other", difficulty=request.form.get("difficulty") or "beginner", partner=(request.form.get("partner") or "").strip() or None, status=request.form.get("status") or "draft", visibility=request.form.get("visibility") or "public", location=(request.form.get("location") or "").strip() or None, materials_required=request.form.get("materials_required"), outcome=request.form.get("outcome"), has_certificate=request.form.get("has_certificate") == "on", max_participants=int(request.form.get("max_participants")) if (request.form.get("max_participants") or "").strip().isdigit() else None, cover_url=(request.form.get("cover_url") or "").strip() or None, tags=normalize_tags(request.form.get("tags")), ) db_session.add(ws) db_session.flush() ws_id = ws.id db_session.commit() return redirect(url_for("workshop_detail", workshop_id=ws_id)) return render_template("workshop_form.html", workshop=None, is_edit=False, WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES, WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, WORKSHOP_DIFFICULTIES=WORKSHOP_DIFFICULTIES, WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, WORKSHOP_STATUSES=WORKSHOP_STATUSES, WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, WORKSHOP_VISIBILITIES=WORKSHOP_VISIBILITIES, WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS) @app.route("/workshops/") def workshop_detail(workshop_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: ws = db_session.get(Workshop, workshop_id) if not ws: return "Мастер-класс не найден", 404 perms = workshop_permissions(ws, user_id, user_role) if ws.status == "draft" and not perms["can_manage"]: return "403: Черновик доступен только ведущему", 403 if ws.visibility == "internal" and not user_id: return redirect(url_for("login")) lead = db_session.get(User, ws.lead_id) sessions = _ws_session_helpers(db_session, workshop_id) registration = _ws_registration(db_session, workshop_id, user_id) is_registered = registration is not None and registration.status in ("registered", "attended") registered_count = _ws_count_registered(db_session, workshop_id) is_full = ws.max_participants is not None and registered_count >= ws.max_participants materials = db_session.exec( select(WorkshopMaterial).where(WorkshopMaterial.workshop_id == workshop_id) .order_by(WorkshopMaterial.created_at.desc()) ).all() visible_materials = [] for m in materials: if m.visible_to == "registered" and not (is_registered or perms["can_manage"]): continue visible_materials.append(m) participants = [] if perms["can_manage"]: regs = db_session.exec( select(WorkshopRegistration, User) .join(User, WorkshopRegistration.user_id == User.id) .where(WorkshopRegistration.workshop_id == workshop_id) .order_by(WorkshopRegistration.registered_at) ).all() participants = regs material_files_map = {} file_ids = [m.file_id for m in visible_materials if m.file_id] if file_ids: for f in db_session.exec(select(PortalFile).where(PortalFile.id.in_(file_ids))).all(): material_files_map[f.id] = f return render_template("workshop_detail.html", workshop=ws, lead=lead, sessions=sessions, perms=perms, user_id=user_id, role=user_role, registration=registration, is_registered=is_registered, registered_count=registered_count, is_full=is_full, materials=visible_materials, material_files_map=material_files_map, participants=participants, WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS) @app.route("/workshops//edit", methods=["GET", "POST"]) @login_required def workshop_edit(workshop_id): with Session(engine) as db_session: ws = db_session.get(Workshop, workshop_id) if not ws: return "Мастер-класс не найден", 404 perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) if not perms["can_manage"]: return "403: Нет прав на редактирование", 403 if request.method == "POST": ws.title = request.form.get("title") ws.description = request.form.get("description") ws.program = request.form.get("program") ws.category = request.form.get("category") or "other" ws.difficulty = request.form.get("difficulty") or "beginner" ws.partner = (request.form.get("partner") or "").strip() or None ws.status = request.form.get("status") or "draft" ws.visibility = request.form.get("visibility") or "public" ws.location = (request.form.get("location") or "").strip() or None ws.materials_required = request.form.get("materials_required") ws.outcome = request.form.get("outcome") ws.has_certificate = request.form.get("has_certificate") == "on" max_p = request.form.get("max_participants") ws.max_participants = int(max_p) if (max_p or "").strip().isdigit() else None ws.cover_url = (request.form.get("cover_url") or "").strip() or None ws.tags = normalize_tags(request.form.get("tags")) db_session.add(ws) db_session.commit() return redirect(url_for("workshop_detail", workshop_id=ws.id)) return render_template("workshop_form.html", workshop=ws, is_edit=True, WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES, WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS, WORKSHOP_DIFFICULTIES=WORKSHOP_DIFFICULTIES, WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS, WORKSHOP_STATUSES=WORKSHOP_STATUSES, WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS, WORKSHOP_VISIBILITIES=WORKSHOP_VISIBILITIES, WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS) @app.route("/workshops//delete", methods=["POST"]) @login_required def workshop_delete(workshop_id): with Session(engine) as db_session: ws = db_session.get(Workshop, workshop_id) if not ws: return redirect(url_for("workshops_list")) perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) if not perms["can_delete"]: return "403: Удалять мастер-класс может только ведущий или админ", 403 db_session.delete(ws) db_session.commit() return redirect(url_for("workshops_list")) @app.route("/workshops//sessions/add", methods=["POST"]) @login_required def workshop_session_add(workshop_id): with Session(engine) as db_session: ws = db_session.get(Workshop, workshop_id) if not ws: return "Мастер-класс не найден", 404 perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) if not perms["can_manage"]: return "403: Нет прав", 403 start = parse_datetime(request.form.get("start_time")) end = parse_datetime(request.form.get("end_time")) if start and end: db_session.add(WorkshopSession( workshop_id=workshop_id, start_time=start, end_time=end, location_override=(request.form.get("location_override") or "").strip() or None, note=(request.form.get("note") or "").strip() or None, )) db_session.commit() return redirect(url_for("workshop_detail", workshop_id=workshop_id)) @app.route("/workshops//sessions//delete", methods=["POST"]) @login_required def workshop_session_delete(workshop_id, session_id): with Session(engine) as db_session: ws = db_session.get(Workshop, workshop_id) if not ws: return redirect(url_for("workshops_list")) perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) if not perms["can_manage"]: return "403: Нет прав", 403 s = db_session.get(WorkshopSession, session_id) if s and s.workshop_id == workshop_id: db_session.delete(s) db_session.commit() return redirect(url_for("workshop_detail", workshop_id=workshop_id)) @app.route("/workshops//materials/add", methods=["POST"]) @login_required def workshop_material_add(workshop_id): with Session(engine) as db_session: ws = db_session.get(Workshop, workshop_id) if not ws: return "Мастер-класс не найден", 404 perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) if not perms["can_manage"]: return "403: Нет прав", 403 title = (request.form.get("title") or "").strip() if title: db_session.add(WorkshopMaterial( workshop_id=workshop_id, title=title, description=(request.form.get("description") or "").strip() or None, external_url=(request.form.get("external_url") or "").strip() or None, visible_to=request.form.get("visible_to") or "registered", )) db_session.commit() regs = db_session.exec( select(WorkshopRegistration).where(WorkshopRegistration.workshop_id == workshop_id) .where(WorkshopRegistration.status.in_(("registered", "attended"))) ).all() recipients = [r.user_id for r in regs if r.user_id != session.get("user_id")] if recipients: notify_users(recipients, f"Новый материал в «{ws.title}»", title, link_url=url_for("workshop_detail", workshop_id=workshop_id), kind="info") return redirect(url_for("workshop_detail", workshop_id=workshop_id)) @app.route("/workshops//materials//delete", methods=["POST"]) @login_required def workshop_material_delete(workshop_id, material_id): with Session(engine) as db_session: ws = db_session.get(Workshop, workshop_id) if not ws: return redirect(url_for("workshops_list")) perms = workshop_permissions(ws, session.get("user_id"), session.get("role")) if not perms["can_manage"]: return "403: Нет прав", 403 m = db_session.get(WorkshopMaterial, material_id) if m and m.workshop_id == workshop_id: db_session.delete(m) db_session.commit() return redirect(url_for("workshop_detail", workshop_id=workshop_id)) @app.route("/workshops//register", methods=["POST"]) @login_required def workshop_register(workshop_id): with Session(engine) as db_session: ws = db_session.get(Workshop, workshop_id) if not ws: return "Мастер-класс не найден", 404 if ws.status != "published": return "Регистрация доступна только для опубликованных", 400 existing = _ws_registration(db_session, workshop_id, session.get("user_id")) if existing and existing.status in ("registered", "attended"): return redirect(url_for("workshop_detail", workshop_id=workshop_id)) if ws.max_participants is not None: count = _ws_count_registered(db_session, workshop_id) if count >= ws.max_participants: return redirect(url_for("workshop_detail", workshop_id=workshop_id)) if existing: existing.status = "registered" existing.registered_at = datetime.now(timezone.utc) db_session.add(existing) else: db_session.add(WorkshopRegistration( workshop_id=workshop_id, user_id=session.get("user_id"), status="registered", )) db_session.commit() if ws.lead_id != session.get("user_id"): notify_user(ws.lead_id, f"Новая запись на «{ws.title}»", f"Пользователь записался на ваш мастер-класс.", link_url=url_for("workshop_detail", workshop_id=workshop_id), kind="success") return redirect(url_for("workshop_detail", workshop_id=workshop_id)) @app.route("/workshops//unregister", methods=["POST"]) @login_required def workshop_unregister(workshop_id): with Session(engine) as db_session: ws = db_session.get(Workshop, workshop_id) existing = _ws_registration(db_session, workshop_id, session.get("user_id")) if existing: existing.status = "cancelled" db_session.add(existing) db_session.commit() if ws and ws.lead_id != session.get("user_id"): notify_user(ws.lead_id, f"Отмена записи на «{ws.title}»", "Пользователь отменил запись на ваш мастер-класс.", link_url=url_for("workshop_detail", workshop_id=workshop_id), kind="warning") return redirect(url_for("workshop_detail", workshop_id=workshop_id)) # ══════════════════════════════════════════════════════════════════════ # ФАЙЛЫ # ══════════════════════════════════════════════════════════════════════ @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() projects_map = {p.id: p.title for p in db_session.exec(select(Project)).all()} return render_template("files.html", files=files, projects_map=projects_map) @app.route("/files/upload", methods=["POST"]) @login_required def file_upload(): try: file = request.files.get('file') except Exception as e: print(f"[file_upload] Ошибка парсинга: {e}", flush=True) return "Ошибка разбора запроса", 400 if not file or file.filename == '': return redirect(url_for('files_list')) 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) try: file.save(filepath) size_bytes = os.path.getsize(filepath) except Exception as e: print(f"[file_upload] Ошибка сохранения: {e}", flush=True) return "Ошибка сохранения файла", 500 try: with Session(engine) as db_session: db_session.add(PortalFile( original_name=original_name, stored_name=stored_name, size_bytes=size_bytes, uploaded_by=session.get("user_id"), )) db_session.commit() except Exception as e: print(f"[file_upload] Ошибка БД: {e}", flush=True) try: os.remove(filepath) except Exception: pass return "Ошибка записи в БД", 500 return redirect(url_for('files_list')) @app.route("/files/download/") @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//delete", methods=["POST"]) @login_required def file_delete(file_id): next_url = request.form.get("next") or "/files" if not next_url.startswith("/"): next_url = "/files" with Session(engine) as db_session: db_file = db_session.get(PortalFile, file_id) if not db_file: return redirect(next_url) allowed = session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id") if not allowed and db_file.project_id: proj = db_session.get(Project, db_file.project_id) if proj: membership = db_session.exec( select(ProjectMember).where(ProjectMember.project_id == db_file.project_id).where(ProjectMember.user_id == session.get("user_id")) ).first() perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership) if perms["can_manage"]: allowed = True if allowed: 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(next_url) # ══════════════════════════════════════════════════════════════════════ # АДМИНКА: ПОЛЬЗОВАТЕЛИ # ══════════════════════════════════════════════════════════════════════ @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//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//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: user = db_session.get(User, user_id) if not user: return redirect(url_for("admin_users")) owned_projects = db_session.exec(select(Project).where(Project.owner_id == user_id)).all() uploaded_files = db_session.exec(select(PortalFile).where(PortalFile.uploaded_by == user_id)).all() if owned_projects or uploaded_files: return (f"Нельзя удалить {user.username}: у него {len(owned_projects)} проект(ов) " f"и {len(uploaded_files)} файл(ов)."), 400 owned_workshops = db_session.exec(select(Workshop).where(Workshop.lead_id == user_id)).all() if owned_workshops: return (f"Нельзя удалить {user.username}: он ведёт {len(owned_workshops)} мастер-класс(ов)."), 400 for pm in db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all(): db_session.delete(pm) for s in db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all(): db_session.delete(s) for sub in db_session.exec(select(EventSubscriber).where(EventSubscriber.user_id == user_id)).all(): sub.user_id = None db_session.add(sub) for t in db_session.exec(select(ProjectTask).where(ProjectTask.assignee_id == user_id)).all(): t.assignee_id = None db_session.add(t) for n in db_session.exec(select(Notification).where(Notification.user_id == user_id)).all(): db_session.delete(n) db_session.delete(user) db_session.commit() return redirect(url_for("admin_users"))