feat: add schedule models, public schedule page and admin panel
This commit is contained in:
parent
108ab9807b
commit
ee0e62160f
161
app/main.py
161
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/<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"))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
147
app/templates/admin_schedule.html
Normal file
147
app/templates/admin_schedule.html
Normal 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>
|
||||
69
app/templates/admin_users.html
Normal file
69
app/templates/admin_users.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
{% else %}
|
||||
<p>Скелет системы успешно запущен! (Flask Edition)</p>
|
||||
<a href="/login" class="btn">Войти через Gitea</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 %}
|
||||
|
||||
<div class="badge">Stage 1.3: OAuth2 OK</div>
|
||||
{% if session.get('role') == 'admin' %}
|
||||
<a href="/admin/users" class="btn btn-admin">Админ-панель</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Скелет системы успешно запущен! (Flask Edition)</p>
|
||||
<div class="actions">
|
||||
<a href="/login" class="btn">Войти через Gitea</a>
|
||||
<a href="/schedule" class="btn btn-schedule">Расписание</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="badge">Stage 2.2: Schedule Management OK</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
72
app/templates/schedule.html
Normal file
72
app/templates/schedule.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user