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 .db import init_db, engine
from . import config, auth from . import config, auth
from sqlmodel import Session, select 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 = Flask(__name__)
app.secret_key = config.WEB_SECRET # Для подписи куки-сессий app.secret_key = config.WEB_SECRET
# Инициализация БД # Инициализация БД и дефолтного статуса
with app.app_context(): with app.app_context():
init_db() 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") @app.route("/healthz")
def healthz(): def healthz():
@ -17,11 +37,10 @@ def healthz():
@app.route("/") @app.route("/")
def index(): def index():
# Если юзер уже в сессии, показываем имя
current_user = session.get("username") current_user = session.get("username")
return render_template("index.html", username=current_user) return render_template("index.html", username=current_user)
# 1. Редирект на Gitea для авторизации # --- АВТОРИЗАЦИЯ ---
@app.route("/login") @app.route("/login")
def login(): def login():
gitea_auth_url = ( gitea_auth_url = (
@ -32,54 +51,46 @@ def login():
) )
return redirect(gitea_auth_url) return redirect(gitea_auth_url)
# 2. Колбэк от Gitea
@app.route("/auth/callback") @app.route("/auth/callback")
def auth_callback(): def auth_callback():
code = request.args.get("code") code = request.args.get("code")
if not code: if not code:
return "Ошибка: Gitea не передала код авторизации.", 400 return "Ошибка: Gitea не передала код авторизации.", 400
# Меняем код на токен
token = auth.get_gitea_token(code) token = auth.get_gitea_token(code)
if not token: if not token:
return "Ошибка: не удалось получить токен от Gitea.", 400 return "Ошибка: не удалось получить токен от Gitea.", 400
# Получаем юзера из Gitea
gitea_user = auth.get_gitea_user(token) gitea_user = auth.get_gitea_user(token)
if not gitea_user: if not gitea_user:
return "Ошибка: не удалось получить профиль пользователя.", 400 return "Ошибка: не удалось получить профиль пользователя.", 400
# Сохраняем или обновляем в БД
with Session(engine) as db_session: with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first() user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first()
if not user: if not user:
# Создаем нового
new_user = User( new_user = User(
gitea_id=gitea_user["id"], gitea_id=gitea_user["id"],
username=gitea_user["login"], username=gitea_user["login"],
full_name=gitea_user.get("full_name"), full_name=gitea_user.get("full_name"),
email=gitea_user.get("email"), 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.add(new_user)
db_session.commit() db_session.commit()
user = new_user user = new_user
else: else:
# Обновляем данные, если изменились в Gitea
user.username = gitea_user["login"] user.username = gitea_user["login"]
user.full_name = gitea_user.get("full_name") user.full_name = gitea_user.get("full_name")
user.email = gitea_user.get("email") user.email = gitea_user.get("email")
db_session.add(user) db_session.add(user)
db_session.commit() db_session.commit()
# Пишем в сессию Flask
session["user_id"] = user.id session["user_id"] = user.id
session["username"] = user.username session["username"] = user.username
session["role"] = user.role
return redirect(url_for("profile")) return redirect(url_for("profile"))
# 3. Страница профиля (только для авторизованных)
@app.route("/profile") @app.route("/profile")
def profile(): def profile():
user_id = session.get("user_id") user_id = session.get("user_id")
@ -94,8 +105,126 @@ def profile():
return render_template("profile.html", user=user) return render_template("profile.html", user=user)
# 4. Выход
@app.route("/logout") @app.route("/logout")
def logout(): def logout():
session.clear() session.clear()
return redirect(url_for("index")) 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 sqlmodel import SQLModel, Field
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime, timezone
class User(SQLModel, table=True): class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
@ -12,4 +10,25 @@ class User(SQLModel, table=True):
email: Optional[str] = None email: Optional[str] = None
role: str = Field(default="student") # admin, professor, lead, member, student role: str = Field(default="student") # admin, professor, lead, member, student
is_active: bool = Field(default=True) 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"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Morozovka Portal</title> <title>Morozovka Portal</title>
<style> <style>
body { 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; }
background: #0d1117; h1 { font-size: 2.5rem; margin-bottom: 10px; background: linear-gradient(90deg, #58a6ff, #a371f7, #f778ba); -webkit-background-clip: text; background-clip: text; color: transparent; }
color: #e6edf3; p { color: #8b949e; margin-bottom: 20px; font-size: 1.1rem; }
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; .badge { background: #238636; color: white; padding: 5px 10px; border-radius: 5px; font-size: 0.8rem; margin-top: 30px; font-weight: bold; }
display: flex; .actions { display: flex; gap: 15px; margin-top: 20px; flex-wrap: wrap; justify-content: center; }
justify-content: center; .btn { background: #238636; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; transition: opacity 0.2s; }
align-items: center; .btn:hover { opacity: 0.8; }
height: 100vh; .btn-profile { background: #1f6feb; }
margin: 0; .btn-admin { background: #da3633; }
flex-direction: column; .btn-schedule { background: #d29922; }
}
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; }
</style> </style>
</head> </head>
<body> <body>
@ -50,12 +22,26 @@
{% if username %} {% if username %}
<p>Привет, <b>{{ username }}</b>!</p> <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 %} {% else %}
<p>Скелет системы успешно запущен! (Flask Edition)</p> <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 %} {% endif %}
<div class="badge">Stage 1.3: OAuth2 OK</div> <div class="badge">Stage 2.2: Schedule Management OK</div>
</body> </body>
</html> </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>