From 4fac39eda5656bad1c1215a95fa217453b046753 Mon Sep 17 00:00:00 2001 From: Ivan Budakov Date: Wed, 21 Oct 2026 02:47:54 +0300 Subject: [PATCH] add a lot of.... realy a lot of --- app/config.py | 9 +- app/gitea_api.py | 156 +++ app/llm_assistant.py | 243 ++++ app/main.py | 1660 ++++++++++++++++++++++--- app/models.py | 110 +- app/static/notif_bell.js | 131 ++ app/templates/index.html | 224 +++- app/templates/notifications_page.html | 71 ++ app/templates/profile.html | 80 +- app/templates/project_board.html | 288 +++++ app/templates/project_detail.html | 425 +++++-- app/templates/project_form.html | 121 +- app/templates/projects.html | 176 ++- app/templates/schedule.html | 71 +- app/templates/subscribe_status.html | 46 + app/templates/task_edit.html | 80 ++ app/templates/workshop_detail.html | 322 +++++ app/templates/workshop_form.html | 181 +++ app/templates/workshops.html | 185 +++ 19 files changed, 4123 insertions(+), 456 deletions(-) create mode 100644 app/gitea_api.py create mode 100644 app/llm_assistant.py create mode 100644 app/static/notif_bell.js create mode 100644 app/templates/notifications_page.html create mode 100644 app/templates/project_board.html create mode 100644 app/templates/subscribe_status.html create mode 100644 app/templates/task_edit.html create mode 100644 app/templates/workshop_detail.html create mode 100644 app/templates/workshop_form.html create mode 100644 app/templates/workshops.html diff --git a/app/config.py b/app/config.py index 0903de7..8fd6f38 100644 --- a/app/config.py +++ b/app/config.py @@ -9,7 +9,6 @@ POSTGRES_DB = os.getenv("POSTGRES_DB", "morozovka_portal") POSTGRES_HOST = os.getenv("POSTGRES_HOST", "127.0.0.1") POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") -# Добавляем psycopg2 в URL DATABASE_URL = f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000") @@ -17,12 +16,18 @@ GITEA_PUBLIC_URL = os.getenv("GITEA_PUBLIC_URL", "https://git.morozovka.space") GITEA_CLIENT_ID = "66697efe-d88b-41fe-b3d5-6498d978e1fa" GITEA_CLIENT_SECRET = "gto_okjv2yj2jjrsqo6qeniz6nbnqulloo4x2rrwex6p4cqx7azrc27a" +# Personal Access Token для Gitea API (для чтения приватных репо). +# Создать: https://git.morozovka.space → Settings → Applications → Generate New Token +# Scopes: read:repository, read:user +# Пусто — работает только для публичных репозиториев. +GITEA_API_TOKEN = os.getenv("GITEA_API_TOKEN", "") + 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") GITEA_REDIRECT_URI = os.getenv("GITEA_REDIRECT_URI", "https://portal.morozovka.space/auth/callback") +LLM_ENABLED = os.getenv("LLM_ENABLED", "false").lower() == "true" diff --git a/app/gitea_api.py b/app/gitea_api.py new file mode 100644 index 0000000..0812a1d --- /dev/null +++ b/app/gitea_api.py @@ -0,0 +1,156 @@ +"""Gitea API client с простым in-memory кэшем (5 минут).""" +import re +import time +import requests +from . import config + +_cache = {} +CACHE_TTL = 300 # 5 минут + + +def _headers(): + if config.GITEA_API_TOKEN: + return {"Authorization": f"token {config.GITEA_API_TOKEN}"} + return {} + + +def _cache_get(key): + entry = _cache.get(key) + if not entry: + return None + ts, data = entry + if time.time() - ts > CACHE_TTL: + return None + return data + + +def _cache_set(key, data): + _cache[key] = (time.time(), data) + + +def _api_get(path, params=None): + """Возвращает (data, total) или (None, None) при ошибке.""" + url = f"{config.GITEA_URL}/api/v1{path}" + try: + r = requests.get(url, headers=_headers(), params=params, timeout=5) + if r.status_code == 200: + total_header = r.headers.get("X-Total-Count") + total = int(total_header) if total_header and total_header.isdigit() else None + try: + data = r.json() + except Exception: + data = None + return data, total + return None, None + except Exception as e: + print(f"[gitea_api] {path}: {e}", flush=True) + return None, None + + +def extract_repo_from_url(url): + """https://git.morozovka.space/owner/repo(.git)? → owner/repo""" + if not url: + return None + m = re.search(r"[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", url.strip()) + if m: + return f"{m.group(1)}/{m.group(2)}" + return None + + +def get_repo_info(owner, repo): + key = f"repo:{owner}/{repo}" + cached = _cache_get(key) + if cached is not None: + return cached + data, _ = _api_get(f"/repos/{owner}/{repo}") + _cache_set(key, data) + return data + + +def get_latest_commits(owner, repo, limit=5): + key = f"commits:{owner}/{repo}:{limit}" + cached = _cache_get(key) + if cached is not None: + return cached + data, _ = _api_get( + f"/repos/{owner}/{repo}/commits", + params={"limit": limit, "page": 1, "stat": "false"}, + ) + result = [] + if isinstance(data, list): + for c in data[:limit]: + commit_obj = c.get("commit") or {} + author_obj = commit_obj.get("author") or {} + result.append({ + "sha_short": (c.get("sha") or "")[:7], + "message": (commit_obj.get("message") or "").split("\n")[0][:120], + "author": author_obj.get("name") or "", + "date": author_obj.get("date") or "", + "html_url": c.get("html_url") or "", + }) + _cache_set(key, result) + return result + + +def get_open_issues_count(owner, repo): + key = f"issues:{owner}/{repo}" + cached = _cache_get(key) + if cached is not None: + return cached + _, total = _api_get( + f"/repos/{owner}/{repo}/issues", + params={"state": "open", "type": "issues", "limit": 1}, + ) + _cache_set(key, total or 0) + return total or 0 + + +def get_open_prs_count(owner, repo): + key = f"prs:{owner}/{repo}" + cached = _cache_get(key) + if cached is not None: + return cached + _, total = _api_get( + f"/repos/{owner}/{repo}/pulls", + params={"state": "open", "limit": 1}, + ) + _cache_set(key, total or 0) + return total or 0 + + +def get_project_stats(gitea_repo): + """gitea_repo: 'owner/repo'. Возвращает dict для JSON-ответа.""" + if not gitea_repo or "/" not in gitea_repo: + return {"ok": False, "error": "no_repo", + "message": "Не указан репозиторий проекта."} + + owner, repo = gitea_repo.split("/", 1) + info = get_repo_info(owner, repo) + if info is None: + # 404 или 401 — не смогли прочитать + if not config.GITEA_API_TOKEN: + return {"ok": False, "error": "auth_required", + "message": "Репозиторий приватный или не найден. " + "Настройте GITEA_API_TOKEN в config.py."} + return {"ok": False, "error": "api_unreachable", + "message": "Не удалось получить данные из Gitea."} + + commits = get_latest_commits(owner, repo, limit=5) + open_issues = get_open_issues_count(owner, repo) + open_prs = get_open_prs_count(owner, repo) + + return { + "ok": True, + "repo": { + "full_name": info.get("full_name", gitea_repo), + "description": info.get("description") or "", + "html_url": info.get("html_url", ""), + "default_branch": info.get("default_branch") or "main", + "stars": info.get("stars_count", 0), + "forks": info.get("forks_count", 0), + "is_private": bool(info.get("private")), + }, + "commits": commits, + "open_issues": open_issues, + "open_prs": open_prs, + } diff --git a/app/llm_assistant.py b/app/llm_assistant.py new file mode 100644 index 0000000..9377bd4 --- /dev/null +++ b/app/llm_assistant.py @@ -0,0 +1,243 @@ +"""LLM-ассистент расписания с tool calling (Ollama или любой OpenAI-compatible).""" +import requests +import pytz +from datetime import datetime, timezone, timedelta +from sqlmodel import Session, select +from . import config +from .db import engine +from .models import Event, GlobalStatus + +MSK = pytz.timezone("Europe/Moscow") + + +def _msk(dt): + if dt is None: return None + if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(MSK) + + +def is_enabled(): + if not config.LLM_ENABLED: + return False + if not config.LLM_API_BASE: + return False + return True + + +# ───────────────────────────────────────────────────────────────────── +# Реализации инструментов +# ───────────────────────────────────────────────────────────────────── +def _tool_get_current_status(): + with Session(engine) as s: + status = s.exec(select(GlobalStatus)).first() + if not status: + return "Статус не задан." + if status.status == "on_place": + base = "Никита Сергеевич сейчас на месте." + elif status.status == "delayed": + base = f"Никита Сергеевич опаздывает на {status.delay_minutes} мин." + elif status.status == "away": + base = "Никита Сергеевич вне вуза." + elif status.status == "cancelled": + base = "Занятия отменены." + else: + base = "Никита Сергеевич свободен / вне расписания." + parts = [base] + if status.message: + parts.append(f"Сообщение: {status.message}") + if status.updated_at: + parts.append(f"Обновлено: {_msk(status.updated_at).strftime('%d.%m.%Y %H:%M')}") + return " ".join(parts) + + +def _tool_get_events_today(): + start = datetime.now(MSK).replace(hour=0, minute=0, second=0, microsecond=0) + start_utc = start.astimezone(timezone.utc) + end_utc = (start + timedelta(days=1)).astimezone(timezone.utc) + with Session(engine) as s: + events = s.exec( + select(Event).where(Event.is_public == True) + .where(Event.start_time >= start_utc) + .where(Event.start_time < end_utc) + .order_by(Event.start_time) + ).all() + if not events: + return "На сегодня событий в расписании нет." + lines = ["Расписание на сегодня:"] + for e in events: + t = _msk(e.start_time).strftime("%H:%M") + loc = f" ({e.location})" if e.location else "" + lines.append(f"- {t} — {e.title}{loc}") + return "\n".join(lines) + + +def _tool_get_next_event(): + now = datetime.now(timezone.utc) + with Session(engine) as s: + ev = s.exec( + select(Event).where(Event.is_public == True) + .where(Event.start_time >= now) + .order_by(Event.start_time) + .limit(1) + ).first() + if not ev: + return "В ближайшее время событий в расписании нет." + dt = _msk(ev.start_time).strftime("%d.%m.%Y в %H:%M") + loc = f", место: {ev.location}" if ev.location else "" + return f"Ближайшее событие: {ev.title} — {dt}{loc}." + + +def _tool_get_events_week(): + now = datetime.now(timezone.utc) + end = now + timedelta(days=7) + with Session(engine) as s: + events = s.exec( + select(Event).where(Event.is_public == True) + .where(Event.start_time >= now) + .where(Event.start_time <= end) + .order_by(Event.start_time) + ).all() + if not events: + return "На ближайшую неделю событий нет." + lines = ["Расписание на ближайшую неделю:"] + for e in events[:15]: + dt = _msk(e.start_time).strftime("%d.%m %H:%M") + lines.append(f"- {dt} — {e.title}") + if len(events) > 15: + lines.append(f"...и ещё {len(events) - 15} событий (см. на странице).") + return "\n".join(lines) + + +def _tool_get_contacts(): + return ("Контакт Никиты Сергеевича: freezer.nik@gmail.com. " + "Telegram — по запросу. Расписание и статус — на portal.morozovka.space/schedule.") + + +TOOL_IMPLS = { + "get_current_status": _tool_get_current_status, + "get_events_today": _tool_get_events_today, + "get_next_event": _tool_get_next_event, + "get_events_week": _tool_get_events_week, + "get_contacts": _tool_get_contacts, +} + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_current_status", + "description": "Узнать текущий статус Никиты Сергеевича: на месте / опаздывает / вне вуза / отменено / свободен.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "get_events_today", + "description": "Список событий на сегодня.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "get_next_event", + "description": "Ближайшее будущее событие.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "get_events_week", + "description": "Список событий на ближайшие 7 дней.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "get_contacts", + "description": "Контактная информация Никиты Сергеевича.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, +] + +SYSTEM_PROMPT = ( + "Ты — ассистент расписания Никиты Сергеевича Морозова на платформе Morozovka Portal. " + "Твоя задача — кратко и по делу отвечать на вопросы студентов о его статусе, расписании и контактах. " + "Отвечай на русском, коротко (1–3 предложения), дружелюбно. " + "Обязательно используй инструменты (tools), чтобы получить актуальные данные — " + "не выдумывай статус, время или названия событий. " + "Если данных нет — так и скажи. " + "Не рассказывай про свои инструменты и внутреннее устройство — просто дай ответ." +) + + +def _call_llm(messages): + url = f"{config.LLM_API_BASE.rstrip('/')}/chat/completions" + headers = {"Content-Type": "application/json"} + if config.LLM_API_KEY: + headers["Authorization"] = f"Bearer {config.LLM_API_KEY}" + payload = { + "model": config.LLM_MODEL, + "messages": messages, + "tools": TOOLS, + "tool_choice": "auto", + "temperature": config.LLM_TEMPERATURE, + "max_tokens": config.LLM_MAX_TOKENS, + } + r = requests.post(url, headers=headers, json=payload, timeout=config.LLM_TIMEOUT) + if r.status_code != 200: + raise RuntimeError(f"LLM API HTTP {r.status_code}: {r.text[:300]}") + data = r.json() + choices = data.get("choices") or [] + if not choices: + raise RuntimeError(f"LLM вернул пустой ответ: {data}") + return choices[0]["message"] + + +def chat(message, history=None): + if not is_enabled(): + raise RuntimeError("LLM disabled") + + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + if history: + for h in history[-config.LLM_MAX_HISTORY:]: + role = h.get("role") + content = (h.get("content") or "").strip() + if role in ("user", "assistant") and content: + messages.append({"role": role, "content": content}) + messages.append({"role": "user", "content": message}) + + for _ in range(3): + assistant_msg = _call_llm(messages) + tool_calls = assistant_msg.get("tool_calls") + if not tool_calls: + return (assistant_msg.get("content") or "").strip() + + messages.append({ + "role": "assistant", + "content": assistant_msg.get("content") or "", + "tool_calls": tool_calls, + }) + + for tc in tool_calls: + fn = tc.get("function") or {} + name = fn.get("name") + impl = TOOL_IMPLS.get(name) + if impl: + try: + result = impl() + except Exception as e: + result = f"Ошибка инструмента: {e}" + else: + result = f"Неизвестный инструмент: {name}" + messages.append({ + "role": "tool", + "tool_call_id": tc.get("id"), + "content": result, + }) + + raise RuntimeError("LLM не вернул финальный ответ после tool calling") diff --git a/app/main.py b/app/main.py index 84200a5..7fed881 100644 --- a/app/main.py +++ b/app/main.py @@ -2,16 +2,23 @@ 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 +from . import config, auth, assistant, gitea_api, llm_assistant from sqlmodel import Session, select -from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession, ProjectMember +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 @@ -21,63 +28,31 @@ serializer = URLSafeTimedSerializer(app.secret_key) MSK = pytz.timezone("Europe/Moscow") + +# ══════════════════════════════════════════════════════════════════════ +# БАЗОВЫЕ ХЕЛПЕРЫ И ДЕКОРАТОРЫ (должны быть ДО всех @app.route) +# ══════════════════════════════════════════════════════════════════════ + def to_msk(dt): - """UTC → Europe/Moscow для отображения в шаблонах и API.""" 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) -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")) - -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 - +@app.template_filter("date_ru") +def _date_ru(d): + if not d: return "" 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 + 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): @@ -91,6 +66,7 @@ def require_role(allowed_roles): return decorated_function return decorator + def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -99,29 +75,401 @@ def login_required(f): return f(*args, **kwargs) return decorated_function + def parse_datetime(dt_str): - """Ввод из формы — в Europe/Moscow, сохраняем как UTC.""" 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"}) + 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": @@ -130,29 +478,27 @@ def register(): 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) + 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() - - token = serializer.dumps(email, salt='email-confirm') - confirm_url = url_for('verify_email', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") - send_email(email, "Подтверждение почты", f"Привет, {full_name}!\nДля подтверждения перейди по ссылке:\n{confirm_url}\n") - - return render_template("login.html", msg="Регистрация успешна. Проверьте почту (или логи сервера).") + 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: @@ -162,31 +508,31 @@ def verify_email(token): 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.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) + return render_template("login.html", msg=request.args.get("msg")) + @app.route("/login/gitea") def login_gitea(): @@ -198,6 +544,7 @@ def login_gitea(): ) return redirect(gitea_auth_url) + @app.route("/auth/callback") def auth_callback(): code = request.args.get("code") @@ -206,7 +553,6 @@ def auth_callback(): 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: @@ -216,20 +562,26 @@ def auth_callback(): 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) + 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.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: @@ -241,6 +593,7 @@ def logout(): session.clear() return redirect(url_for("index")) + @app.route("/forgot_password", methods=["GET", "POST"]) def forgot_password(): if request.method == "POST": @@ -254,11 +607,11 @@ def forgot_password(): 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() @@ -269,13 +622,18 @@ def reset_password(token): 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() + 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(): @@ -286,71 +644,162 @@ def logout_all(): 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")) - return render_template("profile.html", user=user) + 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) + 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, - } + {"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) + 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) + 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() - if not data or "message" not in data: return jsonify({"error": "Нет сообщения"}), 400 - return jsonify({"reply": assistant.get_response(data["message"])}) + 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"]) @@ -360,6 +809,7 @@ def admin_schedule(): 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(): @@ -369,14 +819,22 @@ def update_status(): 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(): @@ -385,17 +843,25 @@ def add_event(): start_time = parse_datetime(request.form.get("start_time")) end_time = parse_datetime(request.form.get("end_time")) with Session(engine) as db_session: - 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")) + 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") @@ -407,31 +873,42 @@ def edit_event(event_id): 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")) -# --- НОВЫЕ РОУТЫ: ИМПОРТ ICS --- + @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 + if 'file' not in request.files: return "Файл не загружен", 400 file = request.files['file'] - if file.filename == '': - return "Файл не выбран", 400 - + if file.filename == '': return "Файл не выбран", 400 if file and file.filename.endswith('.ics'): try: cal = Calendar.from_ical(file.read()) @@ -442,65 +919,71 @@ def import_ics(): summary = str(component.get('summary', 'Без названия')) dtstart_prop = component.get('dtstart') dtend_prop = component.get('dtend') - - if not dtstart_prop or not dtend_prop: - continue - + 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) - - loc = str(component.get('location', '')) - desc = str(component.get('description', '')) - + 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', + title=summary, event_type='lecture', start_time=dtstart.astimezone(timezone.utc), end_time=dtend.astimezone(timezone.utc), - location=loc, - description=desc, + 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: - projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() - member_project_ids = [] + all_projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() + member_map = {} if user_id: - memberships = db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all() - member_project_ids = [m.project_id for m in memberships] - - filtered_projects = [] - for p in projects: + 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_projects.append(p) + filtered.append(p) elif p.visibility == "internal" and user_id: - filtered_projects.append(p) - elif p.visibility == "private" and user_id and (p.owner_id == user_id or p.id in member_project_ids or user_role == "admin"): - filtered_projects.append(p) - - return render_template("projects.html", projects=filtered_projects, role=user_role, user_id=user_id, member_ids=member_project_ids) + 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"]) @@ -508,18 +991,27 @@ 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"), owner_id=session.get("user_id") + 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() - db_session.refresh(new_proj) - db_session.add(ProjectMember(project_id=new_proj.id, user_id=session.get("user_id"), role="lead")) - db_session.commit() - return redirect(url_for("projects_list")) + 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") @@ -527,15 +1019,55 @@ def project_detail(project_id): with Session(engine) as db_session: proj = db_session.get(Project, project_id) if not proj: return "Проект не найден", 404 - - members = db_session.exec(select(ProjectMember, User).join(User, ProjectMember.user_id == User.id).where(ProjectMember.project_id == project_id)).all() - is_member = any(m[0].user_id == user_id for m in members) - - if proj.visibility == "private" and not is_member and user_role != "admin": + 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")) - return render_template("project_detail.html", project=proj, members=members, is_member=is_member, user_id=user_id, role=user_role) + 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 @@ -543,101 +1075,804 @@ 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() - is_lead = membership and membership.role == "lead" - - if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and not is_lead: - return "403: У вас нет прав на редактирование", 403 - + 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") + 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") + 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() - if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and (not membership or membership.role != "lead"): - return "403: Нет прав", 403 - + 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() + 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="member")) + 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 - - membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first() - if session.get("role") != "admin" and proj.owner_id != session.get("user_id") and (not membership or membership.role != "lead"): - return "403: Нет прав", 403 - + 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() + 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 proj and (session.get("role") == "admin" or proj.owner_id == session.get("user_id")): - for m in db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all(): + 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() - return render_template("files.html", files=files) + 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(): - 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) + 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=os.path.getsize(filepath), uploaded_by=session.get("user_id"))) + 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): @@ -647,19 +1882,36 @@ def file_download(file_id): 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 db_file and (session.get("role") == "admin" or db_file.uploaded_by == session.get("user_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(url_for("files_list")) + return redirect(next_url) + + +# ══════════════════════════════════════════════════════════════════════ +# АДМИНКА: ПОЛЬЗОВАТЕЛИ +# ══════════════════════════════════════════════════════════════════════ -# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ --- @app.route("/admin/users") @require_role("admin") def admin_users(): @@ -667,6 +1919,7 @@ def admin_users(): 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): @@ -676,12 +1929,14 @@ def change_user_role(user_id): 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 + 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): @@ -690,17 +1945,26 @@ def delete_user(user_id): 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 - + 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")) diff --git a/app/models.py b/app/models.py index b02e77d..6fb363b 100644 --- a/app/models.py +++ b/app/models.py @@ -48,24 +48,126 @@ class Project(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) title: str description: Optional[str] = None - status: str = Field(default="idea") # idea, in_progress, completed, frozen - visibility: str = Field(default="public") # public, internal, private + status: str = Field(default="idea") + visibility: str = Field(default="public") gitea_url: Optional[str] = None + gitea_repo: Optional[str] = None owner_id: int = Field(foreign_key="user.id") + tags: Optional[str] = None + start_date: Optional[str] = None + end_date: Optional[str] = None + contact_person: Optional[str] = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) -# --- НОВАЯ МОДЕЛЬ --- class ProjectMember(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) project_id: int = Field(foreign_key="project.id") user_id: int = Field(foreign_key="user.id") - role: str = Field(default="member") # lead, member + role: str = Field(default="viewer") joined_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) +class ProjectTask(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + project_id: int = Field(foreign_key="project.id") + title: str + description: Optional[str] = None + status: str = Field(default="backlog") + priority: str = Field(default="medium") + assignee_id: Optional[int] = Field(default=None, foreign_key="user.id") + created_by: int = Field(foreign_key="user.id") + due_date: Optional[str] = None + position: int = Field(default=0) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + class PortalFile(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) original_name: str stored_name: str size_bytes: int uploaded_by: int = Field(foreign_key="user.id") + project_id: Optional[int] = Field(default=None, foreign_key="project.id") uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class EventSubscriber(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + email: str = Field(index=True) + user_id: Optional[int] = Field(default=None, foreign_key="user.id") + unsubscribe_token: str = Field(unique=True, index=True) + is_confirmed: bool = Field(default=False) + confirm_token: Optional[str] = Field(default=None, unique=True, index=True) + notify_status: bool = Field(default=True) + notify_events: bool = Field(default=True) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +# ══════════════════════════════════════════════════════════════════════ +# МАСТЕР-КЛАССЫ +# ══════════════════════════════════════════════════════════════════════ + +class Workshop(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + title: str + description: Optional[str] = None + program: Optional[str] = None + lead_id: int = Field(foreign_key="user.id") + category: str = Field(default="other") + difficulty: str = Field(default="beginner") # beginner, intermediate, advanced + partner: Optional[str] = None # ННГУ, Неймарк, Ядро, ... + status: str = Field(default="draft") # draft, published, archived + visibility: str = Field(default="public") # public, internal + location: Optional[str] = None + materials_required: Optional[str] = None + outcome: Optional[str] = None # что получит участник + has_certificate: bool = Field(default=False) + max_participants: Optional[int] = None + cover_url: Optional[str] = None + tags: Optional[str] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class WorkshopSession(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + workshop_id: int = Field(foreign_key="workshop.id") + start_time: datetime + end_time: datetime + location_override: Optional[str] = None + note: Optional[str] = None + +class WorkshopRegistration(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + workshop_id: int = Field(foreign_key="workshop.id") + user_id: int = Field(foreign_key="user.id") + status: str = Field(default="registered") + notes: Optional[str] = None + registered_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class WorkshopMaterial(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + workshop_id: int = Field(foreign_key="workshop.id") + title: str + description: Optional[str] = None + file_id: Optional[int] = Field(default=None, foreign_key="portalfile.id") + external_url: Optional[str] = None + visible_to: str = Field(default="registered") + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class WorkshopFeedback(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + workshop_id: int = Field(foreign_key="workshop.id") + user_id: int = Field(foreign_key="user.id") + rating: int = Field(default=5) + comment: Optional[str] = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +# ══════════════════════════════════════════════════════════════════════ +# УВЕДОМЛЕНИЯ +# ══════════════════════════════════════════════════════════════════════ + +class Notification(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id", index=True) + title: str + body: Optional[str] = None + link_url: Optional[str] = None + kind: str = Field(default="info") # info, success, warning, error + is_read: bool = Field(default=False, index=True) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/app/static/notif_bell.js b/app/static/notif_bell.js new file mode 100644 index 0000000..f3f185b --- /dev/null +++ b/app/static/notif_bell.js @@ -0,0 +1,131 @@ +(function() { + 'use strict'; + if (window.__notifBellLoaded) return; + window.__notifBellLoaded = true; + + const style = document.createElement('style'); + style.textContent = ` + .notif-bell { position: fixed; top: 12px; right: 32px; z-index: 1000; } + .notif-bell-btn { position: relative; background: #21262d; border: 1px solid #30363d; border-radius: 6px; width: 32px; height: 32px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #c9d1d9; padding: 0; } + .notif-bell-btn:hover { background: #30363d; } + .notif-bell-badge { position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; padding: 0 5px; background: #f85149; color: #fff; border-radius: 10px; font-size: 11px; font-weight: 600; display: none; align-items: center; justify-content: center; } + .notif-bell-badge.visible { display: inline-flex; } + .notif-dropdown { position: absolute; top: 40px; right: 0; width: 360px; max-height: 500px; overflow-y: auto; background: #161b22; border: 1px solid #30363d; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); display: none; } + .notif-dropdown.open { display: block; } + .notif-header { padding: 12px 16px; border-bottom: 1px solid #30363d; display: flex; justify-content: space-between; align-items: center; } + .notif-header h4 { margin: 0; font-size: 14px; font-weight: 600; color: #e6edf3; } + .notif-mark-all { color: #58a6ff; background: none; border: none; cursor: pointer; font-size: 12px; padding: 0; } + .notif-mark-all:hover { text-decoration: underline; } + .notif-list { padding: 0; } + .notif-item { display: block; padding: 12px 16px 12px 22px; border-bottom: 1px solid #21262d; color: inherit; cursor: pointer; text-decoration: none; transition: background 0.15s; position: relative; } + .notif-item:hover { background: #1c2128; text-decoration: none; } + .notif-item.unread { background: rgba(88,166,255,0.05); } + .notif-item.unread::before { content: ''; position: absolute; left: 8px; top: 18px; width: 6px; height: 6px; background: #58a6ff; border-radius: 50%; } + .notif-item-title { color: #e6edf3; font-weight: 500; font-size: 13px; margin-bottom: 2px; } + .notif-item-body { color: #8b949e; font-size: 12px; line-height: 1.4; margin-bottom: 4px; } + .notif-item-time { color: #6e7681; font-size: 11px; } + .notif-item.kind-success .notif-item-title { color: #3fb950; } + .notif-item.kind-warning .notif-item-title { color: #d29922; } + .notif-item.kind-error .notif-item-title { color: #f85149; } + .notif-empty { padding: 32px 16px; text-align: center; color: #8b949e; font-size: 13px; } + .notif-footer { padding: 8px 16px; border-top: 1px solid #30363d; text-align: center; } + .notif-footer a { color: #58a6ff; font-size: 12px; } + @media (max-width: 768px) { + .notif-bell { top: 8px; right: 16px; } + .notif-dropdown { width: calc(100vw - 32px); right: -8px; } + }`; + document.head.appendChild(style); + + const container = document.createElement('div'); + container.className = 'notif-bell'; + container.innerHTML = ` + +
+
+

Уведомления

+ +
+
+
Загрузка...
+
+ +
`; + document.body.appendChild(container); + + const btn = document.getElementById('notif-bell-btn'); + const badge = document.getElementById('notif-bell-badge'); + const dropdown = document.getElementById('notif-dropdown'); + const list = document.getElementById('notif-list'); + const markAll = document.getElementById('notif-mark-all'); + + function fmtTime(iso) { + const d = new Date(iso); + const now = new Date(); + const diff = (now - d) / 1000; + if (diff < 60) return 'только что'; + if (diff < 3600) return Math.floor(diff/60) + ' мин назад'; + if (diff < 86400) return Math.floor(diff/3600) + ' ч назад'; + return d.toLocaleDateString('ru-RU'); + } + + function escapeHtml(s) { + return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + } + + function render(data) { + badge.textContent = data.unread; + badge.classList.toggle('visible', data.unread > 0); + if (!data.items || data.items.length === 0) { + list.innerHTML = '
Нет уведомлений
'; + return; + } + list.innerHTML = data.items.map(n => ` + +
${escapeHtml(n.title)}
+ ${n.body ? `
${escapeHtml(n.body)}
` : ''} +
${fmtTime(n.created_at)}
+
+ `).join(''); + list.querySelectorAll('.notif-item').forEach(el => { + el.addEventListener('click', async (e) => { + const id = el.dataset.id; + const link = el.dataset.link; + e.preventDefault(); + try { await fetch(`/api/notifications/${id}/read`, { method: 'POST' }); } catch (err) {} + if (link) window.location.href = link; + else { el.classList.remove('unread'); refresh(); } + }); + }); + } + + async function refresh() { + try { + const r = await fetch('/api/notifications', { credentials: 'same-origin' }); + if (!r.ok) return; + const data = await r.json(); + render(data); + } catch (e) {} + } + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + dropdown.classList.toggle('open'); + if (dropdown.classList.contains('open')) refresh(); + }); + + document.addEventListener('click', (e) => { + if (!container.contains(e.target)) dropdown.classList.remove('open'); + }); + + markAll.addEventListener('click', async (e) => { + e.stopPropagation(); + try { await fetch('/api/notifications/read-all', { method: 'POST' }); } catch (err) {} + refresh(); + }); + + refresh(); + setInterval(refresh, 30000); +})(); diff --git a/app/templates/index.html b/app/templates/index.html index 9fa81a9..0a8ee28 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,52 +1,194 @@ - - - Morozovka Portal - + + +Morozovka Platform + -

Morozovka Portal

- - {% if username %} -

Привет, {{ username }}!

-
- Мой профиль - Расписание - Проекты - Файлы - - {% if session.get('role') in ['admin', 'professor'] %} - Управление расписанием - {% endif %} + - {% if session.get('role') == 'admin' %} - Админ-панель +
+
+
+ {% if username %} +

Привет, {{ username }}!

+

Добро пожаловать на платформу команды Морозовки. Расписание, проекты, мастер-классы и файлы — всё в одном месте.

+ {% else %} +

Morozovka Platform

+

Единая платформа команды: расписание, проекты, мастер-классы и файлы. Войдите, чтобы получить полный доступ.

{% endif %} +
- {% else %} -

Платформа для команды и проектов

-
- Войти / Регистрация - Расписание - Проекты +
+
+
Статус Никиты Сергеевича
+
Загрузка...
+
Проверяем расписание...
+ Открыть расписание → +
- {% endif %} - -
Stage 1 (Identity): Complete
+
+ +
+

Разделы платформы

+
+ + +
+ + + + + diff --git a/app/templates/notifications_page.html b/app/templates/notifications_page.html new file mode 100644 index 0000000..15082d0 --- /dev/null +++ b/app/templates/notifications_page.html @@ -0,0 +1,71 @@ + + + + + +Уведомления — Morozovka Portal + + + + +
+

Уведомления

+ {% if notifications %} + + {% else %} +
Уведомлений пока нет
+ {% endif %} +
+ + + diff --git a/app/templates/profile.html b/app/templates/profile.html index 1e8f219..2086194 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -5,53 +5,113 @@ Профиль — Morozovka Portal

Личный кабинет

-
Логин
{{ user.username }}
-
ФИО
{{ user.full_name or 'Не указано' }}
-
Email
{{ user.email or 'Не указано' }} {% if user.is_verified %}✓ Подтвержден{% endif %}
-
Организация
{{ user.organization or 'Не указана' }}
- -
+
Роль в системе
{{ user.role }}
- - + +
+

📬 Уведомления о расписании

+ + {% if not user.email %} +
⚠ Укажите email в профиле, чтобы получать уведомления.
+ {% elif subscription and subscription.is_confirmed %} +
+ ✅ Подписка активна + {{ user.email }} +
+
+ + + + +
+ +
+
+

+ Отписаться можно в один клик. Повторная подписка тоже в один клик — никаких подтверждений по почте. +

+ {% else %} +
Вы пока не подписаны на уведомления
+
+ + + + +
+ +
+
+

+ Email берётся из вашего аккаунта — подтверждение не требуется. +

+ {% endif %} +
diff --git a/app/templates/project_board.html b/app/templates/project_board.html new file mode 100644 index 0000000..ddbedbf --- /dev/null +++ b/app/templates/project_board.html @@ -0,0 +1,288 @@ + + + + + +{{ project.title }} — Доска — Morozovka Portal + + + +
+ ← К проекту +
+

{{ project.title }} — Доска

+
+ + + +
+ {% for status in TASK_STATUSES %} +
+
+ {{ TASK_STATUS_LABELS[status] }} + {{ columns[status]|length }} +
+
+ {% for item in columns[status] %} + {% set task = item.task %} +
+
{{ task.title }}
+ {% if task.description %} +
{{ task.description }}
+ {% endif %} +
+ {% if item.assignee %} + 👤 {{ item.assignee.full_name or item.assignee.username }} + {% else %} + — не назначено + {% endif %} + {% if task.due_date %} + {{ task.due_date|date_ru }} + {% endif %} + {% if perms.can_edit %} +
+ ✎ + {% if perms.can_manage %} +
+ +
+ {% endif %} +
+ {% endif %} +
+
+ {% endfor %} +
+ {% if perms.can_edit %} + + {% endif %} +
+ {% endfor %} +
+
+ + + + + + + diff --git a/app/templates/project_detail.html b/app/templates/project_detail.html index 1d63fdc..eeab76d 100644 --- a/app/templates/project_detail.html +++ b/app/templates/project_detail.html @@ -1,98 +1,355 @@ - - {{ project.title }} — Morozovka Portal - + + +{{ project.title }} — Morozovka Portal + -
- ← К проектам - -
-
-

{{ project.title }}

-
- {{ project.status }} - {{ project.visibility }} -
-
-
- {% if role == 'admin' or project.owner_id == user_id or (is_member and role == 'lead') %} - Редактировать - {% endif %} - - {% if role == 'admin' or project.owner_id == user_id %} -
- -
- {% endif %} +
+ ← К проектам + +
+
+

{{ project.title }}

+
+ + {% if project.status == 'idea' %}Идея + {% elif project.status == 'in_progress' %}В работе + {% elif project.status == 'completed' %}Завершён + {% else %}Заморожен{% endif %} + + + {% if project.visibility == 'public' %}Публичный + {% elif project.visibility == 'internal' %}Внутренний + {% else %}Приватный{% endif %} +
- -
{{ project.description or 'Описание пока не добавлено.' }}
- - {% if project.gitea_url %} - 📦 Перейти в репозиторий Gitea - {% endif %} - -

Участники ({{ members|length }})

-
- {% for member_obj, user in members %} -
-
-
{{ user.full_name or user.username }}
-
{{ member_obj.role }}
-
- {% if (role == 'admin' or project.owner_id == user_id or (is_member and role == 'lead')) and user.id != project.owner_id %} -
- -
- {% endif %} -
- {% endfor %} +
+ {% if perms.can_edit %} + Редактировать + {% endif %} + {% if perms.can_delete %} +
+ +
+ {% endif %}
+
- {% if role == 'admin' or project.owner_id == user_id or (is_member and role == 'lead') %} -

Добавить участника

-
- + + + {% if project.tags %} +
+ {% for t in project.tags.split(',') %}#{{ t.strip() }}{% endfor %} +
+ {% endif %} + +
+
+
Начало
+
{{ project.start_date|date_ru or '—' }}
+
+
+
Завершение
+
{{ project.end_date|date_ru or '—' }}
+
+
+
Контакт
+
{{ project.contact_person or '—' }}
+
+
+
Ваша роль
+
+ {% if perms.project_role == 'manager' %}Manager + {% elif perms.project_role == 'editor' %}Editor + {% elif perms.project_role == 'viewer' %}Viewer + {% else %}—{% endif %} +
+
+
+ +
{{ project.description or 'Описание пока не добавлено.' }}
+ + {% if project.gitea_url %} + 📦 Перейти в репозиторий Gitea + {% endif %} + + + {% if project.gitea_url or project.gitea_repo %} +
+
Загружаю статистику из Gitea...
+
+ {% endif %} + +

Участники {{ members|length }}

+ + {% if perms.can_manage %} +
+ +
+ + +
+
+ + +
- {% endif %}
+ {% endif %} + +
+ {% for member_obj, user in members %} +
+
+
{{ user.full_name or user.username }} @{{ user.username }}
+ {% if user.id == project.owner_id %} + Владелец + {% else %} + + {% if member_obj.role == 'manager' %}Manager + {% elif member_obj.role == 'editor' %}Editor + {% else %}Viewer{% endif %} + + {% endif %} +
+ {% if perms.can_manage and user.id != project.owner_id %} +
+
+ + +
+
+ +
+
+ {% endif %} +
+ {% endfor %} +
+ +

Файлы проекта {{ files|length }}

+ + {% if perms.can_upload %} +
+
+
+ + +
+ +
+
+ {% endif %} + + {% if files %} +
+ {% for file, uploader in files %} +
+
+
{{ file.original_name }}
+
{{ (file.size_bytes / 1024 / 1024) | round(2) }} МБ · {{ uploader }} · {{ file.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }}
+
+
+ Скачать + {% if perms.can_manage or file.uploaded_by == user_id %} +
+ + +
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +
+
Файлов в проекте пока нет.{% if perms.can_upload %} Загрузите первый файл выше.{% endif %}
+
+ {% endif %} +
+ + diff --git a/app/templates/project_form.html b/app/templates/project_form.html index 561526f..a8f8de1 100644 --- a/app/templates/project_form.html +++ b/app/templates/project_form.html @@ -1,34 +1,42 @@ - - {{ 'Редактирование' if is_edit else 'Новый проект' }} — Morozovka Portal - + + +{{ 'Редактирование' if is_edit else 'Новый проект' }} — Morozovka Portal + -
- ← Назад -

{{ 'Редактирование проекта' if is_edit else 'Создание нового проекта' }}

-
-
- - -
-
- - -
+
+ ← Назад +

{{ 'Редактирование проекта' if is_edit else 'Создание нового проекта' }}

+ +
+ + +
+
+ + +
+ +
+
+ +
- - + +
- - -
+
+ + +
+
+ +
+ + +
+ +
+ + +
Через запятую. Сохраняются в нижнем регистре, без дублей.
+
+ +
+ + +
+ +
+ + +
Можно оставить пустым — автоматически извлечётся из URL выше. Заполняйте вручную, если URL нестандартный.
+
+ + + +
+ + diff --git a/app/templates/projects.html b/app/templates/projects.html index b9af987..1239cf8 100644 --- a/app/templates/projects.html +++ b/app/templates/projects.html @@ -1,79 +1,119 @@ - - Проекты — Morozovka Portal - + + +Проекты — Morozovka Portal + -
- ← На главную -
-

Каталог проектов

- {% if role in ['admin', 'professor', 'lead'] %} - + Добавить проект - {% endif %} +
+ ← На главную +
+

Каталог проектов

+ {% if role in ['admin', 'professor', 'lead'] %} + + Добавить проект + {% endif %} +
+ +
+ - - + + +
diff --git a/app/templates/schedule.html b/app/templates/schedule.html index 908412d..e535fbf 100644 --- a/app/templates/schedule.html +++ b/app/templates/schedule.html @@ -11,7 +11,9 @@ body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMac h1 { color: #58a6ff; margin: 0 0 15px; } .back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; } .back-link:hover { color: #58a6ff; } - +.banner { padding: 12px 18px; border-radius: 8px; margin-bottom: 15px; font-size: .9rem; } +.banner-msg { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; } +.banner-error { background: rgba(248,81,73,.15); border: 1px solid #f85149; color: #f85149; } .status-banner { padding: 18px 20px; border-radius: 10px; text-align: center; margin-bottom: 20px; } .status-on_place { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; } .status-delayed { background: rgba(210,153,34,.15); border: 1px solid #d29922; color: #d29922; } @@ -19,7 +21,6 @@ h1 { color: #58a6ff; margin: 0 0 15px; } .status-free { background: rgba(139,148,158,.15); border: 1px solid #8b949e; color: #8b949e; } .status-title { font-size: 1.3rem; font-weight: bold; margin-bottom: 4px; } .status-message { font-size: .95rem; opacity: .9; } - .calendar { background: #161b22; border: 1px solid #30363d; border-radius: 12px; overflow: hidden; } .cal-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid #30363d; gap: 15px; flex-wrap: wrap; } .cal-nav { display: flex; gap: 8px; align-items: center; } @@ -32,13 +33,11 @@ h1 { color: #58a6ff; margin: 0 0 15px; } .view-switcher button { background: transparent; border: none; color: #8b949e; padding: 8px 14px; cursor: pointer; font-weight: 600; font-size: .9rem; transition: all .15s; } .view-switcher button:hover { color: #e6edf3; } .view-switcher button.active { background: #58a6ff; color: #fff; } - .cal-filters { display: flex; gap: 10px; padding: 12px 20px; border-bottom: 1px solid #30363d; flex-wrap: wrap; background: #0d1117; } .filter-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 20px; cursor: pointer; font-size: .85rem; color: #8b949e; user-select: none; transition: all .15s; } .filter-chip:hover { border-color: #58a6ff; } .filter-chip input { display: none; } .filter-chip:has(input:checked) { background: rgba(88,166,255,.1); border-color: #58a6ff; color: #e6edf3; } - .month-header { display: grid; grid-template-columns: repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; } .month-header-cell { padding: 10px; text-align: center; color: #8b949e; font-weight: 600; font-size: .75rem; text-transform: uppercase; } .month-grid { display: grid; grid-template-columns: repeat(7,1fr); grid-auto-rows: minmax(110px,auto); } @@ -54,7 +53,6 @@ h1 { color: #58a6ff; margin: 0 0 15px; } .month-event .event-time { font-weight: 600; opacity: .75; } .month-event .event-title { overflow: hidden; text-overflow: ellipsis; } .month-more { font-size: .7rem; color: #58a6ff; padding: 2px 4px; } - .week-view, .day-view { display: flex; flex-direction: column; } .week-header { display: grid; grid-template-columns: 50px repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; } .week-day-header { padding: 10px 4px; text-align: center; border-left: 1px solid #30363d; } @@ -76,7 +74,6 @@ h1 { color: #58a6ff; margin: 0 0 15px; } .we-desc { font-size: .7rem; opacity: .8; margin-top: 4px; line-height: 1.3; } .now-line { position: absolute; left: 0; right: 0; height: 2px; background: #f85149; z-index: 20; pointer-events: none; } .now-line::before { content: ''; position: absolute; left: -4px; top: -3px; width: 8px; height: 8px; background: #f85149; border-radius: 50%; } - .event-type-lecture { background: rgba(88,166,255,.18); border-left-color: #58a6ff; } .event-type-workshop { background: rgba(163,113,247,.18); border-left-color: #a371f7; } .event-type-meeting { background: rgba(210,153,34,.18); border-left-color: #d29922; } @@ -85,7 +82,6 @@ h1 { color: #58a6ff; margin: 0 0 15px; } .month-event.event-type-workshop { background: rgba(163,113,247,.15); color: #a371f7; } .month-event.event-type-meeting { background: rgba(210,153,34,.15); color: #d29922; } .month-event.event-type-personal { background: rgba(139,148,158,.15); color: #8b949e; } - .event-popup { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; z-index: 1000; box-shadow: 0 20px 50px rgba(0,0,0,.6); } .event-popup.hidden { display: none; } .event-popup h3 { margin: 0 0 12px; color: #58a6ff; } @@ -94,6 +90,18 @@ h1 { color: #58a6ff; margin: 0 0 15px; } #popup-meta { color: #8b949e; font-size: .9rem; line-height: 1.8; } #popup-desc { margin-top: 15px; color: #c9d1d9; line-height: 1.5; white-space: pre-wrap; } +.subscribe-box { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px 25px; margin-top: 20px; } +.subscribe-box h3 { margin: 0 0 8px; color: #58a6ff; font-size: 1.1rem; } +.subscribe-box p { color: #8b949e; margin: 0 0 15px; font-size: .9rem; line-height: 1.5; } +.sub-form { display: flex; flex-direction: column; gap: 12px; max-width: 500px; } +.sub-checkbox { display: flex; align-items: center; gap: 8px; color: #c9d1d9; font-size: .9rem; cursor: pointer; } +.sub-checkbox input { width: 16px; height: 16px; } +.btn-primary { background: #238636; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: .95rem; display: inline-block; text-decoration: none; } +.btn-primary:hover { background: #2ea043; } +.btn-danger { background: #da3633; color: #fff; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: .95rem; } +.btn-danger:hover { background: #f85149; } +.sub-active-badge { display: inline-block; padding: 4px 10px; background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; border-radius: 20px; font-size: .8rem; margin-bottom: 10px; } + .chat-fab { position: fixed; bottom: 25px; right: 25px; width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg,#58a6ff,#a371f7); color: #fff; border: none; font-size: 1.5rem; cursor: pointer; box-shadow: 0 6px 20px rgba(88,166,255,.4); z-index: 999; transition: transform .2s; display: flex; align-items: center; justify-content: center; } .chat-fab:hover { transform: scale(1.08); } .chat-window { position: fixed; bottom: 95px; right: 25px; width: 360px; height: 500px; background: #161b22; border: 1px solid #30363d; border-radius: 12px; display: flex; flex-direction: column; z-index: 998; box-shadow: 0 20px 50px rgba(0,0,0,.6); overflow: hidden; } @@ -127,6 +135,9 @@ h1 { color: #58a6ff; margin: 0 0 15px; } ← На главную

Расписание

+ {% if msg %}{% endif %} + {% if error %}{% endif %} + {% if status %}
@@ -162,6 +173,41 @@ h1 { color: #58a6ff; margin: 0 0 15px; }
+ + +