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