From c356166844c9d121005e0bb872b3f839ea20c7fa Mon Sep 17 00:00:00 2001 From: Ivan Budakov Date: Tue, 20 Oct 2026 03:27:14 +0300 Subject: [PATCH] feat: Finaling MVP --- app/assistant.py | 18 +- app/assistant.py.bak | 110 ++++ app/auth.py | 2 +- app/auth.py.bak | 24 + app/config.py | 1 + app/config.py.bak | 27 + app/google_sync.py | 81 +++ app/google_sync.py.bak | 79 +++ app/main.py | 475 +++++++++++----- app/main.py.bak | 679 +++++++++++++++++++++++ app/models.py | 18 +- app/templates/admin_event_edit.html | 100 ++++ app/templates/admin_event_edit.html.bak | 100 ++++ app/templates/admin_schedule.html | 106 +++- app/templates/admin_schedule.html.bak | 199 +++++++ app/templates/admin_schedule_import.html | 54 ++ app/templates/files.html | 2 +- app/templates/files.html.bak | 83 +++ app/templates/project_detail.html | 98 ++++ app/templates/project_form.html | 35 +- app/templates/projects.html | 43 +- app/templates/schedule.html | 574 ++++++++++++++----- app/templates/schedule.html.bak | 143 +++++ app/templates/sessions.html | 2 +- app/templates/sessions.html.bak | 51 ++ dump_project.sh | 177 ++++++ google_credentials.json | 13 + requirements.txt | 24 + 28 files changed, 2987 insertions(+), 331 deletions(-) create mode 100644 app/assistant.py.bak create mode 100644 app/auth.py.bak create mode 100644 app/config.py.bak create mode 100644 app/google_sync.py create mode 100644 app/google_sync.py.bak create mode 100644 app/main.py.bak create mode 100644 app/templates/admin_event_edit.html create mode 100644 app/templates/admin_event_edit.html.bak create mode 100644 app/templates/admin_schedule.html.bak create mode 100644 app/templates/admin_schedule_import.html create mode 100644 app/templates/files.html.bak create mode 100644 app/templates/project_detail.html create mode 100644 app/templates/schedule.html.bak create mode 100644 app/templates/sessions.html.bak create mode 100755 dump_project.sh create mode 100644 google_credentials.json diff --git a/app/assistant.py b/app/assistant.py index e70051c..b2e0ed6 100644 --- a/app/assistant.py +++ b/app/assistant.py @@ -1,9 +1,16 @@ import re from datetime import datetime, timezone, timedelta +import pytz from sqlmodel import Session, select from .models import Event, GlobalStatus from .db import engine +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 get_response(text: str) -> str: """Принимает вопрос, определяет интент и возвращает ответ.""" text = text.lower().strip() @@ -44,7 +51,7 @@ def get_response(text: str) -> str: ).first() if next_event: - local_time = next_event.start_time.strftime('%d.%m в %H:%M') + local_time = _msk(next_event.start_time).strftime('%d.%m в %H:%M') return f"Ближайшее событие: {next_event.title} ({local_time}). Место: {next_event.location or 'не указано'}." else: return "В ближайшее время событий в расписании нет." @@ -52,8 +59,9 @@ def get_response(text: str) -> str: # --- ИНТЕНТ: РАСПИСАНИЕ НА СЕГОДНЯ --- if re.search(r'сегодня|седня', text): # Берем начало сегодняшнего дня и конец сегодняшнего дня - now = datetime.now(timezone.utc) - start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) + now_msk = datetime.now(MSK) + start_of_day_msk = now_msk.replace(hour=0, minute=0, second=0, microsecond=0) + start_of_day = start_of_day_msk.astimezone(timezone.utc) end_of_day = start_of_day + timedelta(days=1) events = db_session.exec( @@ -69,7 +77,7 @@ def get_response(text: str) -> str: res = "Расписание на сегодня:\n" for e in events: - res += f"• {e.start_time.strftime('%H:%M')} - {e.title} ({e.location or 'без места'})\n" + res += f"• {_msk(e.start_time).strftime('%H:%M')} - {e.title} ({e.location or 'без места'})\n" return res # --- ИНТЕНТ: РАСПИСАНИЕ НА НЕДЕЛЮ --- @@ -90,7 +98,7 @@ def get_response(text: str) -> str: res = "Расписание на неделю:\n" for e in events[:5]: # Показываем первые 5, чтобы не спамить - res += f"• {e.start_time.strftime('%d.%m %H:%M')} - {e.title}\n" + res += f"• {_msk(e.start_time).strftime('%d.%m %H:%M')} - {e.title}\n" if len(events) > 5: res += "...и другие события (смотрите на странице)." return res diff --git a/app/assistant.py.bak b/app/assistant.py.bak new file mode 100644 index 0000000..b862a6b --- /dev/null +++ b/app/assistant.py.bak @@ -0,0 +1,110 @@ +import re +from datetime import datetime, timezone, timedelta +import pytz +from sqlmodel import Session, select +from .models import Event, GlobalStatus +from .db import engine + +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 get_response(text: str) -> str: + """Принимает вопрос, определяет интент и возвращает ответ.""" + text = text.lower().strip() + + with Session(engine) as db_session: + # Получаем текущий статус + current_status = db_session.exec(select(GlobalStatus)).first() + + # --- ИНТЕНТ: ГДЕ СЕЙЧАС / СТАТУС --- + if re.search(r'где|статус|сейчас', text): + if current_status.status == 'on_place': + return "Никита Сергеевич сейчас на месте (в аудитории)." + elif current_status.status == 'delayed': + return f"Никита Сергеевич опаздывает на {current_status.delay_minutes} мин. Причина: {current_status.message or 'не указана'}." + elif current_status.status == 'away': + return "Никита Сергеевич вне вуза." + elif current_status.status == 'cancelled': + return f"Занятия отменены! {current_status.message or ''}" + else: + return "Никита Сергеевич сейчас свободен / вне расписания." + + # --- ИНТЕНТ: ОПАЗДЫВАЕТ? --- + if re.search(r'опаздыва|задержива', text): + if current_status.status == 'delayed': + return f"Да, опаздывает на {current_status.delay_minutes} мин. Причина: {current_status.message or 'не указана'}." + else: + return "По текущему статусу, он не опаздывает." + + # --- ИНТЕНТ: КОГДА СЛЕДУЮЩАЯ ПАРА / БЛИЖАЙШЕЕ --- + if re.search(r'когда|следующ|ближайш', text): + now = datetime.now(timezone.utc) + next_event = db_session.exec( + select(Event) + .where(Event.is_public == True) + .where(Event.start_time >= now) + .order_by(Event.start_time) + .limit(1) + ).first() + + if next_event: + local_time = next_event.start_time.strftime('%d.%m в %H:%M') + return f"Ближайшее событие: {next_event.title} ({local_time}). Место: {next_event.location or 'не указано'}." + else: + return "В ближайшее время событий в расписании нет." + + # --- ИНТЕНТ: РАСПИСАНИЕ НА СЕГОДНЯ --- + if re.search(r'сегодня|седня', text): + # Берем начало сегодняшнего дня и конец сегодняшнего дня + now = datetime.now(timezone.utc) + start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) + end_of_day = start_of_day + timedelta(days=1) + + events = db_session.exec( + select(Event) + .where(Event.is_public == True) + .where(Event.start_time >= start_of_day) + .where(Event.start_time < end_of_day) + .order_by(Event.start_time) + ).all() + + if not events: + return "На сегодня событий в расписании нет." + + res = "Расписание на сегодня:\n" + for e in events: + res += f"• {e.start_time.strftime('%H:%M')} - {e.title} ({e.location or 'без места'})\n" + return res + + # --- ИНТЕНТ: РАСПИСАНИЕ НА НЕДЕЛЮ --- + if re.search(r'недел|7 дней', text): + now = datetime.now(timezone.utc) + end_of_week = now + timedelta(days=7) + + events = db_session.exec( + select(Event) + .where(Event.is_public == True) + .where(Event.start_time >= now) + .where(Event.start_time <= end_of_week) + .order_by(Event.start_time) + ).all() + + if not events: + return "На ближайшую неделю событий нет." + + res = "Расписание на неделю:\n" + for e in events[:5]: # Показываем первые 5, чтобы не спамить + res += f"• {e.start_time.strftime('%d.%m %H:%M')} - {e.title}\n" + if len(events) > 5: + res += "...и другие события (смотрите на странице)." + return res + + # --- ИНТЕНТ: КОНТАКТЫ --- + if re.search(r'связаться|контакт|телеграм|почта|кабинет', text): + return "Связаться с Никитой Сергеевичем можно по почте: freezer.nik@gmail.com (или в Telegram)." + + # --- ЕСЛИ НИЧЕГО НЕ ПОНЯЛ --- + return "Извините, я пока не понимаю этот вопрос. Попробуйте спросить: 'Где сейчас?', 'Когда следующая пара?', 'Что сегодня?' или 'Как связаться?'." diff --git a/app/auth.py b/app/auth.py index 3ea27cc..bb10674 100644 --- a/app/auth.py +++ b/app/auth.py @@ -9,7 +9,7 @@ def get_gitea_token(code: str): "client_secret": config.GITEA_CLIENT_SECRET, "code": code, "grant_type": "authorization_code", - "redirect_uri": "https://portal.morozovka.space/auth/callback" + "redirect_uri": config.GITEA_REDIRECT_URI } response = requests.post(url, data=data, headers={"Accept": "application/json"}) return response.json().get("access_token") diff --git a/app/auth.py.bak b/app/auth.py.bak new file mode 100644 index 0000000..3ea27cc --- /dev/null +++ b/app/auth.py.bak @@ -0,0 +1,24 @@ +import requests +from . import config + +def get_gitea_token(code: str): + """Меняет временный код на access token.""" + url = f"{config.GITEA_URL}/login/oauth/access_token" + data = { + "client_id": config.GITEA_CLIENT_ID, + "client_secret": config.GITEA_CLIENT_SECRET, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": "https://portal.morozovka.space/auth/callback" + } + response = requests.post(url, data=data, headers={"Accept": "application/json"}) + return response.json().get("access_token") + +def get_gitea_user(token: str): + """Получает данные пользователя из Gitea по токену.""" + url = f"{config.GITEA_URL}/api/v1/user" + headers = {"Authorization": f"token {token}"} + response = requests.get(url, headers=headers) + if response.status_code == 200: + return response.json() + return None diff --git a/app/config.py b/app/config.py index 2f8671f..0903de7 100644 --- a/app/config.py +++ b/app/config.py @@ -25,3 +25,4 @@ 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") diff --git a/app/config.py.bak b/app/config.py.bak new file mode 100644 index 0000000..2f8671f --- /dev/null +++ b/app/config.py.bak @@ -0,0 +1,27 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +POSTGRES_USER = os.getenv("POSTGRES_USER", "morozovka") +POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "rf-slf-1945") +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") +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" + +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") diff --git a/app/google_sync.py b/app/google_sync.py new file mode 100644 index 0000000..ba8bf89 --- /dev/null +++ b/app/google_sync.py @@ -0,0 +1,81 @@ +import os +from datetime import datetime, timezone, timedelta +from google.oauth2 import service_account +from googleapiclient.discovery import build +from sqlmodel import Session, select +from .db import engine +from .models import Event + +SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] +SERVICE_ACCOUNT_FILE = '/opt/morozovka-portal/google_credentials.json' + +# !!! ВАЖНО: Укажи здесь свой реальный email, к которому привязан календарь !!! +CALENDAR_ID = 'korneshoc@gmail.com' # <--- ИЗМЕНИ ЭТО + +def sync_google_calendar(): + """Забирает события из Google Calendar и обновляет локальную БД.""" + print("[Google Sync] Начинаем синхронизацию...", flush=True) + + creds = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_FILE, scopes=SCOPES) + service = build('calendar', 'v3', credentials=creds) + + # Берем события начиная с 30 дней назад (чтобы точно захватить тесты) + time_min = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() + + print(f"[Google Sync] Запрашиваем календарь: {CALENDAR_ID}", flush=True) + events_result = service.events().list( + calendarId=CALENDAR_ID, + timeMin=time_min, + maxResults=100, + singleEvents=True, + orderBy='startTime' + ).execute() + + google_events = events_result.get('items', []) + print(f"[Google Sync] Найдено событий в Google API: {len(google_events)}", flush=True) + + count = 0 + with Session(engine) as session: + for g_event in google_events: + g_id = g_event['id'] + + # Парсим время + start = g_event['start'].get('dateTime', g_event['start'].get('date')) + end = g_event['end'].get('dateTime', g_event['end'].get('date')) + + if 'T' not in start: + continue + + start_dt = datetime.fromisoformat(start.replace('Z', '+00:00')) + end_dt = datetime.fromisoformat(end.replace('Z', '+00:00')) + + # Ищем событие в БД по google_event_id + existing = session.exec(select(Event).where(Event.google_event_id == g_id)).first() + + if existing: + if getattr(existing, 'manually_edited', False): + continue + existing.title = g_event.get('summary', 'Без названия') + existing.start_time = start_dt + existing.end_time = end_dt + existing.location = g_event.get('location') + existing.description = g_event.get('description') + session.add(existing) + else: + new_event = Event( + google_event_id=g_id, + title=g_event.get('summary', 'Без названия'), + event_type='lecture', + start_time=start_dt, + end_time=end_dt, + location=g_event.get('location'), + description=g_event.get('description'), + is_public=True + ) + session.add(new_event) + count += 1 + + session.commit() + + print(f"[Google Sync] Успешно сохранено новых событий в БД: {count}", flush=True) diff --git a/app/google_sync.py.bak b/app/google_sync.py.bak new file mode 100644 index 0000000..9eceac4 --- /dev/null +++ b/app/google_sync.py.bak @@ -0,0 +1,79 @@ +import os +from datetime import datetime, timezone, timedelta +from google.oauth2 import service_account +from googleapiclient.discovery import build +from sqlmodel import Session, select +from .db import engine +from .models import Event + +SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] +SERVICE_ACCOUNT_FILE = '/opt/morozovka-portal/google_credentials.json' + +# !!! ВАЖНО: Укажи здесь свой реальный email, к которому привязан календарь !!! +CALENDAR_ID = 'korneshoc@gmail.com' # <--- ИЗМЕНИ ЭТО + +def sync_google_calendar(): + """Забирает события из Google Calendar и обновляет локальную БД.""" + print("[Google Sync] Начинаем синхронизацию...", flush=True) + + creds = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_FILE, scopes=SCOPES) + service = build('calendar', 'v3', credentials=creds) + + # Берем события начиная с 30 дней назад (чтобы точно захватить тесты) + time_min = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() + + print(f"[Google Sync] Запрашиваем календарь: {CALENDAR_ID}", flush=True) + events_result = service.events().list( + calendarId=CALENDAR_ID, + timeMin=time_min, + maxResults=100, + singleEvents=True, + orderBy='startTime' + ).execute() + + google_events = events_result.get('items', []) + print(f"[Google Sync] Найдено событий в Google API: {len(google_events)}", flush=True) + + count = 0 + with Session(engine) as session: + for g_event in google_events: + g_id = g_event['id'] + + # Парсим время + start = g_event['start'].get('dateTime', g_event['start'].get('date')) + end = g_event['end'].get('dateTime', g_event['end'].get('date')) + + if 'T' not in start: + continue + + start_dt = datetime.fromisoformat(start.replace('Z', '+00:00')) + end_dt = datetime.fromisoformat(end.replace('Z', '+00:00')) + + # Ищем событие в БД по google_event_id + existing = session.exec(select(Event).where(Event.google_event_id == g_id)).first() + + if existing: + existing.title = g_event.get('summary', 'Без названия') + existing.start_time = start_dt + existing.end_time = end_dt + existing.location = g_event.get('location') + existing.description = g_event.get('description') + session.add(existing) + else: + new_event = Event( + google_event_id=g_id, + title=g_event.get('summary', 'Без названия'), + event_type='lecture', + start_time=start_dt, + end_time=end_dt, + location=g_event.get('location'), + description=g_event.get('description'), + is_public=True + ) + session.add(new_event) + count += 1 + + session.commit() + + print(f"[Google Sync] Успешно сохранено новых событий в БД: {count}", flush=True) diff --git a/app/main.py b/app/main.py index d4830d9..84200a5 100644 --- a/app/main.py +++ b/app/main.py @@ -1,22 +1,37 @@ import os import uuid import smtplib +import pytz from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from itsdangerous import URLSafeTimedSerializer from werkzeug.security import generate_password_hash, check_password_hash -from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory +from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory, make_response +from icalendar import Calendar, Event as ICalEvent from .db import init_db, engine from . import config, auth, assistant from sqlmodel import Session, select -from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession -from datetime import datetime, timezone +from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession, ProjectMember +from datetime import datetime, timezone, timedelta from functools import wraps app = Flask(__name__) app.secret_key = config.WEB_SECRET serializer = URLSafeTimedSerializer(app.secret_key) +MSK = pytz.timezone("Europe/Moscow") + +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 @@ -84,9 +99,23 @@ 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) + @app.route("/healthz") def healthz(): - return jsonify({"status": "ok"}) + try: + from sqlalchemy import text + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + return jsonify({"status": "ok", "db": "ok"}) + except Exception as e: + return jsonify({"status": "error", "db": str(e)}), 503 @app.route("/") def index(): @@ -108,34 +137,21 @@ def register(): 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=False) db_session.add(new_user) db_session.commit() token = serializer.dumps(email, salt='email-confirm') - confirm_url = url_for('verify_email', token=token, _external=True) - confirm_url = confirm_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") + 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") - body = f"Привет, {full_name}!\n\nДля подтверждения почты перейди по ссылке:\n{confirm_url}\n" - send_email(email, "Подтверждение почты в Morozovka Portal", body) - - return render_template("login.html", msg="Регистрация успешна. Проверьте почту для подтверждения.") + return render_template("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 + 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() @@ -154,27 +170,19 @@ def login(): 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") @@ -185,7 +193,7 @@ def login_gitea(): gitea_auth_url = ( f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize" f"?client_id={config.GITEA_CLIENT_ID}&response_type=code" - f"&redirect_uri=https://portal.morozovka.space/auth/callback" + f"&redirect_uri={config.GITEA_REDIRECT_URI}" f"&scope=read:user" ) return redirect(gitea_auth_url) @@ -193,16 +201,11 @@ def login_gitea(): @app.route("/auth/callback") def auth_callback(): code = request.args.get("code") - if not code: - return "Ошибка авторизации Gitea", 400 - + if not code: return "Ошибка авторизации Gitea", 400 token = auth.get_gitea_token(code) - if not token: - return "Ошибка токена Gitea", 400 - + if not token: return "Ошибка токена Gitea", 400 gitea_user = auth.get_gitea_user(token) - if not gitea_user: - return "Ошибка профиля Gitea", 400 + 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() @@ -211,31 +214,20 @@ def auth_callback(): if user: user.gitea_id = gitea_user["id"] else: - user = User( - gitea_id=gitea_user["id"], - username=gitea_user["login"], - full_name=gitea_user.get("full_name"), - email=gitea_user.get("email"), - role="admin" if gitea_user["login"] == "git_admin" else "student", - is_verified=True - ) + existing_admin = db_session.exec(select(User).where(User.role == "admin")).first() + bootstrap_role = "admin" if not existing_admin else "student" + user = User(gitea_id=gitea_user["id"], username=gitea_user["login"], full_name=gitea_user.get("full_name"), email=gitea_user.get("email"), role=bootstrap_role, is_verified=True) db_session.add(user) db_session.commit() session_token = str(uuid.uuid4()) - db_session.add(UserSession( - session_token=session_token, - user_id=user.id, - ip_address=request.remote_addr, - user_agent=request.user_agent.string - )) + db_session.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") @@ -249,7 +241,6 @@ def logout(): session.clear() return redirect(url_for("index")) -# --- СБРОС ПАРОЛЯ --- @app.route("/forgot_password", methods=["GET", "POST"]) def forgot_password(): if request.method == "POST": @@ -258,57 +249,43 @@ def forgot_password(): user = db_session.exec(select(User).where(User.email == email)).first() if user: token = serializer.dumps(email, salt='password-reset') - reset_url = url_for('reset_password', token=token, _external=True) - reset_url = reset_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") - - body = f"Для сброса пароля перейди по ссылке:\n{reset_url}\n" - send_email(email, "Сброс пароля Morozovka Portal", body) - + reset_url = url_for('reset_password', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") + send_email(email, "Сброс пароля", f"Для сброса пароля перейди по ссылке:\n{reset_url}\n") return render_template("login.html", msg="Если такой email есть, инструкция отправлена.") return render_template("forgot_password.html") @app.route("/reset_password/", methods=["GET", "POST"]) def reset_password(token): - try: - email = serializer.loads(token, salt='password-reset', max_age=3600) - except Exception: - return "Ссылка недействительна или устарела.", 400 + try: email = serializer.loads(token, salt='password-reset', max_age=3600) + except Exception: return "Ссылка недействительна или устарела.", 400 if request.method == "POST": - new_password = request.form.get("password") with Session(engine) as db_session: user = db_session.exec(select(User).where(User.email == email)).first() if user: - user.password_hash = generate_password_hash(new_password) + user.password_hash = generate_password_hash(request.form.get("password")) db_session.add(user) db_session.commit() return redirect(url_for("login", msg="Пароль успешно изменен!")) return render_template("reset_password.html", token=token) -# --- УПРАВЛЕНИЕ СЕССИЯМИ --- @app.route("/sessions") @login_required def sessions_list(): with Session(engine) as db_session: - sessions = db_session.exec( - select(UserSession) - .where(UserSession.user_id == session.get("user_id")) - .order_by(UserSession.created_at.desc()) - ).all() + sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id")).order_by(UserSession.created_at.desc())).all() return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id")) @app.route("/sessions/logout_all", methods=["POST"]) @login_required def logout_all(): with Session(engine) as db_session: - user_sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all() - for s in user_sessions: + for s in db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all(): db_session.delete(s) db_session.commit() session.clear() return redirect(url_for("login", msg="Вы вышли со всех устройств.")) -# --- ПРОФИЛЬ --- @app.route("/profile") @login_required def profile(): @@ -316,14 +293,58 @@ def profile(): user = db_session.get(User, session.get("user_id")) return render_template("profile.html", user=user) -# --- РАСПИСАНИЕ --- +# --- РАСПИСАНИЕ (ПУБЛИЧНОЕ И АДМИНКА) --- @app.route("/schedule") def public_schedule(): with Session(engine) as db_session: current_status = db_session.exec(select(GlobalStatus)).first() now = datetime.now(timezone.utc) - events = db_session.exec(select(Event).where(Event.is_public == True).where(Event.start_time >= now).order_by(Event.start_time).limit(10)).all() - return render_template("schedule.html", status=current_status, events=events) + events = db_session.exec( + select(Event) + .where(Event.is_public == True) + .where(Event.start_time >= now - timedelta(days=180)) + .where(Event.start_time <= now + timedelta(days=365)) + .order_by(Event.start_time) + ).all() + + events_json = [ + { + "id": e.id, + "title": e.title, + "type": e.event_type, + "start": to_msk(e.start_time).isoformat(), + "end": to_msk(e.end_time).isoformat(), + "location": e.location, + "description": e.description, + } + for e in events + ] + return render_template("schedule.html", status=current_status, events_json=events_json) + +@app.route("/schedule/export.ics") +def export_ics(): + with Session(engine) as db_session: + events = db_session.exec(select(Event).where(Event.is_public == True)).all() + + cal = Calendar() + cal.add('prodid', '-//Morozovka Portal//mxm.dk//') + cal.add('version', '2.0') + + for ev in events: + ievent = ICalEvent() + ievent.add('summary', ev.title) + ievent.add('dtstart', ev.start_time.astimezone(pytz.utc)) + ievent.add('dtend', ev.end_time.astimezone(pytz.utc)) + if ev.location: + ievent.add('location', ev.location) + if ev.description: + ievent.add('description', ev.description) + cal.add_component(ievent) + + response = make_response(cal.to_ical()) + response.headers["Content-Disposition"] = "attachment; filename=morozovka_schedule.ics" + response.headers["Content-Type"] = "text/calendar; charset=utf-8" + return response @app.route("/api/assistant", methods=["POST"]) def api_assistant(): @@ -336,8 +357,7 @@ def api_assistant(): def admin_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.start_time >= now).order_by(Event.start_time)).all() + 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"]) @@ -362,13 +382,35 @@ def update_status(): def add_event(): title = request.form.get("title") event_type = request.form.get("event_type") - start_time = datetime.strptime(request.form.get("start_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc) - end_time = datetime.strptime(request.form.get("end_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc) + 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")) db_session.commit() return redirect(url_for("admin_schedule")) +@app.route("/admin/schedule/event//edit", methods=["GET", "POST"]) +@require_role(["admin", "professor"]) +def edit_event(event_id): + with Session(engine) as db_session: + event = db_session.get(Event, event_id) + if not event: return "Событие не найдено", 404 + + if request.method == "POST": + event.title = request.form.get("title") + event.event_type = request.form.get("event_type") + event.start_time = parse_datetime(request.form.get("start_time")) + event.end_time = parse_datetime(request.form.get("end_time")) + event.location = request.form.get("location") + event.description = request.form.get("description") + event.is_public = request.form.get("is_public") == "on" + event.manually_edited = True + db_session.add(event) + db_session.commit() + 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): @@ -379,77 +421,198 @@ def delete_event(event_id): db_session.commit() 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 + file = request.files['file'] + if file.filename == '': + return "Файл не выбран", 400 + + if file and file.filename.endswith('.ics'): + try: + cal = Calendar.from_ical(file.read()) + count = 0 + with Session(engine) as db_session: + for component in cal.walk(): + if component.name == "VEVENT": + summary = str(component.get('summary', 'Без названия')) + dtstart_prop = component.get('dtstart') + dtend_prop = component.get('dtend') + + if not dtstart_prop or not dtend_prop: + continue + + dtstart = dtstart_prop.dt + dtend = dtend_prop.dt + + if not isinstance(dtstart, datetime): + dtstart = datetime.combine(dtstart, datetime.min.time()) + if not isinstance(dtend, datetime): + dtend = datetime.combine(dtend, datetime.min.time()) + + if dtstart.tzinfo is None: + dtstart = dtstart.replace(tzinfo=timezone.utc) + if dtend.tzinfo is None: + dtend = dtend.replace(tzinfo=timezone.utc) + + loc = str(component.get('location', '')) + desc = str(component.get('description', '')) + + new_event = Event( + title=summary, + event_type='lecture', + start_time=dtstart.astimezone(timezone.utc), + end_time=dtend.astimezone(timezone.utc), + location=loc, + description=desc, + is_public=True + ) + db_session.add(new_event) + count += 1 + db_session.commit() + 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") with Session(engine) as db_session: projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() - return render_template("projects.html", projects=projects, role=session.get("role")) + member_project_ids = [] + 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: + if p.visibility == "public": + filtered_projects.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) @app.route("/projects/new", methods=["GET", "POST"]) @require_role(["admin", "professor", "lead"]) def project_new(): if request.method == "POST": with Session(engine) as db_session: - db_session.add(Project(title=request.form.get("title"), description=request.form.get("description"), status=request.form.get("status"), gitea_url=request.form.get("gitea_url"), is_public=request.form.get("is_public") == "on", owner_id=session.get("user_id"))) + 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") + ) + db_session.add(new_proj) + 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 render_template("project_form.html") + return render_template("project_form.html", project=None, is_edit=False) + +@app.route("/projects/") +def project_detail(project_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + + 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": + 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) + +@app.route("/projects//edit", methods=["GET", "POST"]) +@login_required +def project_edit(project_id): + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + + membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first() + 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 + + 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") + 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") + 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 + + user_to_add = db_session.exec(select(User).where(User.username == username)).first() + if not user_to_add: return redirect(url_for("project_detail", project_id=project_id)) + + existing = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_to_add.id)).first() + if not existing: + db_session.add(ProjectMember(project_id=project_id, user_id=user_to_add.id, role="member")) + 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 + + if user_id == proj.owner_id: return "Нельзя удалить владельца", 400 + + to_remove = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)).first() + if to_remove: + db_session.delete(to_remove) + db_session.commit() + return redirect(url_for("project_detail", project_id=project_id)) @app.route("/projects//delete", methods=["POST"]) -@require_role(["admin", "professor", "lead"]) +@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(): + db_session.delete(m) db_session.delete(proj) db_session.commit() return redirect(url_for("projects_list")) -# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ --- -@app.route("/admin/users") -@require_role("admin") -def admin_users(): - with Session(engine) as db_session: - users = db_session.exec(select(User).order_by(User.username)).all() - return render_template("admin_users.html", users=users) - -@app.route("/admin/users//role", methods=["POST"]) -@require_role("admin") -def change_user_role(user_id): - new_role = request.form.get("role") - valid_roles = ["admin", "professor", "lead", "member", "student", "guest"] - if new_role not in valid_roles: return "Ошибка", 400 - with Session(engine) as db_session: - user = db_session.get(User, user_id) - if user: - if user.id == session.get("user_id") and new_role != "admin": return "Нельзя снять с себя админа", 400 - user.role = new_role - db_session.add(user) - db_session.commit() - return redirect(url_for("admin_users")) - -# --- НОВЫЙ РОУТ: УДАЛЕНИЕ ПОЛЬЗОВАТЕЛЯ --- -@app.route("/admin/users//delete", methods=["POST"]) -@require_role("admin") -def delete_user(user_id): - if user_id == session.get("user_id"): - return "Ошибка: нельзя удалить самого себя", 400 - - with Session(engine) as db_session: - # Сначала удаляем все сессии пользователя (чтобы не было ошибки внешнего ключа) - sessions = db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all() - for s in sessions: - db_session.delete(s) - - # Затем удаляем самого пользователя - user = db_session.get(User, user_id) - if user: - db_session.delete(user) - db_session.commit() - - return redirect(url_for("admin_users")) - # --- ФАЙЛЫ --- @app.route("/files") @login_required @@ -495,3 +658,49 @@ def file_delete(file_id): db_session.delete(db_file) db_session.commit() return redirect(url_for("files_list")) + +# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ --- +@app.route("/admin/users") +@require_role("admin") +def admin_users(): + with Session(engine) as db_session: + users = db_session.exec(select(User).order_by(User.username)).all() + return render_template("admin_users.html", users=users) + +@app.route("/admin/users//role", methods=["POST"]) +@require_role("admin") +def change_user_role(user_id): + new_role = request.form.get("role") + valid_roles = ["admin", "professor", "lead", "member", "student", "guest"] + if new_role not in valid_roles: return "Ошибка", 400 + with Session(engine) as db_session: + user = db_session.get(User, user_id) + if user: + if user.id == session.get("user_id") and new_role != "admin": return "Нельзя снять с себя админа", 400 + user.role = new_role + db_session.add(user) + db_session.commit() + return redirect(url_for("admin_users")) + +@app.route("/admin/users//delete", methods=["POST"]) +@require_role("admin") +def delete_user(user_id): + if user_id == session.get("user_id"): return "Нельзя удалить себя", 400 + with Session(engine) as db_session: + user = db_session.get(User, user_id) + if not user: + return redirect(url_for("admin_users")) + + owned_projects = db_session.exec(select(Project).where(Project.owner_id == user_id)).all() + uploaded_files = db_session.exec(select(PortalFile).where(PortalFile.uploaded_by == user_id)).all() + if owned_projects or uploaded_files: + return (f"Нельзя удалить {user.username}: у него {len(owned_projects)} проект(ов) " + f"и {len(uploaded_files)} файл(ов). Сначала передайте их другому пользователю."), 400 + + 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) + db_session.delete(user) + db_session.commit() + return redirect(url_for("admin_users")) diff --git a/app/main.py.bak b/app/main.py.bak new file mode 100644 index 0000000..2bd1c67 --- /dev/null +++ b/app/main.py.bak @@ -0,0 +1,679 @@ +import os +import uuid +import smtplib +import pytz +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 icalendar import Calendar, Event as ICalEvent +from .db import init_db, engine +from . import config, auth, assistant +from sqlmodel import Session, select +from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession, ProjectMember +from datetime import datetime, timezone +from functools import wraps + +app = Flask(__name__) +app.secret_key = config.WEB_SECRET +serializer = URLSafeTimedSerializer(app.secret_key) + +MSK = pytz.timezone("Europe/Moscow") + +def to_msk(dt): + if dt is None: return None + if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(MSK) + +@app.template_filter("msk_strftime") +def _msk_strftime(dt, fmt): + if dt is None: return "" + return to_msk(dt).strftime(fmt) + +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 + + try: + msg = MIMEMultipart() + msg['From'] = config.SMTP_FROM + msg['To'] = to_email + msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain')) + + server = smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT) + server.starttls() + server.login(config.SMTP_USER, config.SMTP_PASSWORD) + server.send_message(msg) + server.quit() + return True + except Exception as e: + print(f"Ошибка отправки email: {e}", flush=True) + return False + +def require_role(allowed_roles): + if isinstance(allowed_roles, str): + allowed_roles = [allowed_roles] + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if session.get("role") not in allowed_roles: + return "403: Доступ запрещен.", 403 + return f(*args, **kwargs) + return decorated_function + return decorator + +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get("user_id"): + return redirect(url_for("login")) + return f(*args, **kwargs) + return decorated_function + +def parse_datetime(dt_str): + """Ввод из формы — в 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) + +@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"}) + 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("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + username = request.form.get("username") + email = request.form.get("email") + password = request.form.get("password") + full_name = request.form.get("full_name") + organization = request.form.get("organization") + + with Session(engine) as db_session: + if db_session.exec(select(User).where(User.username == username)).first(): + return render_template("register.html", error="Логин уже занят") + if db_session.exec(select(User).where(User.email == email)).first(): + return render_template("register.html", error="Email уже используется") + + new_user = User(username=username, email=email, password_hash=generate_password_hash(password), full_name=full_name, organization=organization, role="student", is_verified=False) + db_session.add(new_user) + db_session.commit() + + token = serializer.dumps(email, salt='email-confirm') + confirm_url = url_for('verify_email', token=token, _external=True).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("register.html") + +@app.route("/verify_email/") +def verify_email(token): + try: email = serializer.loads(token, salt='email-confirm', max_age=86400) + except Exception: return "Ссылка недействительна или устарела.", 400 + + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.email == email)).first() + if user: + user.is_verified = True + db_session.add(user) + db_session.commit() + return redirect(url_for("login", msg="Почта подтверждена! Теперь можно войти.")) + return "Пользователь не найден.", 404 + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.username == username)).first() + if not user or not user.password_hash or not check_password_hash(user.password_hash, password): + return render_template("login.html", error="Неверный логин или пароль") + if not user.is_verified: + return render_template("login.html", error="Подтвердите почту перед входом") + + session_token = str(uuid.uuid4()) + db_session.add(UserSession(session_token=session_token, user_id=user.id, ip_address=request.remote_addr, user_agent=request.user_agent.string)) + db_session.commit() + + session["user_id"] = user.id + session["username"] = user.username + session["role"] = user.role + session["session_id"] = session_token + return redirect(url_for("index")) + + msg = request.args.get("msg") + return render_template("login.html", msg=msg) + +@app.route("/login/gitea") +def login_gitea(): + gitea_auth_url = ( + f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize" + f"?client_id={config.GITEA_CLIENT_ID}&response_type=code" + f"&redirect_uri={config.GITEA_REDIRECT_URI}" + f"&scope=read:user" + ) + return redirect(gitea_auth_url) + +@app.route("/auth/callback") +def auth_callback(): + code = request.args.get("code") + if not code: return "Ошибка авторизации Gitea", 400 + token = auth.get_gitea_token(code) + if not token: return "Ошибка токена Gitea", 400 + gitea_user = auth.get_gitea_user(token) + if not gitea_user: return "Ошибка профиля Gitea", 400 + + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first() + if not user: + user = db_session.exec(select(User).where(User.email == gitea_user.get("email"))).first() + if user: + user.gitea_id = gitea_user["id"] + else: + existing_admin = db_session.exec(select(User).where(User.role == "admin")).first() + bootstrap_role = "admin" if not existing_admin else "student" + user = User(gitea_id=gitea_user["id"], username=gitea_user["login"], full_name=gitea_user.get("full_name"), email=gitea_user.get("email"), role=bootstrap_role, is_verified=True) + db_session.add(user) + db_session.commit() + + session_token = str(uuid.uuid4()) + db_session.add(UserSession(session_token=session_token, user_id=user.id, ip_address=request.remote_addr, user_agent=request.user_agent.string)) + db_session.commit() + + session["user_id"] = user.id + session["username"] = user.username + session["role"] = user.role + session["session_id"] = session_token + return redirect(url_for("index")) + +@app.route("/logout") +def logout(): + if "session_id" in session: + with Session(engine) as db_session: + db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first() + if db_sess: + db_session.delete(db_sess) + db_session.commit() + session.clear() + return redirect(url_for("index")) + +@app.route("/forgot_password", methods=["GET", "POST"]) +def forgot_password(): + if request.method == "POST": + email = request.form.get("email") + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.email == email)).first() + if user: + token = serializer.dumps(email, salt='password-reset') + reset_url = url_for('reset_password', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://") + send_email(email, "Сброс пароля", f"Для сброса пароля перейди по ссылке:\n{reset_url}\n") + return render_template("login.html", msg="Если такой email есть, инструкция отправлена.") + return render_template("forgot_password.html") + +@app.route("/reset_password/", methods=["GET", "POST"]) +def reset_password(token): + try: email = serializer.loads(token, salt='password-reset', max_age=3600) + except Exception: return "Ссылка недействительна или устарела.", 400 + + if request.method == "POST": + with Session(engine) as db_session: + user = db_session.exec(select(User).where(User.email == email)).first() + if user: + user.password_hash = generate_password_hash(request.form.get("password")) + db_session.add(user) + db_session.commit() + return redirect(url_for("login", msg="Пароль успешно изменен!")) + return render_template("reset_password.html", token=token) + +@app.route("/sessions") +@login_required +def sessions_list(): + with Session(engine) as db_session: + sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id")).order_by(UserSession.created_at.desc())).all() + return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id")) + +@app.route("/sessions/logout_all", methods=["POST"]) +@login_required +def logout_all(): + with Session(engine) as db_session: + for s in db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all(): + db_session.delete(s) + db_session.commit() + session.clear() + return redirect(url_for("login", msg="Вы вышли со всех устройств.")) + +@app.route("/profile") +@login_required +def profile(): + with Session(engine) as db_session: + user = db_session.get(User, session.get("user_id")) + return render_template("profile.html", user=user) + +# --- РАСПИСАНИЕ (ПУБЛИЧНОЕ И АДМИНКА) --- +@app.route("/schedule") +def public_schedule(): + with Session(engine) as db_session: + current_status = db_session.exec(select(GlobalStatus)).first() + now = datetime.now(timezone.utc) + events = db_session.exec(select(Event).where(Event.is_public == True).where(Event.start_time >= now).order_by(Event.start_time).limit(10)).all() + return render_template("schedule.html", status=current_status, events=events) + +@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) + # Приводим к UTC для совместимости + ievent.add('dtstart', ev.start_time.astimezone(pytz.utc)) + ievent.add('dtend', ev.end_time.astimezone(pytz.utc)) + if ev.location: + ievent.add('location', ev.location) + if ev.description: + ievent.add('description', ev.description) + cal.add_component(ievent) + + response = make_response(cal.to_ical()) + response.headers["Content-Disposition"] = "attachment; filename=morozovka_schedule.ics" + response.headers["Content-Type"] = "text/calendar; charset=utf-8" + return response + +@app.route("/api/assistant", methods=["POST"]) +def api_assistant(): + data = request.get_json() + if not data or "message" not in data: return jsonify({"error": "Нет сообщения"}), 400 + return jsonify({"reply": assistant.get_response(data["message"])}) + +@app.route("/admin/schedule") +@require_role(["admin", "professor"]) +def admin_schedule(): + with Session(engine) as db_session: + current_status = db_session.exec(select(GlobalStatus)).first() + events = db_session.exec(select(Event).order_by(Event.start_time.desc()).limit(50)).all() + return render_template("admin_schedule.html", status=current_status, events=events) + +@app.route("/admin/schedule/status", methods=["POST"]) +@require_role(["admin", "professor"]) +def update_status(): + status_val = request.form.get("status") + delay = int(request.form.get("delay_minutes") or 0) + message = request.form.get("message") + with Session(engine) as db_session: + current_status = db_session.exec(select(GlobalStatus)).first() + if current_status: + 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() + return redirect(url_for("admin_schedule")) + +@app.route("/admin/schedule/event/add", methods=["POST"]) +@require_role(["admin", "professor"]) +def add_event(): + title = request.form.get("title") + event_type = request.form.get("event_type") + start_time = parse_datetime(request.form.get("start_time")) + end_time = parse_datetime(request.form.get("end_time")) + with Session(engine) as db_session: + 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")) + db_session.commit() + return redirect(url_for("admin_schedule")) + +@app.route("/admin/schedule/event//edit", methods=["GET", "POST"]) +@require_role(["admin", "professor"]) +def edit_event(event_id): + with Session(engine) as db_session: + event = db_session.get(Event, event_id) + if not event: return "Событие не найдено", 404 + + if request.method == "POST": + event.title = request.form.get("title") + event.event_type = request.form.get("event_type") + event.start_time = parse_datetime(request.form.get("start_time")) + event.end_time = parse_datetime(request.form.get("end_time")) + event.location = request.form.get("location") + event.description = request.form.get("description") + event.is_public = request.form.get("is_public") == "on" + event.manually_edited = True + db_session.add(event) + db_session.commit() + 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: + db_session.delete(event) + db_session.commit() + 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 + file = request.files['file'] + if file.filename == '': + return "Файл не выбран", 400 + + if file and file.filename.endswith('.ics'): + try: + cal = Calendar.from_ical(file.read()) + count = 0 + with Session(engine) as db_session: + for component in cal.walk(): + if component.name == "VEVENT": + summary = str(component.get('summary', 'Без названия')) + dtstart_prop = component.get('dtstart') + dtend_prop = component.get('dtend') + + if not dtstart_prop or not dtend_prop: + continue + + dtstart = dtstart_prop.dt + dtend = dtend_prop.dt + + # Обработка событий на весь день (datetime.date) + if not isinstance(dtstart, datetime): + dtstart = datetime.combine(dtstart, datetime.min.time()) + if not isinstance(dtend, datetime): + dtend = datetime.combine(dtend, datetime.min.time()) + + # Делаем timezone-aware (если нет зоны, считаем UTC) + 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', '')) + + new_event = Event( + title=summary, + event_type='lecture', # По умолчанию + start_time=dtstart.astimezone(timezone.utc), + end_time=dtend.astimezone(timezone.utc), + location=loc, + description=desc, + is_public=True # Импортированные события делаем публичными + ) + db_session.add(new_event) + count += 1 + db_session.commit() + 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") + with Session(engine) as db_session: + projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all() + member_project_ids = [] + 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: + if p.visibility == "public": + filtered_projects.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) + +@app.route("/projects/new", methods=["GET", "POST"]) +@require_role(["admin", "professor", "lead"]) +def project_new(): + if request.method == "POST": + with Session(engine) as db_session: + new_proj = Project( + title=request.form.get("title"), description=request.form.get("description"), + status=request.form.get("status"), visibility=request.form.get("visibility"), + gitea_url=request.form.get("gitea_url"), owner_id=session.get("user_id") + ) + db_session.add(new_proj) + 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 render_template("project_form.html", project=None, is_edit=False) + +@app.route("/projects/") +def project_detail(project_id): + user_id = session.get("user_id") + user_role = session.get("role") + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + + 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": + 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) + +@app.route("/projects//edit", methods=["GET", "POST"]) +@login_required +def project_edit(project_id): + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if not proj: return "Проект не найден", 404 + + membership = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))).first() + 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 + + 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") + 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") + 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 + + user_to_add = db_session.exec(select(User).where(User.username == username)).first() + if not user_to_add: return redirect(url_for("project_detail", project_id=project_id)) + + existing = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_to_add.id)).first() + if not existing: + db_session.add(ProjectMember(project_id=project_id, user_id=user_to_add.id, role="member")) + 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 + + if user_id == proj.owner_id: return "Нельзя удалить владельца", 400 + + to_remove = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)).first() + if to_remove: + db_session.delete(to_remove) + db_session.commit() + return redirect(url_for("project_detail", project_id=project_id)) + +@app.route("/projects//delete", methods=["POST"]) +@login_required +def project_delete(project_id): + with Session(engine) as db_session: + proj = db_session.get(Project, project_id) + if 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(): + db_session.delete(m) + db_session.delete(proj) + db_session.commit() + return redirect(url_for("projects_list")) + +# --- ФАЙЛЫ --- +@app.route("/files") +@login_required +def files_list(): + with Session(engine) as db_session: + files = db_session.exec(select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id).order_by(PortalFile.uploaded_at.desc())).all() + return render_template("files.html", files=files) + +@app.route("/files/upload", methods=["POST"]) +@login_required +def file_upload(): + if 'file' not in request.files: return redirect(url_for('files_list')) + file = request.files['file'] + if file.filename == '': return redirect(url_for('files_list')) + if file: + original_name = file.filename + ext = os.path.splitext(original_name)[1] + stored_name = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) + file.save(filepath) + with Session(engine) as db_session: + db_session.add(PortalFile(original_name=original_name, stored_name=stored_name, size_bytes=os.path.getsize(filepath), uploaded_by=session.get("user_id"))) + db_session.commit() + return redirect(url_for('files_list')) + +@app.route("/files/download/") +@login_required +def file_download(file_id): + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if db_file: + return send_from_directory(app.config['UPLOAD_FOLDER'], db_file.stored_name, as_attachment=True, download_name=db_file.original_name) + return "Файл не найден", 404 + +@app.route("/files//delete", methods=["POST"]) +@login_required +def file_delete(file_id): + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if db_file and (session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id")): + filepath = os.path.join(app.config['UPLOAD_FOLDER'], db_file.stored_name) + if os.path.exists(filepath): os.remove(filepath) + db_session.delete(db_file) + db_session.commit() + return redirect(url_for("files_list")) + +# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ --- +@app.route("/admin/users") +@require_role("admin") +def admin_users(): + with Session(engine) as db_session: + users = db_session.exec(select(User).order_by(User.username)).all() + return render_template("admin_users.html", users=users) + +@app.route("/admin/users//role", methods=["POST"]) +@require_role("admin") +def change_user_role(user_id): + new_role = request.form.get("role") + valid_roles = ["admin", "professor", "lead", "member", "student", "guest"] + if new_role not in valid_roles: return "Ошибка", 400 + with Session(engine) as db_session: + user = db_session.get(User, user_id) + if user: + if user.id == session.get("user_id") and new_role != "admin": return "Нельзя снять с себя админа", 400 + user.role = new_role + db_session.add(user) + db_session.commit() + return redirect(url_for("admin_users")) + +@app.route("/admin/users//delete", methods=["POST"]) +@require_role("admin") +def delete_user(user_id): + if user_id == session.get("user_id"): return "Нельзя удалить себя", 400 + with Session(engine) as db_session: + for s in db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all(): + db_session.delete(s) + user = db_session.get(User, user_id) + if user: + db_session.delete(user) + db_session.commit() + return redirect(url_for("admin_users")) diff --git a/app/models.py b/app/models.py index 9fd5b99..b02e77d 100644 --- a/app/models.py +++ b/app/models.py @@ -4,12 +4,12 @@ from datetime import datetime, timezone class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) - gitea_id: Optional[int] = Field(default=None, unique=True, index=True) # Теперь может быть None, если регаемся локально + gitea_id: Optional[int] = Field(default=None, unique=True, index=True) username: str = Field(unique=True, index=True) password_hash: Optional[str] = None full_name: Optional[str] = None email: Optional[str] = Field(default=None, unique=True) - organization: Optional[str] = None # ННГУ, Неймарк, Ядро и т.д. + organization: Optional[str] = None role: str = Field(default="student") is_active: bool = Field(default=True) is_verified: bool = Field(default=False) @@ -33,6 +33,8 @@ class Event(SQLModel, table=True): description: Optional[str] = None status: str = Field(default="scheduled") is_public: bool = Field(default=True) + google_event_id: Optional[str] = Field(default=None, unique=True, index=True) + manually_edited: bool = Field(default=False) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class GlobalStatus(SQLModel, table=True): @@ -46,12 +48,20 @@ 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") + status: str = Field(default="idea") # idea, in_progress, completed, frozen + visibility: str = Field(default="public") # public, internal, private gitea_url: Optional[str] = None owner_id: int = Field(foreign_key="user.id") - is_public: bool = Field(default=True) 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 + joined_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 diff --git a/app/templates/admin_event_edit.html b/app/templates/admin_event_edit.html new file mode 100644 index 0000000..1171b67 --- /dev/null +++ b/app/templates/admin_event_edit.html @@ -0,0 +1,100 @@ + + + + + Редактирование события — Morozovka Portal + + + + + + + + + +
+ ← Назад к расписанию +

Редактирование события

+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + + + diff --git a/app/templates/admin_event_edit.html.bak b/app/templates/admin_event_edit.html.bak new file mode 100644 index 0000000..8a04d0c --- /dev/null +++ b/app/templates/admin_event_edit.html.bak @@ -0,0 +1,100 @@ + + + + + Редактирование события — Morozovka Portal + + + + + + + + + +
+ ← Назад к расписанию +

Редактирование события

+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + + + diff --git a/app/templates/admin_schedule.html b/app/templates/admin_schedule.html index 2cab7d3..d9e6916 100644 --- a/app/templates/admin_schedule.html +++ b/app/templates/admin_schedule.html @@ -3,45 +3,81 @@ Управление расписанием — Morozovka Portal + + + + + +
← На главную -

Управление расписанием и статусом

+ +
+

Управление расписанием

+ +
-
-

Текущий статус (выводится на главной странице)

-
+

Текущий статус

+
+ + + + + +
+ +
- - @@ -51,18 +87,17 @@
- +
- +
- +
-

Добавить новое событие

@@ -88,11 +123,11 @@
- +
- +
@@ -109,9 +144,8 @@
-
-

Запланированные события

+

Последние 50 событий

@@ -125,23 +159,41 @@ {% for event in events %} - + - {% else %} - + {% endfor %}
{{ event.start_time.strftime('%d.%m %H:%M') }} - {{ event.end_time.strftime('%H:%M') }}{{ event.start_time|msk_strftime('%d.%m.%Y %H:%M') }}
до {{ event.end_time|msk_strftime('%H:%M') }}
{{ event.title }}
{{ event.location or '' }}
{{ event.event_type }} {{ 'Да' if event.is_public else 'Нет' }} + + ✎ Ред.
- +
Нет запланированных событий
Нет событий
-
+ + diff --git a/app/templates/admin_schedule.html.bak b/app/templates/admin_schedule.html.bak new file mode 100644 index 0000000..b789b2d --- /dev/null +++ b/app/templates/admin_schedule.html.bak @@ -0,0 +1,199 @@ + + + + + Управление расписанием — Morozovka Portal + + + + + + + + + +
+ ← На главную + +
+

Управление расписанием

+ +
+ +
+

Текущий статус

+
+ + + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+

Добавить новое событие

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+
+
+ +
+

Последние 50 событий

+ + + + + + + + + + + + {% for event in events %} + + + + + + + + {% else %} + + {% endfor %} + +
Дата и времяНазваниеТипПубличноеДействие
{{ event.start_time.strftime('%d.%m.%Y %H:%M') }}
до {{ event.end_time.strftime('%H:%M') }}
{{ event.title }}
{{ event.location or '' }}
{{ event.event_type }}{{ 'Да' if event.is_public else 'Нет' }} + ✎ Ред. +
+ +
+
Нет событий
+
+
+ + + + diff --git a/app/templates/admin_schedule_import.html b/app/templates/admin_schedule_import.html new file mode 100644 index 0000000..f0f8f8b --- /dev/null +++ b/app/templates/admin_schedule_import.html @@ -0,0 +1,54 @@ + + + + + Импорт расписания — Morozovka Portal + + + +
+ ← Назад к расписанию +

Импорт расписания

+ +
+
+
+ + +

Файл не выбран

+
+ +
+ +
+ Как выгрузить расписание из Google Calendar?
+ 1. Зайдите в Google Calendar на компьютере.
+ 2. Нажмите на три точки рядом с нужным календарем.
+ 3. Выберите «Настройки и общий доступ».
+ 4. Прокрутите вниз до раздела «Экспорт календаря» и скачайте ZIP-архив.
+ 5. Распакуйте архив и загрузите сюда файл с расширением .ics. +
+
+
+ + diff --git a/app/templates/files.html b/app/templates/files.html index 9bc6266..26f1b1c 100644 --- a/app/templates/files.html +++ b/app/templates/files.html @@ -60,7 +60,7 @@ {{ db_file.original_name }} {{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ {{ uploader_name }} - {{ db_file.uploaded_at.strftime('%d.%m.%Y %H:%M') }} + {{ db_file.uploaded_at|msk_strftime('%d.%m.%Y %H:%M') }} Скачать diff --git a/app/templates/files.html.bak b/app/templates/files.html.bak new file mode 100644 index 0000000..9bc6266 --- /dev/null +++ b/app/templates/files.html.bak @@ -0,0 +1,83 @@ + + + + + Файлы — Morozovka Portal + + + +
+ ← На главную +

Файлохранилище

+ +
+
+ + + Файл не выбран +

+ +
+
+ + + + + + + + + + + + + {% for db_file, uploader_name in files %} + + + + + + + + {% else %} + + + + {% endfor %} + +
Имя файлаРазмерЗагрузилДатаДействия
{{ db_file.original_name }}{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ{{ uploader_name }}{{ db_file.uploaded_at.strftime('%d.%m.%Y %H:%M') }} + Скачать + + {% if session.get('role') == 'admin' or db_file.uploaded_by == session.get('user_id') %} +
+ +
+ {% endif %} +
Файлов пока нет. Загрузите первый!
+
+ + diff --git a/app/templates/project_detail.html b/app/templates/project_detail.html new file mode 100644 index 0000000..1d63fdc --- /dev/null +++ b/app/templates/project_detail.html @@ -0,0 +1,98 @@ + + + + + {{ 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.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 role == 'admin' or project.owner_id == user_id or (is_member and role == 'lead') %} +

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

+
+ + +
+ {% endif %} +
+ + diff --git a/app/templates/project_form.html b/app/templates/project_form.html index 7c906af..561526f 100644 --- a/app/templates/project_form.html +++ b/app/templates/project_form.html @@ -2,7 +2,7 @@ - Новый проект — Morozovka Portal + {{ 'Редактирование' if is_edit else 'Новый проект' }} — Morozovka Portal
- ← К проектам -

Создание нового проекта

+ ← Назад +

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

- +
- +
+
+
+ +
- +
-
- - -
- +
diff --git a/app/templates/projects.html b/app/templates/projects.html index cf52050..b9af987 100644 --- a/app/templates/projects.html +++ b/app/templates/projects.html @@ -12,18 +12,25 @@ .btn-add:hover { background: #2ea043; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; } - .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; } + .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; text-decoration: none; color: inherit; transition: border-color 0.2s; } + .card:hover { border-color: #58a6ff; } .card h3 { margin: 0 0 10px 0; color: #e6edf3; } .card p { color: #8b949e; font-size: 0.9rem; flex-grow: 1; margin-bottom: 15px; } - .status-badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; text-transform: uppercase; margin-bottom: 10px; } + + .badges { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; } + .badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; text-transform: uppercase; } .status-idea { background: #30363d; color: white; } .status-in_progress { background: #1f6feb; color: white; } .status-completed { background: #238636; color: white; } .status-frozen { background: #da3633; color: white; } - .card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; border-top: 1px solid #30363d; padding-top: 15px; } - .repo-link { color: #58a6ff; text-decoration: none; font-size: 0.85rem; } - .delete-btn { background: none; border: none; color: #da3633; cursor: pointer; font-size: 0.85rem; } + .vis-public { background: rgba(63,185,80,0.2); color: #3fb950; border: 1px solid #3fb950; } + .vis-internal { background: rgba(210,153,34,0.2); color: #d29922; border: 1px solid #d29922; } + .vis-private { background: rgba(248,81,73,0.2); color: #f85149; border: 1px solid #f85149; } + + .card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; border-top: 1px solid #30363d; padding-top: 15px; font-size: 0.85rem; } + .repo-link { color: #58a6ff; text-decoration: none; } + .delete-btn { background: none; border: none; color: #da3633; cursor: pointer; font-size: 0.85rem; padding: 0; } .delete-btn:hover { text-decoration: underline; } .back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; } @@ -40,33 +47,31 @@
{% for proj in projects %} -
- + {% else %} -

Проектов пока нет.

+

Доступных проектов пока нет.

{% endfor %}
diff --git a/app/templates/schedule.html b/app/templates/schedule.html index 879bda8..908412d 100644 --- a/app/templates/schedule.html +++ b/app/templates/schedule.html @@ -1,143 +1,469 @@ - - - Расписание — Morozovka Portal - +.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; } +.btn-icon { background: #21262d; border: 1px solid #30363d; color: #e6edf3; width: 36px; height: 36px; border-radius: 6px; cursor: pointer; font-size: 1.1rem; display: inline-flex; align-items: center; justify-content: center; } +.btn-icon:hover { background: #30363d; } +.btn-secondary { background: #21262d; border: 1px solid #30363d; color: #e6edf3; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: .9rem; } +.btn-secondary:hover { background: #30363d; } +.cal-title { font-size: 1.2rem; margin: 0; color: #e6edf3; font-weight: 600; flex: 1; text-align: center; min-width: 150px; } +.view-switcher { display: flex; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; } +.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); } +.month-cell { border-right: 1px solid #30363d; border-bottom: 1px solid #30363d; padding: 6px; overflow: hidden; position: relative; min-height: 110px; } +.month-cell:nth-child(7n) { border-right: none; } +.month-cell.other-month { background: rgba(0,0,0,.15); } +.month-cell.other-month .month-day-num { color: #4d5560; } +.month-cell.today { background: rgba(88,166,255,.05); } +.month-cell.today .month-day-num { background: #58a6ff; color: #fff; border-radius: 50%; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; } +.month-day-num { font-size: .85rem; color: #8b949e; font-weight: 600; margin-bottom: 6px; } +.month-event { display: flex; gap: 4px; font-size: .7rem; padding: 2px 5px; border-radius: 3px; margin-bottom: 3px; cursor: pointer; overflow: hidden; white-space: nowrap; border-left: 3px solid; } +.month-event:hover { opacity: .85; } +.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; } +.week-day-header.today .day-num { background: #58a6ff; color: #fff; border-radius: 50%; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; margin: 0 auto; } +.day-name { font-size: .7rem; color: #8b949e; text-transform: uppercase; font-weight: 600; margin-bottom: 4px; } +.day-num { font-size: 1.1rem; font-weight: 600; color: #e6edf3; } +.week-body { display: grid; grid-template-columns: 50px repeat(7,1fr); overflow-y: auto; max-height: 560px; position: relative; } +.day-body { display: grid; grid-template-columns: 50px 1fr; overflow-y: auto; max-height: 600px; position: relative; } +.time-gutter-col { background: #0d1117; border-right: 1px solid #30363d; } +.hour-label { height: 40px; padding: 2px 6px; font-size: .7rem; color: #6e7681; text-align: right; } +.day-col { position: relative; border-left: 1px solid #30363d; } +.day-col-single { border-left: none; } +.hour-cell { height: 40px; border-bottom: 1px solid #21262d; } +.week-event { position: absolute; background: rgba(88,166,255,.2); border-left: 3px solid #58a6ff; border-radius: 4px; padding: 3px 6px; font-size: .7rem; color: #e6edf3; overflow: hidden; cursor: pointer; z-index: 2; min-height: 18px; transition: filter .15s; } +.week-event:hover { filter: brightness(1.3); z-index: 10; } +.we-time { font-size: .65rem; opacity: .85; font-weight: 600; } +.we-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; } +.we-loc { font-size: .65rem; opacity: .7; margin-top: 1px; } +.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; } +.event-type-personal { background: rgba(139,148,158,.18); border-left-color: #8b949e; } +.month-event.event-type-lecture { background: rgba(88,166,255,.15); color: #58a6ff; } +.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; } +.popup-close { position: absolute; top: 12px; right: 15px; cursor: pointer; color: #8b949e; font-size: 1.1rem; } +.popup-close:hover { color: #f85149; } +#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; } + +.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; } +.chat-window.hidden { display: none; } +.chat-header { padding: 14px 18px; background: #0d1117; border-bottom: 1px solid #30363d; font-weight: 600; display: flex; justify-content: space-between; align-items: center; } +.chat-close { background: none; border: none; color: #8b949e; cursor: pointer; font-size: 1rem; } +.chat-close:hover { color: #f85149; } +.chat-messages { flex: 1; padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; } +.msg { max-width: 85%; padding: 8px 12px; border-radius: 10px; font-size: .9rem; line-height: 1.4; white-space: pre-wrap; word-wrap: break-word; } +.msg.bot { background: #21262d; border: 1px solid #30363d; align-self: flex-start; } +.msg.user { background: #1f6feb; color: #fff; align-self: flex-end; } +.chat-form { display: flex; padding: 10px; border-top: 1px solid #30363d; background: #0d1117; gap: 8px; } +.chat-form input { flex: 1; padding: 10px 12px; background: #21262d; border: 1px solid #30363d; color: #fff; border-radius: 6px; outline: none; font-size: .9rem; } +.chat-form input:focus { border-color: #58a6ff; } +.chat-form button { padding: 10px 16px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; } +.chat-form button:hover { background: #2ea043; } + +@media (max-width: 768px) { + body { padding: 10px; } + .cal-title { font-size: 1rem; } + .view-switcher button { padding: 6px 10px; font-size: .8rem; } + .chat-window { width: calc(100vw - 20px); right: 10px; bottom: 90px; height: 60vh; } + .month-cell { min-height: 70px; } + .month-event .event-title { display: none; } + .week-body { max-height: 400px; } +} + -
- ← На главную -

Расписание

+
+ ← На главную +

Расписание

- {% if status %} -
-
- {% if status.status == 'on_place' %}На месте - {% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин - {% elif status.status == 'away' %}Вне вуза - {% elif status.status == 'cancelled' %}Отменено - {% else %}Свободен / Вне расписания{% endif %} -
- {% if status.message %} -
{{ status.message }}
- {% endif %} + {% if status %} +
+
+ {% if status.status == 'on_place' %}На месте + {% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин + {% elif status.status == 'away' %}Вне вуза + {% elif status.status == 'cancelled' %}Отменено + {% else %}Свободен / Вне расписания{% endif %}
- {% endif %} - -

Ближайшие события

- - {% for event in events %} -
-
- {{ event.start_time.strftime('%d.%m %H:%M') }}
- до {{ event.end_time.strftime('%H:%M') }} -
-
-

{{ event.title }}

-
- {% if event.location %}📍 {{ event.location }}{% endif %} -
- {{ event.event_type }} - {% if event.description %} -
{{ event.description }}
- {% endif %} -
-
- {% else %} -

Пока нет запланированных событий.

- {% endfor %} - - -
-
🤖 Ассистент расписания
-
-
Привет! Я могу подсказать статус Никиты Сергеевича, его расписание или контакты. Спроси меня о чем-нибудь.
-
-
- - -
-
- + {% if status.message %}
{{ status.message }}
{% endif %}
+ {% endif %} - + if (col === -1) { col = cols.length; cols.push([]); } + cols[col].push(ev); + placed.push({ event: ev, col: col }); + } + const total = cols.length || 1; + return placed.map(p => Object.assign({}, p, { leftPct: (p.col/total)*100, widthPct: (1/total)*100 })); +} +function escapeHtml(s) { + return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} + +function render() { + const body = document.getElementById('cal-body'); + document.getElementById('cal-title').textContent = getTitle(); + if (state.view === 'month') renderMonth(body); + else if (state.view === 'week') renderWeek(body); + else renderDay(body); +} +function getTitle() { + const p = mskParts(state.cursor); + if (state.view === 'month') return MONTHS[p.month] + ' ' + p.year; + if (state.view === 'week') { + const ws = startOfMSKWeek(state.cursor); const we = addDays(ws, 6); + const wp = mskParts(ws); const wep = mskParts(we); + if (wp.month === wep.month) return wp.day + '–' + wep.day + ' ' + MONTHS_GEN[wp.month] + ' ' + wp.year; + return wp.day + ' ' + MONTHS_GEN[wp.month] + ' – ' + wep.day + ' ' + MONTHS_GEN[wep.month] + ' ' + wep.year; + } + return fmtDateFull(state.cursor); +} + +function renderMonth(container) { + const first = startOfMSKMonth(state.cursor); + const gridStart = startOfMSKWeek(first); + const days = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс']; + const today = startOfMSKDay(new Date()); + let html = '
'; + for (const d of days) html += '
' + d + '
'; + html += '
'; + for (let i = 0; i < 42; i++) { + const cs = addDays(gridStart, i); + const ce = addDays(cs, 1); + const p = mskParts(cs); + const isOther = p.month !== mskParts(first).month; + const isToday = cs.getTime() === today.getTime(); + const evs = eventsForDay(cs, ce); + html += '
'; + html += '
' + p.day + '
'; + for (const ev of evs.slice(0, 3)) { + html += '
' + fmtTime(ev._start) + ' ' + escapeHtml(ev.title) + '
'; + } + if (evs.length > 3) html += '
+' + (evs.length - 3) + ' ещё
'; + html += '
'; + } + html += '
'; + container.innerHTML = html; + attachEventHandlers(); +} + +function renderWeek(container) { + const weekStart = startOfMSKWeek(state.cursor); + const today = startOfMSKDay(new Date()); + let html = '
'; + const names = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс']; + for (let i = 0; i < 7; i++) { + const ds = addDays(weekStart, i); + const p = mskParts(ds); + const isToday = ds.getTime() === today.getTime(); + html += '
' + names[i] + '
' + p.day + '
'; + } + html += '
'; + for (let h = 0; h < 24; h++) html += '
' + String(h).padStart(2,'0') + ':00
'; + html += '
'; + for (let i = 0; i < 7; i++) { + const ds = addDays(weekStart, i); + const de = addDays(ds, 1); + const evs = eventsForDay(ds, de); + const laid = layoutOverlaps(evs, ds, de); + html += '
'; + for (let h = 0; h < 24; h++) html += '
'; + const dayMs = 86400000; + for (const p of laid) { + const top = ((p.event._clipStart - ds) / dayMs) * 100; + const height = Math.max(((p.event._clipEnd - p.event._clipStart) / dayMs) * 100, 1.5); + html += '
'; + html += '
' + fmtTime(p.event._start) + '–' + fmtTime(p.event._end) + '
'; + html += '
' + escapeHtml(p.event.title) + '
'; + if (p.event.location) html += '
📍 ' + escapeHtml(p.event.location) + '
'; + html += '
'; + } + if (ds.getTime() === today.getTime()) { + const now = new Date(); + if (now >= ds && now < de) { + const nowFrac = (now - ds) / dayMs; + html += '
'; + } + } + html += '
'; + } + html += '
'; + container.innerHTML = html; + attachEventHandlers(); + if (!container.dataset.scrolled) { + const nowParts = mskParts(new Date()); + const wb = container.querySelector('.week-body'); + if (wb) wb.scrollTop = Math.max(0, (nowParts.hour - 1) * 40); + container.dataset.scrolled = '1'; + } +} + +function renderDay(container) { + const ds = startOfMSKDay(state.cursor); + const de = addDays(ds, 1); + const evs = eventsForDay(ds, de); + const laid = layoutOverlaps(evs, ds, de); + let html = '
'; + for (let h = 0; h < 24; h++) html += '
' + String(h).padStart(2,'0') + ':00
'; + html += '
'; + for (let h = 0; h < 24; h++) html += '
'; + const dayMs = 86400000; + for (const p of laid) { + const top = ((p.event._clipStart - ds) / dayMs) * 100; + const height = Math.max(((p.event._clipEnd - p.event._clipStart) / dayMs) * 100, 1.5); + html += '
'; + html += '
' + fmtTime(p.event._start) + '–' + fmtTime(p.event._end) + '
'; + html += '
' + escapeHtml(p.event.title) + '
'; + if (p.event.location) html += '
📍 ' + escapeHtml(p.event.location) + '
'; + if (p.event.description) html += '
' + escapeHtml(p.event.description) + '
'; + html += '
'; + } + const now = new Date(); + if (now >= ds && now < de) { + const nowFrac = (now - ds) / dayMs; + html += '
'; + } + html += '
'; + container.innerHTML = html; + attachEventHandlers(); +} + +function attachEventHandlers() { + document.querySelectorAll('[data-event-id]').forEach(el => { + el.addEventListener('click', e => { e.stopPropagation(); showEventPopup(parseInt(el.dataset.eventId)); }); + }); +} +function showEventPopup(id) { + const ev = EVENTS.find(e => e.id === id); + if (!ev) return; + const start = new Date(ev.start); const end = new Date(ev.end); + const types = { lecture:'📚 Лекция', workshop:'🛠 Мастер-класс', meeting:'👥 Встреча', personal:'🔒 Личное' }; + document.getElementById('popup-title').textContent = ev.title; + let meta = '
' + (types[ev.type] || ev.type) + '
'; + meta += '
🕐 ' + fmtDate(start) + ' ' + fmtTime(start) + ' – ' + fmtTime(end) + '
'; + if (ev.location) meta += '
📍 ' + escapeHtml(ev.location) + '
'; + document.getElementById('popup-meta').innerHTML = meta; + document.getElementById('popup-desc').textContent = ev.description || ''; + document.getElementById('event-popup').classList.remove('hidden'); +} + +document.getElementById('cal-prev').addEventListener('click', () => { + if (state.view === 'month') state.cursor = addMonths(state.cursor, -1); + else if (state.view === 'week') state.cursor = addDays(state.cursor, -7); + else state.cursor = addDays(state.cursor, -1); + render(); +}); +document.getElementById('cal-next').addEventListener('click', () => { + if (state.view === 'month') state.cursor = addMonths(state.cursor, 1); + else if (state.view === 'week') state.cursor = addDays(state.cursor, 7); + else state.cursor = addDays(state.cursor, 1); + render(); +}); +document.getElementById('cal-today').addEventListener('click', () => { state.cursor = new Date(); render(); }); +document.querySelectorAll('.view-switcher button').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.view = btn.dataset.view; + render(); + }); +}); +document.querySelectorAll('#cal-filters input').forEach(cb => { + cb.addEventListener('change', () => { + if (cb.checked) state.filters.add(cb.dataset.type); + else state.filters.delete(cb.dataset.type); + render(); + }); +}); + +document.getElementById('popup-close').addEventListener('click', () => { + document.getElementById('event-popup').classList.add('hidden'); +}); +document.addEventListener('click', e => { + const popup = document.getElementById('event-popup'); + if (!popup.classList.contains('hidden') && !popup.contains(e.target) && !e.target.closest('[data-event-id]')) { + popup.classList.add('hidden'); + } +}); + +const chatWindow = document.getElementById('chat-window'); +document.getElementById('chat-toggle').addEventListener('click', () => chatWindow.classList.toggle('hidden')); +document.getElementById('chat-close').addEventListener('click', () => chatWindow.classList.add('hidden')); +document.getElementById('chat-form').addEventListener('submit', async e => { + e.preventDefault(); + const input = document.getElementById('chat-input'); + const text = input.value.trim(); if (!text) return; + addMsg(text, 'user'); input.value = ''; + const loader = addMsg('...', 'bot'); + try { + const res = await fetch('/api/assistant', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text }), + }); + const data = await res.json(); + loader.textContent = data.reply || 'Ошибка'; + } catch (e) { loader.textContent = 'Ошибка соединения'; } +}); +function addMsg(text, sender) { + const div = document.createElement('div'); + div.className = 'msg ' + sender; div.textContent = text; + const c = document.getElementById('chat-messages'); + c.appendChild(div); c.scrollTop = c.scrollHeight; + return div; +} + +render(); + diff --git a/app/templates/schedule.html.bak b/app/templates/schedule.html.bak new file mode 100644 index 0000000..879bda8 --- /dev/null +++ b/app/templates/schedule.html.bak @@ -0,0 +1,143 @@ + + + + + + Расписание — Morozovka Portal + + + +
+ ← На главную +

Расписание

+ + {% if status %} +
+
+ {% if status.status == 'on_place' %}На месте + {% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин + {% elif status.status == 'away' %}Вне вуза + {% elif status.status == 'cancelled' %}Отменено + {% else %}Свободен / Вне расписания{% endif %} +
+ {% if status.message %} +
{{ status.message }}
+ {% endif %} +
+ {% endif %} + +

Ближайшие события

+ + {% for event in events %} +
+
+ {{ event.start_time.strftime('%d.%m %H:%M') }}
+ до {{ event.end_time.strftime('%H:%M') }} +
+
+

{{ event.title }}

+
+ {% if event.location %}📍 {{ event.location }}{% endif %} +
+ {{ event.event_type }} + {% if event.description %} +
{{ event.description }}
+ {% endif %} +
+
+ {% else %} +

Пока нет запланированных событий.

+ {% endfor %} + + +
+
🤖 Ассистент расписания
+
+
Привет! Я могу подсказать статус Никиты Сергеевича, его расписание или контакты. Спроси меня о чем-нибудь.
+
+
+ + +
+
+ +
+ + + + diff --git a/app/templates/sessions.html b/app/templates/sessions.html index d5ab872..8358e52 100644 --- a/app/templates/sessions.html +++ b/app/templates/sessions.html @@ -36,7 +36,7 @@ {{ s.user_agent or 'Неизвестно' }} {{ s.ip_address }} - {{ s.created_at.strftime('%d.%m.%Y %H:%M') }} + {{ s.created_at|msk_strftime('%d.%m.%Y %H:%M') }} {% if s.session_token == current_token %}Текущая{% else %}-{% endif %} {% endfor %} diff --git a/app/templates/sessions.html.bak b/app/templates/sessions.html.bak new file mode 100644 index 0000000..d5ab872 --- /dev/null +++ b/app/templates/sessions.html.bak @@ -0,0 +1,51 @@ + + + + + Активные сессии — Morozovka Portal + + + +
+ ← В профиль +

Управление устройствами

+

Список всех мест, где выполнен вход в ваш аккаунт.

+ + + + + + + + + + + + {% for s in sessions %} + + + + + + + {% endfor %} + +
Устройство (User-Agent)IP-адресДата входаСтатус
{{ s.user_agent or 'Неизвестно' }}{{ s.ip_address }}{{ s.created_at.strftime('%d.%m.%Y %H:%M') }}{% if s.session_token == current_token %}Текущая{% else %}-{% endif %}
+ +
+ +
+
+ + diff --git a/dump_project.sh b/dump_project.sh new file mode 100755 index 0000000..7f81af7 --- /dev/null +++ b/dump_project.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# dump_project.sh — собирает всё нужное для аудита проекта в один файл +# Запуск: sudo bash dump_project.sh +# Результат: /root/morozovka_dump_YYYY-MM-DD_HH-MM.txt + +set -uo pipefail + +PROJECT_DIR="/opt/morozovka-portal" +OUT="/root/morozovka_dump_$(date +%F_%H-%M).txt" +MAX_FILE_SIZE=$((512 * 1024)) # файлы больше 512 КБ пропускаем (обычно мусор) + +# ----- исключения ----- +EXCLUDES=( + "*/venv/*" + "*/__pycache__/*" + "*.pyc" + "*/.git/*" + "*/node_modules/*" + "*.log" + "*.sqlite" + "*.db" + "google_credentials.json" # СЕКРЕТ — не включаем + "*.png" "*.jpg" "*.jpeg" "*.gif" "*.ico" "*.pdf" "*.zip" "*.tar" "*.gz" "*.woff" "*.woff2" "*.ttf" +) + +should_skip() { + local f="$1" + for pat in "${EXCLUDES[@]}"; do + # shellcheck disable=SC2053 + [[ "$f" == $pat ]] && return 0 + done + return 1 +} + +section() { + echo "" >> "$OUT" + echo "================================================================================" >> "$OUT" + echo "== $*" >> "$OUT" + echo "================================================================================" >> "$OUT" +} + +dump_file() { + local f="$1" + [[ -f "$f" ]] || { echo "[skip: not a file] $f" >> "$OUT"; return; } + local size + size=$(stat -c%s "$f" 2>/dev/null || echo 0) + if (( size > MAX_FILE_SIZE )); then + echo "" >> "$OUT" + echo "---- FILE: $f (SKIPPED, size=${size}B > ${MAX_FILE_SIZE}B) ----" >> "$OUT" + return + fi + echo "" >> "$OUT" + echo "---- FILE: $f (size=${size}B) ----" >> "$OUT" + cat "$f" >> "$OUT" + echo "" >> "$OUT" + echo "---- END: $f ----" >> "$OUT" +} + +# ----- начинаем ----- +: > "$OUT" +echo "DUMP GENERATED: $(date -Is)" >> "$OUT" +echo "HOSTNAME: $(hostname -f 2>/dev/null || hostname)" >> "$OUT" +echo "UPTIME: $(uptime -p 2>/dev/null || uptime)" >> "$OUT" + +# 1) Дерево файлов проекта +section "TREE: $PROJECT_DIR" +if command -v tree >/dev/null 2>&1; then + tree -a -I 'venv|__pycache__|.git|node_modules' -L 4 "$PROJECT_DIR" >> "$OUT" 2>&1 +else + find "$PROJECT_DIR" \ + \( -path '*/venv' -o -path '*/__pycache__' -o -path '*/.git' -o -path '*/node_modules' \) -prune -o \ + -type f -print | sort >> "$OUT" +fi + +# 2) requirements.txt + версия Python +section "ENVIRONMENT" +echo "python: $(command -v python3) $(python3 --version 2>&1)" >> "$OUT" +if [[ -f "$PROJECT_DIR/requirements.txt" ]]; then + echo "" >> "$OUT" + echo "--- pip freeze (из venv) ---" >> "$OUT" + if [[ -x "$PROJECT_DIR/venv/bin/pip" ]]; then + "$PROJECT_DIR/venv/bin/pip" freeze >> "$OUT" 2>&1 + else + cat "$PROJECT_DIR/requirements.txt" >> "$OUT" + fi +fi + +# 3) Все текстовые файлы проекта +section "PROJECT FILES ($PROJECT_DIR)" +while IFS= read -r -d '' f; do + should_skip "$f" && continue + # бинарные — пропускаем по mime + if command -v file >/dev/null 2>&1; then + case "$(file -b --mime-encoding "$f" 2>/dev/null)" in + binary) echo "" >> "$OUT"; echo "---- BINARY SKIPPED: $f ----" >> "$OUT"; continue ;; + esac + fi + dump_file "$f" +done < <(find "$PROJECT_DIR" \ + \( -path '*/venv' -o -path '*/__pycache__' -o -path '*/.git' -o -path '*/node_modules' \) -prune -o \ + -type f -print0 | sort -z) + +# 4) systemd-юниты и таймеры проекта +section "SYSTEMD UNITS" +for u in \ + /etc/systemd/system/morozovka-portal.service \ + /etc/systemd/system/morozovka-sync.service \ + /etc/systemd/system/morozovka-sync.timer \ + /etc/systemd/system/gitea.service \ + /etc/systemd/system/bridge-webui.service; do + [[ -f "$u" ]] && dump_file "$u" || echo "[missing] $u" >> "$OUT" +done + +echo "" >> "$OUT" +echo "--- systemctl list-units (morozovka*) ---" >> "$OUT" +systemctl list-units --type=service --type=timer --no-pager --all --no-legend 2>/dev/null \ + | grep -Ei 'morozovka|gitea|bridge' >> "$OUT" || true + +echo "" >> "$OUT" +echo "--- systemctl status (кратко) ---" >> "$OUT" +for s in morozovka-portal morozovka-sync.timer gitea; do + echo "" >> "$OUT" + echo "### $s" >> "$OUT" + systemctl is-enabled "$s" 2>&1 >> "$OUT" || true + systemctl is-active "$s" 2>&1 >> "$OUT" || true +done + +# 5) Nginx-конфиги (сервер 3) +section "NGINX CONFIGS (server 3)" +for d in /etc/nginx/sites-available /etc/nginx/sites-enabled /etc/nginx/conf.d; do + [[ -d "$d" ]] || continue + while IFS= read -r -d '' f; do + dump_file "$f" + done < <(find "$d" -maxdepth 1 -type f -o -type l -print0 2>/dev/null | sort -z) +done + +section "NGINX TEST OUTPUT" +nginx -t 2>&1 >> "$OUT" || true + +# 6) PostgreSQL: список таблиц и схема (без данных) +section "POSTGRESQL SCHEMA (morozovka_portal)" +if command -v psql >/dev/null 2>&1 || [[ -x /usr/bin/sudo ]]; then + sudo -u postgres psql -d morozovka_portal -c "\dt" >> "$OUT" 2>&1 || true + sudo -u postgres psql -d morozovka_portal -c "\d+" >> "$OUT" 2>&1 || true + echo "" >> "$OUT" + echo "--- pg_dump --schema-only ---" >> "$OUT" + sudo -u postgres pg_dump --schema-only --no-owner --no-privileges morozovka_portal >> "$OUT" 2>&1 || true +else + echo "[psql not found]" >> "$OUT" +fi + +# 7) Проверка, что портал отвечает +section "PORTAL HEALTH" +curl -sS -m 5 http://127.0.0.1:8000/healthz >> "$OUT" 2>&1 || echo "[healthz failed]" >> "$OUT" +echo "" >> "$OUT" +curl -sS -m 5 -o /dev/null -w "HTTP %{http_code} time=%{time_total}s\n" http://127.0.0.1:8000/ >> "$OUT" 2>&1 || true + +# 8) Последние логи (обрезанные) +section "RECENT LOGS (last 40 lines each)" +for u in morozovka-portal morozovka-sync; do + echo "" >> "$OUT" + echo "### journalctl -u $u -n 40 --no-pager" >> "$OUT" + journalctl -u "$u" -n 40 --no-pager >> "$OUT" 2>&1 || true +done + +# 9) Проверка VPN/маршрутов на всякий +section "NETWORK (server 3)" +ip -brief addr >> "$OUT" 2>&1 || true +echo "" >> "$OUT" +ip route >> "$OUT" 2>&1 || true + +echo "" >> "$OUT" +echo "DUMP COMPLETE: $(date -Is)" >> "$OUT" +echo "SIZE: $(du -h "$OUT" | cut -f1)" >> "$OUT" + +echo "Готово: $OUT" +echo "Размер: $(du -h "$OUT" | cut -f1)" diff --git a/google_credentials.json b/google_credentials.json new file mode 100644 index 0000000..3e9b3fe --- /dev/null +++ b/google_credentials.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "morozovka-portal", + "private_key_id": "9f91b62410bd12b999277f1f74d7a428fb523d24", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC437ddiGGmsWbG\njItUKokzYMKw0b4Qz4AHy1S75B2PuLwcjB2N5K78BR84XNusrguHVHJsgxaEg8Ib\nnaw8K3yNKfQSPTe2+F8BA1QgJjaWZDJJbASIEaJzX4KOOBmqAfYc0TlQRb8Knm6Y\nB7Hqm3/UysWy5gVEED9d3ZPgds+xy+X5HreJLPM6Uf61TPpr+u8GpaFKBsTZrgfi\n/mrjm6d2SKqgz0O1nMtFQMbhWXiLK4MiRjNjQLNPC5IjD/VXInIXdqvpkUtaO1k9\n5RNr6TMlvZ0FDbZxxMYbDBEcCxEafsCSsvjucq5U2527BNeqUKty3IrKXoLAW5zG\nykv2BmmxAgMBAAECggEACl5VBsxPP72rgt9fMWio+mZG8mBHuDfPBCuO9HJnhwfo\nbZ3HyuK5vqgx4fkb7UFzTj3PT2SDEYpgi1Mf6whRq2AgOxqkV+EdAO31HKV6rRtw\nUjuvkwsRe5uDyLf+69DsLZ29XhIUqW2NIdPX/Q1cvBblmMeQ9t+GEkdgU7fjek2Z\nqf7ZFXzNAWQ8MOdAmr3NRXR8PT5tjb5e/rmu2c8B5L/qmC4ULKbaZMPQjky006pt\nL/i0af2KDUPRLaur7Fwhb3FxBWk+mtNISjzw3uMuJeAyo0l3Kd++3Erc0wewS5Oz\nOR6Ibdf82I1a+zuTRwgpg5lyt7L/qUZ3YUvyuCdb3QKBgQDqYH39ve9pxOGaj0dY\nwCJ02m0cO+pQold00xKxavKs3iJen8Q1F0RWRyWk3r/QhDq42BguX6mBiqrC28ry\nkvA0DXrSul7aMtaSaIdPabx/lCmUFFpy+XuwAiI/5ljSIJ7RZUmnIIAV5qfZ33c5\nQK9d7XEeezRbvvlvUXdRQvRHfQKBgQDJ7hAYL3AvCQ9yJ9wqrrmjq90FwwMlGb+c\niJSs89mEDTOsrPuAMCXIyhBWkdOLCfVeRAjzgEzP9YBV5JrmDqocT+5ujnbCtTBn\nhIRWdQKmBKAonrF0xOlMDJ3MjP6NeZlwvhhDK16x0I+y/CCilwKvod+w+QLtNPMW\nqLU5P6bJRQKBgDAhFH5QzKM5rOJiXdJZ0jmQySstGpboX3E8DvCj96WMEB7CTDZi\nysO05IQQwe792Fkw9vY+6r4RTbOqEujxR6ZIxloTr3q7SdjS4kZqfizsrgUckN5f\nYQjpx0K7+K1dyX9Sf2d0gxj8Pqe8/eyh3eE56yrJPh1BAFPdtNTZHPS5AoGARFfb\ngKQFnxW88ZEGFjgewoN3gey30sW9MViUoVjC5MunEqlVUdR55VRmZTZj16oeLrJu\nKSPiW3wm3GtL9dM+xehTtX1oOowifta+B5kAtXnZTwYu32uJ3mBixAlwWieuxBbC\nlXFHUn1ZNWXaEc10bkcmM3qEAA6su52MAH1HkNUCgYA7GGzM79Ms4r/VysJUK/xm\n1z33wvog3D6UeltX2blSLqsGsn0t9HbQnrYOJtQ38X4NZRO1w0I4eEMVqE0vfu5f\nNtbxbD7MCC9N0bglYUR9EuYuW4s7ge+HO1zE5STKLnHAGClqjNFPIu+crZhE3Sex\nK8UsUYvOyoXW/n5GVENwsw==\n-----END PRIVATE KEY-----\n", + "client_email": "portal-sync@morozovka-portal.iam.gserviceaccount.com", + "client_id": "114793071668131489151", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/portal-sync%40morozovka-portal.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/requirements.txt b/requirements.txt index ea2d765..7e3e137 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,45 @@ annotated-types==0.8.0 blinker==1.9.0 certifi==2026.10.18 +cffi==2.1.1 charset-normalizer==3.5.2 click==8.5.0 +cryptography==50.0.2 Flask==3.1.3 +google-api-core==2.42.0 +google-api-python-client==2.201.0 +google-auth==2.62.0 +google-auth-httplib2==0.4.4 +google-auth-oauthlib==1.5.0 +googleapis-common-protos==1.75.5 gunicorn==26.2.0 +httplib2==0.32.0 +icalendar==7.3.0 idna==3.20 itsdangerous==2.2.0 Jinja2==3.1.6 MarkupSafe==3.0.4 +oauthlib==4.0.0 +opentelemetry-api==1.45.1 +proto-plus==1.29.0 +protobuf==7.36.2 psycopg2-binary==2.9.13 +pyasn1==0.6.4 +pyasn1_modules==0.4.2 +pycparser==3.11 pydantic==2.14.1 pydantic_core==2.50.1 +pyparsing==3.3.3 +python-dateutil==2.9.0.post0 +pytz==2026.5 requests==2.34.2 +requests-oauthlib==2.0.0 +six==1.17.0 SQLAlchemy==2.1.5 sqlmodel==0.0.48 typing-inspection==0.4.4 typing_extensions==4.16.0 +tzdata==2026.5 +uritemplate==4.2.0 urllib3==2.8.0 Werkzeug==3.1.9