morozovka-portal/app/modules/schedule_routes.py

209 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Роуты расписания: публичная страница, экспорт ICS, ассистент, админка."""
import pytz
from datetime import datetime, timezone, timedelta
from flask import render_template, request, redirect, url_for, session, jsonify, make_response
from icalendar import Calendar, Event as ICalEvent
from sqlmodel import Session, select
from ..db import engine
from ..models import Event, GlobalStatus, User
from ..helpers import (
to_msk, login_required, require_role,
parse_datetime, status_label, event_summary_text,
STATUS_LABELS, send_email, notify_subscribers,
)
from .. import assistant, llm_assistant
def register(app):
@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 - timedelta(days=180))
.where(Event.start_time <= now + timedelta(days=365))
.order_by(Event.start_time)
).all()
from ..helpers import get_user_subscription
my_subscription = None
current_user = None
if session.get("user_id"):
current_user = db_session.get(User, session["user_id"])
if current_user:
my_subscription = get_user_subscription(db_session, current_user)
events_json = [{"id": e.id, "title": e.title, "type": e.event_type,
"start": to_msk(e.start_time).isoformat(),
"end": to_msk(e.end_time).isoformat(),
"location": e.location, "description": e.description}
for e in events]
return render_template("schedule.html", status=current_status,
events_json=events_json,
my_subscription=my_subscription,
current_user=current_user,
msg=request.args.get("msg"),
error=request.args.get("error"))
@app.route("/schedule/export.ics")
def export_ics():
with Session(engine) as db_session:
events = db_session.exec(select(Event).where(Event.is_public == True)).all()
cal = Calendar()
cal.add('prodid', '-//Morozovka Portal//mxm.dk//')
cal.add('version', '2.0')
for ev in events:
ievent = ICalEvent()
ievent.add('summary', ev.title)
ievent.add('dtstart', ev.start_time.astimezone(pytz.utc))
ievent.add('dtend', ev.end_time.astimezone(pytz.utc))
if ev.location: ievent.add('location', ev.location)
if ev.description: ievent.add('description', ev.description)
cal.add_component(ievent)
response = make_response(cal.to_ical())
response.headers["Content-Disposition"] = "attachment; filename=morozovka_schedule.ics"
response.headers["Content-Type"] = "text/calendar; charset=utf-8"
return response
@app.route("/api/assistant", methods=["POST"])
def api_assistant():
data = request.get_json() or {}
message = (data.get("message") or "").strip()
if not message: return jsonify({"error": "Нет сообщения"}), 400
history = data.get("history") or []
if llm_assistant.is_enabled():
try:
reply = llm_assistant.chat(message, history)
if reply: return jsonify({"reply": reply, "source": "llm"})
except Exception as e:
print(f"[llm] fallback: {e}", flush=True)
return jsonify({"reply": assistant.get_response(message), "source": "regex"})
@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()
events = db_session.exec(select(Event).order_by(Event.start_time.desc()).limit(50)).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:
cs = db_session.exec(select(GlobalStatus)).first()
if cs:
old_label = status_label(cs.status, cs.delay_minutes)
cs.status = status_val
cs.delay_minutes = delay
cs.message = message
cs.updated_at = datetime.now(timezone.utc)
db_session.add(cs); db_session.commit()
new_label = status_label(status_val, delay)
body = f"Новый статус: {new_label}\nБыло: {old_label}\n"
if message: body += f"Сообщение: {message}\n"
body += f"Обновлено: {to_msk(cs.updated_at).strftime('%d.%m.%Y %H:%M')}"
notify_subscribers(f"[Расписание] {new_label}", body, kind="status")
return redirect(url_for("admin_schedule"))
@app.route("/admin/schedule/event/add", methods=["POST"])
@require_role(["admin", "professor"])
def add_event():
with Session(engine) as db_session:
ev = Event(title=request.form.get("title"),
event_type=request.form.get("event_type"),
start_time=parse_datetime(request.form.get("start_time")),
end_time=parse_datetime(request.form.get("end_time")),
location=request.form.get("location"),
description=request.form.get("description"),
is_public=request.form.get("is_public") == "on")
db_session.add(ev); db_session.commit(); db_session.refresh(ev)
if ev.is_public:
notify_subscribers(f"[Расписание] Новое: {ev.title}",
"Добавлено событие.\n\n" + event_summary_text(ev),
kind="event")
return redirect(url_for("admin_schedule"))
@app.route("/admin/schedule/event/<int:event_id>/edit", methods=["GET", "POST"])
@require_role(["admin", "professor"])
def edit_event(event_id):
with Session(engine) as db_session:
event = db_session.get(Event, event_id)
if not event: return "Не найдено", 404
if request.method == "POST":
event.title = request.form.get("title")
event.event_type = request.form.get("event_type")
event.start_time = parse_datetime(request.form.get("start_time"))
event.end_time = parse_datetime(request.form.get("end_time"))
event.location = request.form.get("location")
event.description = request.form.get("description")
event.is_public = request.form.get("is_public") == "on"
event.manually_edited = True
db_session.add(event); db_session.commit()
if event.is_public:
notify_subscribers(f"[Расписание] Изменено: {event.title}",
"Событие изменено.\n\n" + event_summary_text(event),
kind="event")
return redirect(url_for("admin_schedule"))
return render_template("admin_event_edit.html", event=event)
@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:
t_snap = event.title
s_snap = event.start_time
was_public = event.is_public
db_session.delete(event); db_session.commit()
if was_public:
body = f"Событие удалено.\n\nНазвание: {t_snap}\nБыло: {to_msk(s_snap).strftime('%d.%m.%Y %H:%M')}"
notify_subscribers(f"[Расписание] Удалено: {t_snap}", body, kind="event")
return redirect(url_for("admin_schedule"))
@app.route("/admin/schedule/import", methods=["GET", "POST"])
@require_role(["admin", "professor"])
def import_ics():
if request.method == "POST":
if 'file' not in request.files: return "Файл не загружен", 400
file = request.files['file']
if not file or not file.filename: return "Файл не выбран", 400
if file.filename.endswith('.ics'):
try:
cal = Calendar.from_ical(file.read())
count = 0
with Session(engine) as db_session:
for comp in cal.walk():
if comp.name == "VEVENT":
summary = str(comp.get('summary', 'Без названия'))
ds = comp.get('dtstart'); de = comp.get('dtend')
if not ds or not de: continue
dtstart = ds.dt; dtend = de.dt
if not isinstance(dtstart, datetime):
dtstart = datetime.combine(dtstart, datetime.min.time())
if not isinstance(dtend, datetime):
dtend = datetime.combine(dtend, datetime.min.time())
if dtstart.tzinfo is None: dtstart = dtstart.replace(tzinfo=timezone.utc)
if dtend.tzinfo is None: dtend = dtend.replace(tzinfo=timezone.utc)
db_session.add(Event(
title=summary, event_type='lecture',
start_time=dtstart.astimezone(timezone.utc),
end_time=dtend.astimezone(timezone.utc),
location=str(comp.get('location', '')),
description=str(comp.get('description', '')),
is_public=True))
count += 1
db_session.commit()
if count > 0:
notify_subscribers(f"[Расписание] Импорт: {count}",
f"Добавлено: {count}", kind="event")
return redirect(url_for("admin_schedule"))
except Exception as e:
return f"Ошибка: {e}", 400
return render_template("admin_schedule_import.html")