From ee0e62160f6b82457e769948827468ef8e432d75 Mon Sep 17 00:00:00 2001 From: Ivan Budakov Date: Mon, 19 Oct 2026 18:09:43 +0300 Subject: [PATCH] feat: add schedule models, public schedule page and admin panel --- app/main.py | 161 +++++++++++++++++++++++++++--- app/models.py | 27 ++++- app/templates/admin_schedule.html | 147 +++++++++++++++++++++++++++ app/templates/admin_users.html | 69 +++++++++++++ app/templates/index.html | 68 +++++-------- app/templates/schedule.html | 72 +++++++++++++ 6 files changed, 483 insertions(+), 61 deletions(-) create mode 100644 app/templates/admin_schedule.html create mode 100644 app/templates/admin_users.html create mode 100644 app/templates/schedule.html diff --git a/app/main.py b/app/main.py index e66e28b..18f6ffb 100644 --- a/app/main.py +++ b/app/main.py @@ -2,14 +2,34 @@ from flask import Flask, render_template, jsonify, redirect, url_for, request, s from .db import init_db, engine from . import config, auth from sqlmodel import Session, select -from .models import User +from .models import User, Event, GlobalStatus +from datetime import datetime, timezone +from functools import wraps app = Flask(__name__) -app.secret_key = config.WEB_SECRET # Для подписи куки-сессий +app.secret_key = config.WEB_SECRET -# Инициализация БД +# Инициализация БД и дефолтного статуса 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() + +# --- Декоратор проверки ролей --- +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 @app.route("/healthz") def healthz(): @@ -17,11 +37,10 @@ def healthz(): @app.route("/") def index(): - # Если юзер уже в сессии, показываем имя current_user = session.get("username") return render_template("index.html", username=current_user) -# 1. Редирект на Gitea для авторизации +# --- АВТОРИЗАЦИЯ --- @app.route("/login") def login(): gitea_auth_url = ( @@ -32,54 +51,46 @@ def login(): ) return redirect(gitea_auth_url) -# 2. Колбэк от Gitea @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 gitea_user = auth.get_gitea_user(token) if not gitea_user: return "Ошибка: не удалось получить профиль пользователя.", 400 - # Сохраняем или обновляем в БД with Session(engine) as db_session: user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first() - if not user: - # Создаем нового new_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" # Первому дадим админа + role="admin" if gitea_user["login"] == "git_admin" else "student" ) db_session.add(new_user) db_session.commit() user = new_user else: - # Обновляем данные, если изменились в Gitea user.username = gitea_user["login"] user.full_name = gitea_user.get("full_name") user.email = gitea_user.get("email") db_session.add(user) db_session.commit() - # Пишем в сессию Flask session["user_id"] = user.id session["username"] = user.username + session["role"] = user.role return redirect(url_for("profile")) -# 3. Страница профиля (только для авторизованных) @app.route("/profile") def profile(): user_id = session.get("user_id") @@ -94,8 +105,126 @@ def profile(): return render_template("profile.html", user=user) -# 4. Выход @app.route("/logout") def logout(): session.clear() return redirect(url_for("index")) + +# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ --- +@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("/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("/admin/schedule") +@require_role(["admin", "professor"]) +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() + 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_str = request.form.get("start_time") + end_str = request.form.get("end_time") + location = request.form.get("location") + description = request.form.get("description") + is_public = request.form.get("is_public") == "on" + + # Парсим дату из input type="datetime-local" + start_time = datetime.strptime(start_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc) + end_time = datetime.strptime(end_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc) + + with Session(engine) as db_session: + new_event = Event( + title=title, + event_type=event_type, + start_time=start_time, + end_time=end_time, + location=location, + description=description, + is_public=is_public + ) + db_session.add(new_event) + db_session.commit() + + return redirect(url_for("admin_schedule")) + +@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")) diff --git a/app/models.py b/app/models.py index fa3f68a..464775a 100644 --- a/app/models.py +++ b/app/models.py @@ -1,8 +1,6 @@ -from sqlmodel import SQLModel - from sqlmodel import SQLModel, Field from typing import Optional -from datetime import datetime +from datetime import datetime, timezone class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) @@ -12,4 +10,25 @@ class User(SQLModel, table=True): email: Optional[str] = None role: str = Field(default="student") # admin, professor, lead, member, student is_active: bool = Field(default=True) - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class Event(SQLModel, table=True): + """Событие в расписании.""" + id: Optional[int] = Field(default=None, primary_key=True) + title: str + event_type: str = Field(default="lecture") # lecture, workshop, meeting, personal + start_time: datetime + end_time: datetime + location: Optional[str] = None + description: Optional[str] = None + status: str = Field(default="scheduled") # scheduled, moved, cancelled, delayed + is_public: bool = Field(default=True) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +class GlobalStatus(SQLModel, table=True): + """Текущий статус Морозова.""" + id: Optional[int] = Field(default=None, primary_key=True) + status: str = Field(default="free") # free, on_place, delayed, away, cancelled + delay_minutes: int = Field(default=0) + message: Optional[str] = None + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/app/templates/admin_schedule.html b/app/templates/admin_schedule.html new file mode 100644 index 0000000..2cab7d3 --- /dev/null +++ b/app/templates/admin_schedule.html @@ -0,0 +1,147 @@ + + + + + Управление расписанием — Morozovka Portal + + + +
+ ← На главную +

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

+ + +
+

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

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

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

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

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

+ + + + + + + + + + + + {% for event in events %} + + + + + + + + {% else %} + + {% endfor %} + +
Дата и времяНазваниеТипПубличноеДействие
{{ event.start_time.strftime('%d.%m %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_users.html b/app/templates/admin_users.html new file mode 100644 index 0000000..90b079c --- /dev/null +++ b/app/templates/admin_users.html @@ -0,0 +1,69 @@ + + + + + Админка — Morozovka Portal + + + +

Управление пользователями

+

← На главную

+ + + + + + + + + + + + + + {% for u in users %} + + + + + + + + + {% endfor %} + +
IDЛогинФИОEmailРольДействие
{{ u.id }}{{ u.username }}{{ u.full_name or '—' }}{{ u.email or '—' }} + {{ u.role }} + +
+ + +
+
+ + diff --git a/app/templates/index.html b/app/templates/index.html index 2867fff..521a670 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -5,44 +5,16 @@ Morozovka Portal @@ -50,12 +22,26 @@ {% if username %}

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

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

Скелет системы успешно запущен! (Flask Edition)

- Войти через Gitea + {% endif %} -
Stage 1.3: OAuth2 OK
+
Stage 2.2: Schedule Management OK
diff --git a/app/templates/schedule.html b/app/templates/schedule.html new file mode 100644 index 0000000..5610d01 --- /dev/null +++ b/app/templates/schedule.html @@ -0,0 +1,72 @@ + + + + + + Расписание — 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 %} +
+ +