feat: add schedule models, public schedule page and admin panel

This commit is contained in:
Ivan Budakov 2026-10-19 18:09:43 +03:00
parent 108ab9807b
commit ee0e62160f
6 changed files with 483 additions and 61 deletions

View File

@ -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/<int:user_id>/role", methods=["POST"])
@require_role("admin")
def change_user_role(user_id):
new_role = request.form.get("role")
valid_roles = ["admin", "professor", "lead", "member", "student", "guest"]
if new_role not in valid_roles:
return "Ошибка: неверная роль", 400
with Session(engine) as db_session:
user = db_session.get(User, user_id)
if user:
if user.id == session.get("user_id") and new_role != "admin":
return "Ошибка: нельзя лишить самого себя прав администратора", 400
user.role = new_role
db_session.add(user)
db_session.commit()
return redirect(url_for("admin_users"))
# --- РАСПИСАНИЕ: ПУБЛИЧНАЯ ЧАСТЬ ---
@app.route("/schedule")
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/<int:event_id>/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"))

View File

@ -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))

View File

@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Управление расписанием — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 900px; margin: 0 auto; }
h2 { color: #58a6ff; border-bottom: 1px solid #30363d; padding-bottom: 10px; }
h3 { color: #8b949e; margin-top: 30px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
.form-row { display: flex; gap: 15px; margin-bottom: 15px; align-items: flex-end; }
.form-group { display: flex; flex-direction: column; flex: 1; }
.form-group label { margin-bottom: 5px; font-size: 0.85rem; color: #8b949e; }
input, select, textarea { padding: 8px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
textarea { resize: vertical; min-height: 60px; }
button { padding: 10px 15px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
button:hover { background: #2ea043; }
button.danger { background: #da3633; }
button.danger:hover { background: #f85149; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #8b949e; }
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← На главную</a>
<h2>Управление расписанием и статусом</h2>
<!-- Блок 1: Статус -->
<div class="card">
<h3>Текущий статус (выводится на главной странице)</h3>
<form action="/admin/schedule/status" method="POST">
<div class="form-row">
<div class="form-group">
<label>Статус</label>
<select name="status">
<option value="free" {% if status.status == 'free' %}selected{% endif %}>Свободен / Вне расписания</option>
<option value="on_place" {% if status.status == 'on_place' %}selected{% endif %}>На месте</option>
<option value="delayed" {% if status.status == 'delayed' %}selected{% endif %}>Опаздываю</option>
<option value="away" {% if status.status == 'away' %}selected{% endif %}>Вне вуза</option>
<option value="cancelled" {% if status.status == 'cancelled' %}selected{% endif %}>Отменено</option>
</select>
</div>
<div class="form-group" style="flex: 0.5;">
<label>Опоздание (мин)</label>
<input type="number" name="delay_minutes" value="{{ status.delay_minutes or 0 }}" min="0">
</div>
<div class="form-group">
<label>Сообщение для студентов</label>
<input type="text" name="message" value="{{ status.message or '' }}" placeholder="Например: задерживаюсь на 15 минут">
</div>
<button type="submit">Обновить статус</button>
</div>
</form>
</div>
<!-- Блок 2: Добавление события -->
<div class="card">
<h3>Добавить новое событие</h3>
<form action="/admin/schedule/event/add" method="POST">
<div class="form-row">
<div class="form-group" style="flex: 2;">
<label>Название</label>
<input type="text" name="title" required>
</div>
<div class="form-group">
<label>Тип</label>
<select name="event_type">
<option value="lecture">Лекция</option>
<option value="workshop">Мастер-класс</option>
<option value="meeting">Встреча</option>
<option value="personal">Личное</option>
</select>
</div>
<div class="form-group">
<label>Место</label>
<input type="text" name="location">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Начало</label>
<input type="datetime-local" name="start_time" required>
</div>
<div class="form-group">
<label>Конец</label>
<input type="datetime-local" name="end_time" required>
</div>
<div class="form-group" style="flex: 0.5; flex-direction: row; align-items: center; gap: 10px;">
<input type="checkbox" name="is_public" checked id="is_public">
<label for="is_public" style="margin: 0; cursor: pointer;">Публичное</label>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Описание</label>
<textarea name="description"></textarea>
</div>
<button type="submit" style="height: 60px;">Добавить</button>
</div>
</form>
</div>
<!-- Блок 3: Список событий -->
<div class="card">
<h3>Запланированные события</h3>
<table>
<thead>
<tr>
<th>Дата и время</th>
<th>Название</th>
<th>Тип</th>
<th>Публичное</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.start_time.strftime('%d.%m %H:%M') }} - {{ event.end_time.strftime('%H:%M') }}</td>
<td><strong>{{ event.title }}</strong><br><small style="color:#8b949e">{{ event.location or '' }}</small></td>
<td>{{ event.event_type }}</td>
<td>{{ 'Да' if event.is_public else 'Нет' }}</td>
<td>
<form action="/admin/schedule/event/{{ event.id }}/delete" method="POST" onsubmit="return confirm('Удалить событие?');">
<button type="submit" class="danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" style="text-align: center; color: #8b949e;">Нет запланированных событий</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Админка — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
h2 { color: #58a6ff; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; background: #161b22; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #8b949e; font-size: 0.85rem; text-transform: uppercase; }
tr:hover { background: #21262d; }
.badge { padding: 4px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: bold; }
.role-admin { background: #da3633; color: white; }
.role-professor { background: #f0883e; color: white; }
.role-lead { background: #d29922; color: white; }
.role-member { background: #238636; color: white; }
.role-student { background: #1f6feb; color: white; }
.role-guest { background: #30363d; color: white; }
select { padding: 6px; border-radius: 4px; background: #0d1117; color: white; border: 1px solid #30363d; }
button { padding: 6px 12px; background: #238636; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #2ea043; }
a { color: #58a6ff; text-decoration: none; }
</style>
</head>
<body>
<h2>Управление пользователями</h2>
<p><a href="/">← На главную</a></p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Логин</th>
<th>ФИО</th>
<th>Email</th>
<th>Роль</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td><strong>{{ u.username }}</strong></td>
<td>{{ u.full_name or '—' }}</td>
<td>{{ u.email or '—' }}</td>
<td>
<span class="badge role-{{ u.role }}">{{ u.role }}</span>
</td>
<td>
<form action="/admin/users/{{ u.id }}/role" method="POST" style="display:flex; gap: 10px;">
<select name="role">
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
<option value="professor" {% if u.role == 'professor' %}selected{% endif %}>professor</option>
<option value="lead" {% if u.role == 'lead' %}selected{% endif %}>lead</option>
<option value="member" {% if u.role == 'member' %}selected{% endif %}>member</option>
<option value="student" {% if u.role == 'student' %}selected{% endif %}>student</option>
<option value="guest" {% if u.role == 'guest' %}selected{% endif %}>guest</option>
</select>
<button type="submit">Сменить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>

View File

@ -5,44 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Morozovka Portal</title>
<style>
body {
background: #0d1117;
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
flex-direction: column;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
background: linear-gradient(90deg, #58a6ff, #a371f7, #f778ba);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
p { color: #8b949e; }
.badge {
background: #238636;
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 0.8rem;
margin-top: 20px;
}
.btn {
background: #238636;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 5px;
margin-top: 20px;
font-weight: bold;
}
.btn:hover { background: #2ea043; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; flex-direction: column; }
h1 { font-size: 2.5rem; margin-bottom: 10px; background: linear-gradient(90deg, #58a6ff, #a371f7, #f778ba); -webkit-background-clip: text; background-clip: text; color: transparent; }
p { color: #8b949e; margin-bottom: 20px; font-size: 1.1rem; }
.badge { background: #238636; color: white; padding: 5px 10px; border-radius: 5px; font-size: 0.8rem; margin-top: 30px; font-weight: bold; }
.actions { display: flex; gap: 15px; margin-top: 20px; flex-wrap: wrap; justify-content: center; }
.btn { background: #238636; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; transition: opacity 0.2s; }
.btn:hover { opacity: 0.8; }
.btn-profile { background: #1f6feb; }
.btn-admin { background: #da3633; }
.btn-schedule { background: #d29922; }
</style>
</head>
<body>
@ -50,12 +22,26 @@
{% if username %}
<p>Привет, <b>{{ username }}</b>!</p>
<a href="/profile" class="btn">Мой профиль</a>
<div class="actions">
<a href="/profile" class="btn btn-profile">Мой профиль</a>
<a href="/schedule" class="btn btn-schedule">Расписание</a>
{% if session.get('role') in ['admin', 'professor'] %}
<a href="/admin/schedule" class="btn btn-schedule">Управление расписанием</a>
{% endif %}
{% if session.get('role') == 'admin' %}
<a href="/admin/users" class="btn btn-admin">Админ-панель</a>
{% endif %}
</div>
{% else %}
<p>Скелет системы успешно запущен! (Flask Edition)</p>
<a href="/login" class="btn">Войти через Gitea</a>
<div class="actions">
<a href="/login" class="btn">Войти через Gitea</a>
<a href="/schedule" class="btn btn-schedule">Расписание</a>
</div>
{% endif %}
<div class="badge">Stage 1.3: OAuth2 OK</div>
<div class="badge">Stage 2.2: Schedule Management OK</div>
</body>
</html>

View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 40px 20px; }
.container { max-width: 800px; margin: 0 auto; }
.status-banner { padding: 20px; border-radius: 10px; text-align: center; margin-bottom: 30px; }
.status-on_place { background: rgba(35, 134, 54, 0.2); border: 1px solid #238636; color: #3fb950; }
.status-delayed { background: rgba(210, 153, 34, 0.2); border: 1px solid #d29922; color: #d29922; }
.status-away, .status-cancelled { background: rgba(248, 81, 73, 0.2); border: 1px solid #f85149; color: #f85149; }
.status-free { background: rgba(139, 148, 158, 0.2); border: 1px solid #8b949e; color: #8b949e; }
.status-title { font-size: 1.5rem; font-weight: bold; margin-bottom: 5px; }
.status-message { font-size: 1.1rem; }
.event-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; margin-bottom: 15px; display: flex; gap: 20px; }
.event-time { font-family: monospace; font-size: 1.1rem; color: #58a6ff; min-width: 120px; }
.event-details { flex: 1; }
.event-title { font-size: 1.2rem; font-weight: bold; margin: 0 0 5px 0; }
.event-meta { color: #8b949e; font-size: 0.9rem; margin-bottom: 5px; }
.event-type { display: inline-block; background: #21262d; padding: 2px 8px; border-radius: 4px; font-size: 0.8rem; text-transform: uppercase; }
.event-desc { font-size: 0.95rem; margin-top: 10px; }
.back-link { display: inline-block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
.back-link:hover { color: #58a6ff; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← На главную</a>
<h1 style="margin-top: 0;">Расписание</h1>
{% if status %}
<div class="status-banner status-{{ status.status }}">
<div class="status-title">
{% if status.status == 'on_place' %}На месте
{% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин
{% elif status.status == 'away' %}Вне вуза
{% elif status.status == 'cancelled' %}Отменено
{% else %}Свободен / Вне расписания{% endif %}
</div>
{% if status.message %}
<div class="status-message">{{ status.message }}</div>
{% endif %}
</div>
{% endif %}
<h2 style="color: #8b949e; font-size: 1.2rem; border-bottom: 1px solid #30363d; padding-bottom: 10px;">Ближайшие события</h2>
{% for event in events %}
<div class="event-card">
<div class="event-time">
{{ event.start_time.strftime('%d.%m %H:%M') }}<br>
<span style="color: #8b949e; font-size: 0.9rem;">до {{ event.end_time.strftime('%H:%M') }}</span>
</div>
<div class="event-details">
<h3 class="event-title">{{ event.title }}</h3>
<div class="event-meta">
{% if event.location %}📍 {{ event.location }}{% endif %}
</div>
<span class="event-type">{{ event.event_type }}</span>
{% if event.description %}
<div class="event-desc">{{ event.description }}</div>
{% endif %}
</div>
</div>
{% else %}
<p style="color: #8b949e;">Пока нет запланированных событий.</p>
{% endfor %}
</div>
</body>
</html>