morozovka-portal/app/main.py

1971 lines
92 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.

import os
import uuid
import smtplib
import pytz
import threading
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from itsdangerous import URLSafeTimedSerializer
from werkzeug.security import generate_password_hash, check_password_hash
from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory, make_response
from sqlalchemy import or_
from icalendar import Calendar, Event as ICalEvent
from .db import init_db, engine
from . import config, auth, assistant, gitea_api, llm_assistant
from sqlmodel import Session, select
from .models import (
User, Event, GlobalStatus, Project, PortalFile, UserSession,
ProjectMember, EventSubscriber, ProjectTask,
Workshop, WorkshopSession, WorkshopRegistration,
WorkshopMaterial, WorkshopFeedback, Notification,
)
from datetime import datetime, timezone, timedelta
from functools import wraps
app = Flask(__name__)
app.secret_key = config.WEB_SECRET
serializer = URLSafeTimedSerializer(app.secret_key)
MSK = pytz.timezone("Europe/Moscow")
# ══════════════════════════════════════════════════════════════════════
# БАЗОВЫЕ ХЕЛПЕРЫ И ДЕКОРАТОРЫ (должны быть ДО всех @app.route)
# ══════════════════════════════════════════════════════════════════════
def to_msk(dt):
if dt is None: return None
if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(MSK)
@app.template_filter("msk_strftime")
def _msk_strftime(dt, fmt):
if dt is None: return ""
return to_msk(dt).strftime(fmt)
@app.template_filter("date_ru")
def _date_ru(d):
if not d: return ""
try:
return datetime.strptime(str(d)[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
except Exception:
return str(d)
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
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get("user_id"):
return redirect(url_for("login"))
return f(*args, **kwargs)
return decorated_function
def parse_datetime(dt_str):
if not dt_str: return None
clean_str = dt_str.replace('T', ' ').strip()
if len(clean_str) > 16: clean_str = clean_str[:16]
naive = datetime.strptime(clean_str, "%Y-%m-%d %H:%M")
return MSK.localize(naive).astimezone(timezone.utc)
def normalize_tags(raw):
if not raw: return None
parts = {t.strip().lower() for t in raw.split(",") if t.strip()}
return ",".join(sorted(parts)) if parts else None
def status_label(status_val, delay):
if status_val == "delayed":
return f"Опаздываю на {delay} мин"
return STATUS_LABELS.get(status_val, status_val)
def event_summary_text(ev):
start = to_msk(ev.start_time).strftime('%d.%m.%Y %H:%M')
end = to_msk(ev.end_time).strftime('%H:%M')
lines = [f"Название: {ev.title}", f"Тип: {ev.event_type}", f"Когда: {start} – {end}"]
if ev.location: lines.append(f"Место: {ev.location}")
if ev.description: lines.append(f"Описание: {ev.description}")
return "\n".join(lines)
def project_permissions(project, user_id, global_role, membership):
is_admin = global_role == "admin"
is_owner = user_id is not None and project.owner_id == user_id
project_role = None
if is_owner:
project_role = "manager"
elif membership:
project_role = membership.role
can_manage = is_admin or is_owner or project_role == "manager"
can_edit = can_manage or project_role == "editor"
can_delete = is_admin or is_owner
can_upload = can_edit
return {
"is_admin": is_admin,
"is_owner": is_owner,
"project_role": project_role,
"can_manage": can_manage,
"can_edit": can_edit,
"can_delete": can_delete,
"can_upload": can_upload,
}
def workshop_permissions(workshop, user_id, global_role):
is_admin = global_role in ("admin", "professor")
is_lead = user_id is not None and workshop.lead_id == user_id
can_manage = is_admin or is_lead
return {
"is_admin": global_role == "admin",
"is_lead": is_lead,
"can_manage": can_manage,
"can_delete": global_role == "admin" or is_lead,
}
def _ws_registration(db_session, workshop_id, user_id):
if not user_id:
return None
return db_session.exec(
select(WorkshopRegistration)
.where(WorkshopRegistration.workshop_id == workshop_id)
.where(WorkshopRegistration.user_id == user_id)
).first()
def _ws_session_helpers(db_session, workshop_id):
return db_session.exec(
select(WorkshopSession)
.where(WorkshopSession.workshop_id == workshop_id)
.order_by(WorkshopSession.start_time)
).all()
def _ws_count_registered(db_session, workshop_id):
regs = db_session.exec(
select(WorkshopRegistration)
.where(WorkshopRegistration.workshop_id == workshop_id)
.where(WorkshopRegistration.status.in_(("registered", "attended")))
).all()
return len(regs)
def get_all_tags(db_session):
projects = db_session.exec(select(Project)).all()
tags = set()
for p in projects:
if p.tags:
for t in p.tags.split(","):
t = t.strip().lower()
if t: tags.add(t)
return sorted(tags)
def resolve_gitea_repo(form_data, current_url=None, current_repo=None):
raw_repo = (form_data.get("gitea_repo") or "").strip() or None
raw_url = (form_data.get("gitea_url") or "").strip() or None
if raw_repo:
raw_repo = raw_repo.strip("/")
if "/" in raw_repo:
return raw_repo
if raw_url:
parsed = gitea_api.extract_repo_from_url(raw_url)
if parsed:
return parsed
return None
def get_user_subscription(db_session, user):
if not user:
return None
if not user.id and not user.email:
return None
conds = []
if user.id:
conds.append(EventSubscriber.user_id == user.id)
if user.email:
conds.append(EventSubscriber.email == user.email)
if not conds:
return None
return db_session.exec(select(EventSubscriber).where(or_(*conds))).first()
# ══════════════════════════════════════════════════════════════════════
# КОНСТАНТЫ
# ══════════════════════════════════════════════════════════════════════
STATUS_LABELS = {
"free": "Свободен / Вне расписания",
"on_place": "На месте",
"away": "Вне вуза",
"cancelled": "Отменено",
}
PROJECT_ROLE_RANK = {"viewer": 1, "editor": 2, "manager": 3}
TASK_STATUSES = ["backlog", "todo", "in_progress", "review", "done"]
TASK_STATUS_LABELS = {
"backlog": "Backlog",
"todo": "To Do",
"in_progress": "In Progress",
"review": "Review",
"done": "Done",
}
TASK_PRIORITIES = ["low", "medium", "high", "critical"]
WORKSHOP_CATEGORIES = ["soldering", "embroidery", "embedded", "linux", "other"]
WORKSHOP_CATEGORY_LABELS = {
"soldering": "Пайка",
"embroidery": "Бисероплетение",
"embedded": "Embedded",
"linux": "Linux",
"other": "Другое",
}
WORKSHOP_DIFFICULTIES = ["beginner", "intermediate", "advanced"]
WORKSHOP_DIFFICULTY_LABELS = {
"beginner": "Новичок",
"intermediate": "Средний",
"advanced": "Продвинутый",
}
WORKSHOP_STATUSES = ["draft", "published", "archived"]
WORKSHOP_STATUS_LABELS = {
"draft": "Черновик",
"published": "Опубликован",
"archived": "В архиве",
}
WORKSHOP_VISIBILITIES = ["public", "internal"]
WORKSHOP_VISIBILITY_LABELS = {
"public": "Публичный",
"internal": "Внутренний",
}
# ══════════════════════════════════════════════════════════════════════
# НАСТРОЙКА ПРИЛОЖЕНИЯ И СТАРТ
# ══════════════════════════════════════════════════════════════════════
UPLOAD_FOLDER = '/var/lib/gitea/portal-files'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
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()
@app.before_request
def validate_session():
if "session_id" in session:
with Session(engine) as db_session:
db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first()
if not db_sess:
session.clear()
return redirect(url_for("login"))
# ══════════════════════════════════════════════════════════════════════
# EMAIL + УВЕДОМЛЕНИЯ (in-app)
# ══════════════════════════════════════════════════════════════════════
def send_email(to_email, subject, body):
if not config.SMTP_SERVER:
print("\n" + "="*60, flush=True)
print(f"📧 EMAIL ДЛЯ: {to_email}", flush=True)
print(f"Тема: {subject}", flush=True)
print(body, flush=True)
print("="*60 + "\n", flush=True)
return True
try:
msg = MIMEMultipart()
msg['From'] = config.SMTP_FROM
msg['To'] = to_email
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain'))
server = smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT)
server.starttls()
server.login(config.SMTP_USER, config.SMTP_PASSWORD)
server.send_message(msg)
server.quit()
return True
except Exception as e:
print(f"Ошибка отправки email: {e}", flush=True)
return False
def notify_user(user_id, title, body=None, link_url=None, kind="info"):
if not user_id:
return
try:
with Session(engine) as s:
s.add(Notification(user_id=user_id, title=title, body=body, link_url=link_url, kind=kind))
s.commit()
except Exception as e:
print(f"[notify_user] {e}", flush=True)
def notify_users(user_ids, title, body=None, link_url=None, kind="info"):
for uid in set(uid for uid in user_ids if uid):
notify_user(uid, title, body, link_url, kind)
def _notify_subscribers_sync(subject, body, kind):
try:
with Session(engine) as db_session:
subscribers = db_session.exec(
select(EventSubscriber).where(EventSubscriber.is_confirmed == True)
).all()
for sub in subscribers:
if kind == "status" and not sub.notify_status: continue
if kind == "event" and not sub.notify_events: continue
unsubscribe_url = f"https://portal.morozovka.space/unsubscribe/{sub.unsubscribe_token}"
full_body = (
body + "\n\n---\n"
+ "Открыть расписание: https://portal.morozovka.space/schedule\n"
+ f"Отписаться: {unsubscribe_url}\n"
)
send_email(sub.email, subject, full_body)
except Exception as e:
print(f"[notify] Ошибка рассылки: {e}", flush=True)
def notify_subscribers(subject, body, kind):
threading.Thread(target=_notify_subscribers_sync, args=(subject, body, kind), daemon=True).start()
# ══════════════════════════════════════════════════════════════════════
# БАЗОВЫЕ РОУТЫ
# ══════════════════════════════════════════════════════════════════════
@app.route("/healthz")
def healthz():
try:
from sqlalchemy import text
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return jsonify({
"status": "ok",
"db": "ok",
"llm": "enabled" if llm_assistant.is_enabled() else "disabled",
})
except Exception as e:
return jsonify({"status": "error", "db": str(e)}), 503
@app.route("/")
def index():
return render_template("index.html", username=session.get("username"))
@app.route("/api/public-status")
def api_public_status():
with Session(engine) as db_session:
s = db_session.exec(select(GlobalStatus)).first()
if not s:
return jsonify({"status": "free", "delay_minutes": 0, "message": None})
return jsonify({
"status": s.status,
"delay_minutes": s.delay_minutes or 0,
"message": s.message,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
})
# ══════════════════════════════════════════════════════════════════════
# УВЕДОМЛЕНИЯ — API
# ══════════════════════════════════════════════════════════════════════
@app.route("/api/notifications")
@login_required
def api_notifications_list():
uid = session.get("user_id")
with Session(engine) as s:
items = s.exec(
select(Notification).where(Notification.user_id == uid)
.order_by(Notification.created_at.desc()).limit(20)
).all()
unread = s.exec(
select(Notification).where(Notification.user_id == uid).where(Notification.is_read == False)
).all()
return jsonify({
"items": [{
"id": n.id, "title": n.title, "body": n.body, "link_url": n.link_url,
"kind": n.kind, "is_read": n.is_read,
"created_at": n.created_at.isoformat() + "Z",
} for n in items],
"unread": len(unread),
})
@app.route("/api/notifications/count")
@login_required
def api_notifications_count():
uid = session.get("user_id")
with Session(engine) as s:
unread = s.exec(
select(Notification).where(Notification.user_id == uid).where(Notification.is_read == False)
).all()
return jsonify({"unread": len(unread)})
@app.route("/api/notifications/<int:nid>/read", methods=["POST"])
@login_required
def api_notifications_read(nid):
uid = session.get("user_id")
with Session(engine) as s:
n = s.get(Notification, nid)
if not n or n.user_id != uid:
return jsonify({"ok": False}), 403
n.is_read = True
s.add(n)
s.commit()
return jsonify({"ok": True})
@app.route("/api/notifications/read-all", methods=["POST"])
@login_required
def api_notifications_read_all():
uid = session.get("user_id")
with Session(engine) as s:
for n in s.exec(select(Notification).where(Notification.user_id == uid).where(Notification.is_read == False)).all():
n.is_read = True
s.add(n)
s.commit()
return jsonify({"ok": True})
@app.route("/notifications")
@login_required
def notifications_page():
uid = session.get("user_id")
with Session(engine) as s:
items = s.exec(
select(Notification).where(Notification.user_id == uid)
.order_by(Notification.created_at.desc()).limit(100)
).all()
return render_template("notifications_page.html", notifications=items)
# ══════════════════════════════════════════════════════════════════════
# АУТЕНТИФИКАЦИЯ
# ══════════════════════════════════════════════════════════════════════
@app.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
username = request.form.get("username")
email = request.form.get("email")
password = request.form.get("password")
full_name = request.form.get("full_name")
organization = request.form.get("organization")
with Session(engine) as db_session:
if db_session.exec(select(User).where(User.username == username)).first():
return render_template("register.html", error="Логин уже занят")
if db_session.exec(select(User).where(User.email == email)).first():
return render_template("register.html", error="Email уже используется")
new_user = User(
username=username, email=email,
password_hash=generate_password_hash(password),
full_name=full_name, organization=organization,
role="student", is_verified=True,
)
db_session.add(new_user)
db_session.commit()
return render_template("login.html", msg="Регистрация успешна. Можете войти.")
return render_template("register.html")
@app.route("/verify_email/<token>")
def verify_email(token):
try: email = serializer.loads(token, salt='email-confirm', max_age=86400)
except Exception: return "Ссылка недействительна или устарела.", 400
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.email == email)).first()
if user:
user.is_verified = True
db_session.add(user)
db_session.commit()
return redirect(url_for("login", msg="Почта подтверждена! Теперь можно войти."))
return "Пользователь не найден.", 404
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.username == username)).first()
if not user or not user.password_hash or not check_password_hash(user.password_hash, password):
return render_template("login.html", error="Неверный логин или пароль")
if not user.is_verified:
return render_template("login.html", error="Подтвердите почту перед входом")
session_token = str(uuid.uuid4())
db_session.add(UserSession(
session_token=session_token, user_id=user.id,
ip_address=request.remote_addr, user_agent=request.user_agent.string,
))
db_session.commit()
session["user_id"] = user.id
session["username"] = user.username
session["role"] = user.role
session["session_id"] = session_token
return redirect(url_for("index"))
return render_template("login.html", msg=request.args.get("msg"))
@app.route("/login/gitea")
def login_gitea():
gitea_auth_url = (
f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize"
f"?client_id={config.GITEA_CLIENT_ID}&response_type=code"
f"&redirect_uri={config.GITEA_REDIRECT_URI}"
f"&scope=read:user"
)
return redirect(gitea_auth_url)
@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_user = auth.get_gitea_user(token)
if not gitea_user: return "Ошибка профиля Gitea", 400
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first()
if not user:
user = db_session.exec(select(User).where(User.email == gitea_user.get("email"))).first()
if user:
user.gitea_id = gitea_user["id"]
else:
existing_admin = db_session.exec(select(User).where(User.role == "admin")).first()
bootstrap_role = "admin" if not existing_admin else "student"
user = User(
gitea_id=gitea_user["id"], username=gitea_user["login"],
full_name=gitea_user.get("full_name"), email=gitea_user.get("email"),
role=bootstrap_role, is_verified=True,
)
db_session.add(user)
db_session.commit()
session_token = str(uuid.uuid4())
db_session.add(UserSession(
session_token=session_token, user_id=user.id,
ip_address=request.remote_addr, user_agent=request.user_agent.string,
))
db_session.commit()
session["user_id"] = user.id
session["username"] = user.username
session["role"] = user.role
session["session_id"] = session_token
return redirect(url_for("index"))
@app.route("/logout")
def logout():
if "session_id" in session:
with Session(engine) as db_session:
db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first()
if db_sess:
db_session.delete(db_sess)
db_session.commit()
session.clear()
return redirect(url_for("index"))
@app.route("/forgot_password", methods=["GET", "POST"])
def forgot_password():
if request.method == "POST":
email = request.form.get("email")
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.email == email)).first()
if user:
token = serializer.dumps(email, salt='password-reset')
reset_url = url_for('reset_password', token=token, _external=True).replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
send_email(email, "Сброс пароля", f"Для сброса пароля перейди по ссылке:\n{reset_url}\n")
return render_template("login.html", msg="Если такой email есть, инструкция отправлена.")
return render_template("forgot_password.html")
@app.route("/reset_password/<token>", methods=["GET", "POST"])
def reset_password(token):
try: email = serializer.loads(token, salt='password-reset', max_age=3600)
except Exception: return "Ссылка недействительна или устарела.", 400
if request.method == "POST":
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.email == email)).first()
if user:
user.password_hash = generate_password_hash(request.form.get("password"))
db_session.add(user)
db_session.commit()
return redirect(url_for("login", msg="Пароль успешно изменен!"))
return render_template("reset_password.html", token=token)
@app.route("/sessions")
@login_required
def sessions_list():
with Session(engine) as db_session:
sessions = db_session.exec(
select(UserSession).where(UserSession.user_id == session.get("user_id"))
.order_by(UserSession.created_at.desc())
).all()
return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id"))
@app.route("/sessions/logout_all", methods=["POST"])
@login_required
def logout_all():
with Session(engine) as db_session:
for s in db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all():
db_session.delete(s)
db_session.commit()
session.clear()
return redirect(url_for("login", msg="Вы вышли со всех устройств."))
@app.route("/profile")
@login_required
def profile():
with Session(engine) as db_session:
user = db_session.get(User, session.get("user_id"))
subscription = get_user_subscription(db_session, user)
return render_template("profile.html", user=user, subscription=subscription)
# ══════════════════════════════════════════════════════════════════════
# ПОДПИСКИ
# ══════════════════════════════════════════════════════════════════════
@app.route("/subscribe/toggle", methods=["POST"])
@login_required
def subscribe_toggle():
action = request.form.get("action")
notify_status = request.form.get("notify_status") == "on"
notify_events = request.form.get("notify_events") == "on"
next_url = request.form.get("next") or url_for("profile")
if not next_url.startswith("/"): next_url = url_for("profile")
with Session(engine) as db_session:
user = db_session.get(User, session.get("user_id"))
if not user or not user.email:
return redirect(next_url)
existing = get_user_subscription(db_session, user)
if action == "unsubscribe":
if existing:
db_session.delete(existing)
db_session.commit()
return redirect(next_url)
if existing:
existing.email = user.email
existing.user_id = user.id
existing.notify_status = notify_status
existing.notify_events = notify_events
existing.is_confirmed = True
existing.confirm_token = None
db_session.add(existing)
else:
db_session.add(EventSubscriber(
email=user.email, user_id=user.id,
unsubscribe_token=str(uuid.uuid4()),
is_confirmed=True, confirm_token=None,
notify_status=notify_status, notify_events=notify_events,
))
db_session.commit()
return redirect(next_url)
@app.route("/subscribe/confirm/<token>")
def subscribe_confirm(token):
with Session(engine) as db_session:
sub = db_session.exec(select(EventSubscriber).where(EventSubscriber.confirm_token == token)).first()
if not sub:
return render_template("subscribe_status.html", title="Ссылка недействительна",
message="Возможно, ссылка уже использована или устарела.", ok=False)
sub.is_confirmed = True
sub.confirm_token = None
db_session.add(sub)
db_session.commit()
return render_template("subscribe_status.html", title="Подписка подтверждена!",
message=f"Вы будете получать уведомления на {sub.email}.",
ok=True, unsubscribe_token=sub.unsubscribe_token)
@app.route("/unsubscribe/<token>", methods=["GET", "POST"])
def unsubscribe(token):
with Session(engine) as db_session:
sub = db_session.exec(select(EventSubscriber).where(EventSubscriber.unsubscribe_token == token)).first()
if not sub:
return render_template("subscribe_status.html", title="Ссылка недействительна",
message="Такой подписки нет.", ok=False)
if request.method == "POST":
db_session.delete(sub)
db_session.commit()
return render_template("subscribe_status.html", title="Вы отписаны",
message="Больше не будет присылать уведомления.", ok=True)
return render_template("subscribe_status.html", title="Отписаться от уведомлений?",
message=f"Вы перестанете получать письма на {sub.email}.",
ok=True, show_confirm_form=True,
confirm_action=url_for("unsubscribe", token=token))
# ══════════════════════════════════════════════════════════════════════
# РАСПИСАНИЕ
# ══════════════════════════════════════════════════════════════════════
@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()
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 to regex: {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:
current_status = db_session.exec(select(GlobalStatus)).first()
if current_status:
old_label = status_label(current_status.status, current_status.delay_minutes)
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()
new_label = status_label(status_val, delay)
subject = f"[Расписание] Статус: {new_label}"
body = f"Новый статус: {new_label}\nБыло: {old_label}\n"
if message: body += f"Сообщение: {message}\n"
body += f"Обновлено: {datetime.now(MSK).strftime('%d.%m.%Y %H:%M')}\n"
notify_subscribers(subject, body, kind="status")
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_time = parse_datetime(request.form.get("start_time"))
end_time = parse_datetime(request.form.get("end_time"))
with Session(engine) as db_session:
ev = Event(title=title, event_type=event_type, start_time=start_time, end_time=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:
title_snapshot = event.title
start_snapshot = event.start_time
was_public = event.is_public
db_session.delete(event)
db_session.commit()
if was_public:
body = (
f"Событие отменено/удалено из расписания.\n\n"
f"Название: {title_snapshot}\n"
f"Было запланировано: {to_msk(start_snapshot).strftime('%d.%m.%Y %H:%M')}\n"
)
notify_subscribers(f"[Расписание] Удалено: {title_snapshot}", 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 file.filename == '': return "Файл не выбран", 400
if file and file.filename.endswith('.ics'):
try:
cal = Calendar.from_ical(file.read())
count = 0
with Session(engine) as db_session:
for component in cal.walk():
if component.name == "VEVENT":
summary = str(component.get('summary', 'Без названия'))
dtstart_prop = component.get('dtstart')
dtend_prop = component.get('dtend')
if not dtstart_prop or not dtend_prop: continue
dtstart = dtstart_prop.dt
dtend = dtend_prop.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)
new_event = Event(
title=summary, event_type='lecture',
start_time=dtstart.astimezone(timezone.utc),
end_time=dtend.astimezone(timezone.utc),
location=str(component.get('location', '')),
description=str(component.get('description', '')),
is_public=True
)
db_session.add(new_event)
count += 1
db_session.commit()
if count > 0:
notify_subscribers(f"[Расписание] Импортировано событий: {count}",
f"В расписание добавлено событий: {count}.\n", kind="event")
return redirect(url_for("admin_schedule"))
except Exception as e:
return f"Ошибка при чтении файла .ics: {e}", 400
return render_template("admin_schedule_import.html")
# ══════════════════════════════════════════════════════════════════════
# ПРОЕКТЫ
# ══════════════════════════════════════════════════════════════════════
@app.route("/projects")
def projects_list():
user_role = session.get("role")
user_id = session.get("user_id")
current_tag = (request.args.get("tag") or "").strip().lower()
current_status = (request.args.get("status") or "").strip()
with Session(engine) as db_session:
all_projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all()
member_map = {}
if user_id:
for m in db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all():
member_map[m.project_id] = m.role
all_tags = get_all_tags(db_session)
filtered = []
for p in all_projects:
if p.visibility == "public":
filtered.append(p)
elif p.visibility == "internal" and user_id:
filtered.append(p)
elif p.visibility == "private" and user_id and (p.owner_id == user_id or p.id in member_map or user_role == "admin"):
filtered.append(p)
if current_status:
filtered = [p for p in filtered if p.status == current_status]
if current_tag:
def has_tag(p):
if not p.tags: return False
return current_tag in [t.strip().lower() for t in p.tags.split(",")]
filtered = [p for p in filtered if has_tag(p)]
return render_template("projects.html", projects=filtered, role=user_role, user_id=user_id,
member_map=member_map, all_tags=all_tags,
current_tag=current_tag, current_status=current_status)
@app.route("/projects/new", methods=["GET", "POST"])
@require_role(["admin", "professor", "lead"])
def project_new():
if request.method == "POST":
with Session(engine) as db_session:
new_proj = Project(
title=request.form.get("title"),
description=request.form.get("description"),
status=request.form.get("status"),
visibility=request.form.get("visibility"),
gitea_url=(request.form.get("gitea_url") or "").strip() or None,
gitea_repo=resolve_gitea_repo(request.form),
tags=normalize_tags(request.form.get("tags")),
start_date=(request.form.get("start_date") or "").strip() or None,
end_date=(request.form.get("end_date") or "").strip() or None,
contact_person=(request.form.get("contact_person") or "").strip() or None,
owner_id=session.get("user_id"),
)
db_session.add(new_proj)
db_session.flush()
proj_id = new_proj.id
db_session.add(ProjectMember(project_id=proj_id, user_id=session.get("user_id"), role="manager"))
db_session.commit()
return redirect(url_for("project_detail", project_id=proj_id))
return render_template("project_form.html", project=None, is_edit=False)
@app.route("/projects/<int:project_id>")
def project_detail(project_id):
user_id = session.get("user_id")
user_role = session.get("role")
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = None
if user_id:
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)
).first()
perms = project_permissions(proj, user_id, user_role, membership)
if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership:
return "403: Это приватный проект", 403
if proj.visibility == "internal" and not user_id:
return redirect(url_for("login"))
members = db_session.exec(
select(ProjectMember, User).join(User, ProjectMember.user_id == User.id)
.where(ProjectMember.project_id == project_id).order_by(User.username)
).all()
files = db_session.exec(
select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id)
.where(PortalFile.project_id == project_id).order_by(PortalFile.uploaded_at.desc())
).all()
task_counts = {s: 0 for s in TASK_STATUSES}
for t in db_session.exec(select(ProjectTask).where(ProjectTask.project_id == project_id)).all():
if t.status in task_counts: task_counts[t.status] += 1
return render_template("project_detail.html", project=proj, members=members, files=files,
perms=perms, user_id=user_id, role=user_role,
active_tab="overview", task_counts=task_counts)
@app.route("/projects/<int:project_id>/gitea-stats")
def project_gitea_stats(project_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj:
return jsonify({"ok": False, "error": "not_found", "message": "Проект не найден"}), 404
user_id = session.get("user_id")
user_role = session.get("role")
membership = None
if user_id:
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)
).first()
perms = project_permissions(proj, user_id, user_role, membership)
if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership:
return jsonify({"ok": False, "error": "forbidden"}), 403
repo = proj.gitea_repo
if not repo and proj.gitea_url:
repo = gitea_api.extract_repo_from_url(proj.gitea_url)
if not repo:
return jsonify({"ok": False, "error": "no_repo", "message": "У проекта не указан репозиторий."})
return jsonify(gitea_api.get_project_stats(repo))
@app.route("/projects/<int:project_id>/edit", methods=["GET", "POST"])
@login_required
def project_edit(project_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))
).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_manage"]:
return "403: У вас нет прав на редактирование проекта", 403
if request.method == "POST":
proj.title = request.form.get("title")
proj.description = request.form.get("description")
proj.status = request.form.get("status")
proj.visibility = request.form.get("visibility")
proj.gitea_url = (request.form.get("gitea_url") or "").strip() or None
proj.gitea_repo = resolve_gitea_repo(request.form, proj.gitea_url, proj.gitea_repo)
proj.tags = normalize_tags(request.form.get("tags"))
proj.start_date = (request.form.get("start_date") or "").strip() or None
proj.end_date = (request.form.get("end_date") or "").strip() or None
proj.contact_person = (request.form.get("contact_person") or "").strip() or None
db_session.add(proj)
db_session.commit()
return redirect(url_for("project_detail", project_id=proj.id))
return render_template("project_form.html", project=proj, is_edit=True)
@app.route("/projects/<int:project_id>/members/add", methods=["POST"])
@login_required
def project_add_member(project_id):
username = (request.form.get("username") or "").strip()
role = request.form.get("role") or "viewer"
if role not in PROJECT_ROLE_RANK: role = "viewer"
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))
).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_manage"]: return "403: Нет прав", 403
user_to_add = db_session.exec(select(User).where(User.username == username)).first()
if not user_to_add: return redirect(url_for("project_detail", project_id=project_id))
existing = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_to_add.id)
).first()
if not existing:
db_session.add(ProjectMember(project_id=project_id, user_id=user_to_add.id, role=role))
db_session.commit()
notify_user(user_to_add.id,
f"Вас добавили в проект «{proj.title}»",
f"Роль: {role}",
link_url=url_for("project_detail", project_id=project_id),
kind="info")
return redirect(url_for("project_detail", project_id=project_id))
@app.route("/projects/<int:project_id>/members/<int:user_id>/role", methods=["POST"])
@login_required
def project_change_member_role(project_id, user_id):
new_role = request.form.get("role") or "viewer"
if new_role not in PROJECT_ROLE_RANK: return "Ошибка: неверная роль", 400
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
my_membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))
).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), my_membership)
if not perms["can_manage"]: return "403: Нет прав", 403
if user_id == proj.owner_id: return "Нельзя менять роль владельца", 400
target = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)
).first()
if target:
target.role = new_role
db_session.add(target)
db_session.commit()
return redirect(url_for("project_detail", project_id=project_id))
@app.route("/projects/<int:project_id>/members/<int:user_id>/remove", methods=["POST"])
@login_required
def project_remove_member(project_id, user_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
my_membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))
).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), my_membership)
if not perms["can_manage"]: return "403: Нет прав", 403
if user_id == proj.owner_id: return "Нельзя удалить владельца", 400
to_remove = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)
).first()
if to_remove:
db_session.delete(to_remove)
db_session.commit()
return redirect(url_for("project_detail", project_id=project_id))
@app.route("/projects/<int:project_id>/delete", methods=["POST"])
@login_required
def project_delete(project_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj:
print(f"[project_delete] Проект {project_id} не найден", flush=True)
return redirect(url_for("projects_list"))
user_id = session.get("user_id")
user_role = session.get("role")
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)
).first()
perms = project_permissions(proj, user_id, user_role, membership)
if not perms["can_delete"]:
return f"403: Удалять проект может только владелец (id={proj.owner_id}) или админ. Вы — id={user_id}, роль={user_role}.", 403
try:
files = db_session.exec(select(PortalFile).where(PortalFile.project_id == project_id)).all()
for f in files:
f.project_id = None
db_session.add(f)
db_session.flush()
tasks = db_session.exec(select(ProjectTask).where(ProjectTask.project_id == project_id)).all()
for t in tasks:
db_session.delete(t)
db_session.flush()
members = db_session.exec(select(ProjectMember).where(ProjectMember.project_id == project_id)).all()
for m in members:
db_session.delete(m)
db_session.flush()
db_session.delete(proj)
db_session.commit()
except Exception as e:
db_session.rollback()
print(f"[project_delete] ОШИБКА при удалении {project_id}: {e}", flush=True)
import traceback
traceback.print_exc()
return f"Ошибка удаления: {e}", 500
return redirect(url_for("projects_list"))
@app.route("/projects/<int:project_id>/files/upload", methods=["POST"])
@login_required
def project_file_upload(project_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))
).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_upload"]:
return "403: Нет прав на загрузку файлов", 403
try:
file = request.files.get('file')
except Exception as e:
print(f"[project_file_upload] Ошибка парсинга: {e}", flush=True)
return "Ошибка разбора запроса", 400
if not file or file.filename == '':
return redirect(url_for("project_detail", project_id=project_id))
original_name = file.filename
ext = os.path.splitext(original_name)[1]
stored_name = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name)
try:
file.save(filepath)
size_bytes = os.path.getsize(filepath)
except Exception as e:
print(f"[project_file_upload] Ошибка сохранения: {e}", flush=True)
return "Ошибка сохранения файла", 500
try:
with Session(engine) as db_session:
db_session.add(PortalFile(
original_name=original_name, stored_name=stored_name,
size_bytes=size_bytes, uploaded_by=session.get("user_id"),
project_id=project_id,
))
db_session.commit()
except Exception as e:
print(f"[project_file_upload] Ошибка БД: {e}", flush=True)
try: os.remove(filepath)
except Exception: pass
return "Ошибка записи в БД", 500
return redirect(url_for("project_detail", project_id=project_id))
# ══════════════════════════════════════════════════════════════════════
# КАНБАН
# ══════════════════════════════════════════════════════════════════════
@app.route("/projects/<int:project_id>/board")
def project_board(project_id):
user_id = session.get("user_id")
user_role = session.get("role")
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = None
if user_id:
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == user_id)
).first()
perms = project_permissions(proj, user_id, user_role, membership)
if proj.visibility == "private" and not perms["is_admin"] and not perms["is_owner"] and not membership:
return "403: Это приватный проект", 403
if proj.visibility == "internal" and not user_id:
return redirect(url_for("login"))
members = db_session.exec(
select(ProjectMember, User).join(User, ProjectMember.user_id == User.id)
.where(ProjectMember.project_id == project_id).order_by(User.username)
).all()
tasks = db_session.exec(
select(ProjectTask).where(ProjectTask.project_id == project_id)
.order_by(ProjectTask.position, ProjectTask.id)
).all()
user_ids = set()
for t in tasks:
if t.assignee_id: user_ids.add(t.assignee_id)
if t.created_by: user_ids.add(t.created_by)
users_map = {}
if user_ids:
for u in db_session.exec(select(User).where(User.id.in_(user_ids))).all():
users_map[u.id] = u
columns = {s: [] for s in TASK_STATUSES}
for t in tasks:
if t.status in columns:
columns[t.status].append({
"task": t,
"assignee": users_map.get(t.assignee_id) if t.assignee_id else None,
"author": users_map.get(t.created_by),
})
task_counts = {s: len(columns[s]) for s in TASK_STATUSES}
return render_template("project_board.html",
project=proj, members=members, columns=columns,
perms=perms, user_id=user_id, role=user_role,
active_tab="board", task_counts=task_counts,
TASK_STATUSES=TASK_STATUSES, TASK_STATUS_LABELS=TASK_STATUS_LABELS,
TASK_PRIORITIES=TASK_PRIORITIES)
@app.route("/projects/<int:project_id>/tasks/new", methods=["POST"])
@login_required
def task_new(project_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))
).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_edit"]:
return "403: Нет прав", 403
title = (request.form.get("title") or "").strip()
if not title:
return redirect(url_for("project_board", project_id=project_id))
status = request.form.get("status") or "backlog"
if status not in TASK_STATUSES: status = "backlog"
priority = request.form.get("priority") or "medium"
if priority not in TASK_PRIORITIES: priority = "medium"
assignee_id = request.form.get("assignee_id")
assignee_id = int(assignee_id) if assignee_id and assignee_id.isdigit() else None
max_pos = db_session.exec(
select(ProjectTask).where(ProjectTask.project_id == project_id).where(ProjectTask.status == status)
).all()
position = max([t.position for t in max_pos], default=-1) + 1
task = ProjectTask(
project_id=project_id, title=title,
description=(request.form.get("description") or "").strip() or None,
status=status, priority=priority, assignee_id=assignee_id,
created_by=session.get("user_id"),
due_date=(request.form.get("due_date") or "").strip() or None,
position=position,
)
db_session.add(task)
db_session.commit()
if assignee_id and assignee_id != session.get("user_id"):
notify_user(assignee_id,
f"Вам назначена задача: {title}",
f"Проект: {proj.title}",
link_url=url_for("project_board", project_id=project_id),
kind="info")
return redirect(url_for("project_board", project_id=project_id))
@app.route("/projects/<int:project_id>/tasks/<int:task_id>/edit", methods=["GET", "POST"])
@login_required
def task_edit(project_id, task_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))
).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_edit"]:
return "403: Нет прав", 403
task = db_session.get(ProjectTask, task_id)
if not task or task.project_id != project_id:
return "Задача не найдена", 404
if request.method == "POST":
old_assignee = task.assignee_id
task.title = (request.form.get("title") or task.title).strip()
task.description = (request.form.get("description") or "").strip() or None
new_status = request.form.get("status") or task.status
if new_status in TASK_STATUSES: task.status = new_status
new_priority = request.form.get("priority") or task.priority
if new_priority in TASK_PRIORITIES: task.priority = new_priority
assignee = request.form.get("assignee_id")
new_assignee = int(assignee) if assignee and assignee.isdigit() else None
task.assignee_id = new_assignee
task.due_date = (request.form.get("due_date") or "").strip() or None
task.updated_at = datetime.now(timezone.utc)
db_session.add(task)
db_session.commit()
if new_assignee and new_assignee != old_assignee and new_assignee != session.get("user_id"):
notify_user(new_assignee,
f"Вам назначена задача: {task.title}",
f"Проект: {proj.title}",
link_url=url_for("project_board", project_id=project_id),
kind="info")
return redirect(url_for("project_board", project_id=project_id))
members = db_session.exec(
select(ProjectMember, User).join(User, ProjectMember.user_id == User.id)
.where(ProjectMember.project_id == project_id).order_by(User.username)
).all()
return render_template("task_edit.html", project=proj, task=task, members=members,
TASK_STATUSES=TASK_STATUSES, TASK_STATUS_LABELS=TASK_STATUS_LABELS,
TASK_PRIORITIES=TASK_PRIORITIES)
@app.route("/projects/<int:project_id>/tasks/<int:task_id>/delete", methods=["POST"])
@login_required
def task_delete(project_id, task_id):
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return "Проект не найден", 404
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))
).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_manage"]:
return "403: Удалять задачи может только менеджер проекта", 403
task = db_session.get(ProjectTask, task_id)
if task and task.project_id == project_id:
db_session.delete(task)
db_session.commit()
return redirect(url_for("project_board", project_id=project_id))
@app.route("/projects/<int:project_id>/tasks/<int:task_id>/move", methods=["POST"])
@login_required
def task_move(project_id, task_id):
data = request.get_json(silent=True) or {}
new_status = data.get("status")
new_position = data.get("position", 0)
if new_status not in TASK_STATUSES:
return jsonify({"error": "invalid status"}), 400
with Session(engine) as db_session:
proj = db_session.get(Project, project_id)
if not proj: return jsonify({"error": "project not found"}), 404
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == project_id).where(ProjectMember.user_id == session.get("user_id"))
).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if not perms["can_edit"]:
return jsonify({"error": "no permission"}), 403
task = db_session.get(ProjectTask, task_id)
if not task or task.project_id != project_id:
return jsonify({"error": "task not found"}), 404
old_status = task.status
siblings = db_session.exec(
select(ProjectTask)
.where(ProjectTask.project_id == project_id)
.where(ProjectTask.status == new_status)
.where(ProjectTask.id != task_id)
.order_by(ProjectTask.position, ProjectTask.id)
).all()
siblings.insert(max(0, min(int(new_position), len(siblings))), task)
for i, t in enumerate(siblings):
t.position = i
if t.id == task_id:
t.status = new_status
t.updated_at = datetime.now(timezone.utc)
db_session.add(t)
db_session.flush()
if old_status != new_status:
olds = db_session.exec(
select(ProjectTask)
.where(ProjectTask.project_id == project_id)
.where(ProjectTask.status == old_status)
.order_by(ProjectTask.position, ProjectTask.id)
).all()
for i, t in enumerate(olds):
t.position = i
db_session.add(t)
db_session.commit()
return jsonify({"ok": True})
# ══════════════════════════════════════════════════════════════════════
# МАСТЕР-КЛАССЫ
# ══════════════════════════════════════════════════════════════════════
@app.route("/workshops")
def workshops_list():
user_id = session.get("user_id")
user_role = session.get("role")
current_cat = (request.args.get("category") or "").strip()
current_status = (request.args.get("status") or "").strip()
with Session(engine) as db_session:
all_ws = db_session.exec(select(Workshop).order_by(Workshop.created_at.desc())).all()
visible = []
for w in all_ws:
if w.status == "draft" and not (user_role in ("admin", "professor") or w.lead_id == user_id):
continue
if w.visibility == "internal" and not user_id:
continue
visible.append(w)
reg_counts = {}
for w in visible:
reg_counts[w.id] = _ws_count_registered(db_session, w.id)
next_sessions = {}
now = datetime.now(timezone.utc)
for w in visible:
sessions = _ws_session_helpers(db_session, w.id)
future = [s for s in sessions if s.start_time >= now]
next_sessions[w.id] = future[0] if future else (sessions[-1] if sessions else None)
lead_ids = {w.lead_id for w in visible}
leads_map = {}
if lead_ids:
for u in db_session.exec(select(User).where(User.id.in_(lead_ids))).all():
leads_map[u.id] = u
filtered = visible
if current_cat:
filtered = [w for w in filtered if w.category == current_cat]
if current_status:
filtered = [w for w in filtered if w.status == current_status]
can_create = user_role in ("admin", "professor", "lead")
return render_template("workshops.html",
workshops=filtered,
reg_counts=reg_counts,
next_sessions=next_sessions,
leads_map=leads_map,
current_cat=current_cat,
current_status=current_status,
can_create=can_create,
WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES,
WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS,
WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS,
WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS,
WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS)
@app.route("/workshops/new", methods=["GET", "POST"])
@require_role(["admin", "professor", "lead"])
def workshop_new():
if request.method == "POST":
with Session(engine) as db_session:
ws = Workshop(
title=request.form.get("title"),
description=request.form.get("description"),
program=request.form.get("program"),
lead_id=session.get("user_id"),
category=request.form.get("category") or "other",
difficulty=request.form.get("difficulty") or "beginner",
partner=(request.form.get("partner") or "").strip() or None,
status=request.form.get("status") or "draft",
visibility=request.form.get("visibility") or "public",
location=(request.form.get("location") or "").strip() or None,
materials_required=request.form.get("materials_required"),
outcome=request.form.get("outcome"),
has_certificate=request.form.get("has_certificate") == "on",
max_participants=int(request.form.get("max_participants")) if (request.form.get("max_participants") or "").strip().isdigit() else None,
cover_url=(request.form.get("cover_url") or "").strip() or None,
tags=normalize_tags(request.form.get("tags")),
)
db_session.add(ws)
db_session.flush()
ws_id = ws.id
db_session.commit()
return redirect(url_for("workshop_detail", workshop_id=ws_id))
return render_template("workshop_form.html", workshop=None, is_edit=False,
WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES,
WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS,
WORKSHOP_DIFFICULTIES=WORKSHOP_DIFFICULTIES,
WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS,
WORKSHOP_STATUSES=WORKSHOP_STATUSES,
WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS,
WORKSHOP_VISIBILITIES=WORKSHOP_VISIBILITIES,
WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS)
@app.route("/workshops/<int:workshop_id>")
def workshop_detail(workshop_id):
user_id = session.get("user_id")
user_role = session.get("role")
with Session(engine) as db_session:
ws = db_session.get(Workshop, workshop_id)
if not ws: return "Мастер-класс не найден", 404
perms = workshop_permissions(ws, user_id, user_role)
if ws.status == "draft" and not perms["can_manage"]:
return "403: Черновик доступен только ведущему", 403
if ws.visibility == "internal" and not user_id:
return redirect(url_for("login"))
lead = db_session.get(User, ws.lead_id)
sessions = _ws_session_helpers(db_session, workshop_id)
registration = _ws_registration(db_session, workshop_id, user_id)
is_registered = registration is not None and registration.status in ("registered", "attended")
registered_count = _ws_count_registered(db_session, workshop_id)
is_full = ws.max_participants is not None and registered_count >= ws.max_participants
materials = db_session.exec(
select(WorkshopMaterial).where(WorkshopMaterial.workshop_id == workshop_id)
.order_by(WorkshopMaterial.created_at.desc())
).all()
visible_materials = []
for m in materials:
if m.visible_to == "registered" and not (is_registered or perms["can_manage"]):
continue
visible_materials.append(m)
participants = []
if perms["can_manage"]:
regs = db_session.exec(
select(WorkshopRegistration, User)
.join(User, WorkshopRegistration.user_id == User.id)
.where(WorkshopRegistration.workshop_id == workshop_id)
.order_by(WorkshopRegistration.registered_at)
).all()
participants = regs
material_files_map = {}
file_ids = [m.file_id for m in visible_materials if m.file_id]
if file_ids:
for f in db_session.exec(select(PortalFile).where(PortalFile.id.in_(file_ids))).all():
material_files_map[f.id] = f
return render_template("workshop_detail.html",
workshop=ws, lead=lead, sessions=sessions,
perms=perms, user_id=user_id, role=user_role,
registration=registration, is_registered=is_registered,
registered_count=registered_count, is_full=is_full,
materials=visible_materials,
material_files_map=material_files_map,
participants=participants,
WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS,
WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS,
WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS,
WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS)
@app.route("/workshops/<int:workshop_id>/edit", methods=["GET", "POST"])
@login_required
def workshop_edit(workshop_id):
with Session(engine) as db_session:
ws = db_session.get(Workshop, workshop_id)
if not ws: return "Мастер-класс не найден", 404
perms = workshop_permissions(ws, session.get("user_id"), session.get("role"))
if not perms["can_manage"]:
return "403: Нет прав на редактирование", 403
if request.method == "POST":
ws.title = request.form.get("title")
ws.description = request.form.get("description")
ws.program = request.form.get("program")
ws.category = request.form.get("category") or "other"
ws.difficulty = request.form.get("difficulty") or "beginner"
ws.partner = (request.form.get("partner") or "").strip() or None
ws.status = request.form.get("status") or "draft"
ws.visibility = request.form.get("visibility") or "public"
ws.location = (request.form.get("location") or "").strip() or None
ws.materials_required = request.form.get("materials_required")
ws.outcome = request.form.get("outcome")
ws.has_certificate = request.form.get("has_certificate") == "on"
max_p = request.form.get("max_participants")
ws.max_participants = int(max_p) if (max_p or "").strip().isdigit() else None
ws.cover_url = (request.form.get("cover_url") or "").strip() or None
ws.tags = normalize_tags(request.form.get("tags"))
db_session.add(ws)
db_session.commit()
return redirect(url_for("workshop_detail", workshop_id=ws.id))
return render_template("workshop_form.html", workshop=ws, is_edit=True,
WORKSHOP_CATEGORIES=WORKSHOP_CATEGORIES,
WORKSHOP_CATEGORY_LABELS=WORKSHOP_CATEGORY_LABELS,
WORKSHOP_DIFFICULTIES=WORKSHOP_DIFFICULTIES,
WORKSHOP_DIFFICULTY_LABELS=WORKSHOP_DIFFICULTY_LABELS,
WORKSHOP_STATUSES=WORKSHOP_STATUSES,
WORKSHOP_STATUS_LABELS=WORKSHOP_STATUS_LABELS,
WORKSHOP_VISIBILITIES=WORKSHOP_VISIBILITIES,
WORKSHOP_VISIBILITY_LABELS=WORKSHOP_VISIBILITY_LABELS)
@app.route("/workshops/<int:workshop_id>/delete", methods=["POST"])
@login_required
def workshop_delete(workshop_id):
with Session(engine) as db_session:
ws = db_session.get(Workshop, workshop_id)
if not ws: return redirect(url_for("workshops_list"))
perms = workshop_permissions(ws, session.get("user_id"), session.get("role"))
if not perms["can_delete"]:
return "403: Удалять мастер-класс может только ведущий или админ", 403
db_session.delete(ws)
db_session.commit()
return redirect(url_for("workshops_list"))
@app.route("/workshops/<int:workshop_id>/sessions/add", methods=["POST"])
@login_required
def workshop_session_add(workshop_id):
with Session(engine) as db_session:
ws = db_session.get(Workshop, workshop_id)
if not ws: return "Мастер-класс не найден", 404
perms = workshop_permissions(ws, session.get("user_id"), session.get("role"))
if not perms["can_manage"]: return "403: Нет прав", 403
start = parse_datetime(request.form.get("start_time"))
end = parse_datetime(request.form.get("end_time"))
if start and end:
db_session.add(WorkshopSession(
workshop_id=workshop_id,
start_time=start, end_time=end,
location_override=(request.form.get("location_override") or "").strip() or None,
note=(request.form.get("note") or "").strip() or None,
))
db_session.commit()
return redirect(url_for("workshop_detail", workshop_id=workshop_id))
@app.route("/workshops/<int:workshop_id>/sessions/<int:session_id>/delete", methods=["POST"])
@login_required
def workshop_session_delete(workshop_id, session_id):
with Session(engine) as db_session:
ws = db_session.get(Workshop, workshop_id)
if not ws: return redirect(url_for("workshops_list"))
perms = workshop_permissions(ws, session.get("user_id"), session.get("role"))
if not perms["can_manage"]: return "403: Нет прав", 403
s = db_session.get(WorkshopSession, session_id)
if s and s.workshop_id == workshop_id:
db_session.delete(s)
db_session.commit()
return redirect(url_for("workshop_detail", workshop_id=workshop_id))
@app.route("/workshops/<int:workshop_id>/materials/add", methods=["POST"])
@login_required
def workshop_material_add(workshop_id):
with Session(engine) as db_session:
ws = db_session.get(Workshop, workshop_id)
if not ws: return "Мастер-класс не найден", 404
perms = workshop_permissions(ws, session.get("user_id"), session.get("role"))
if not perms["can_manage"]: return "403: Нет прав", 403
title = (request.form.get("title") or "").strip()
if title:
db_session.add(WorkshopMaterial(
workshop_id=workshop_id, title=title,
description=(request.form.get("description") or "").strip() or None,
external_url=(request.form.get("external_url") or "").strip() or None,
visible_to=request.form.get("visible_to") or "registered",
))
db_session.commit()
regs = db_session.exec(
select(WorkshopRegistration).where(WorkshopRegistration.workshop_id == workshop_id)
.where(WorkshopRegistration.status.in_(("registered", "attended")))
).all()
recipients = [r.user_id for r in regs if r.user_id != session.get("user_id")]
if recipients:
notify_users(recipients,
f"Новый материал в «{ws.title}»",
title,
link_url=url_for("workshop_detail", workshop_id=workshop_id),
kind="info")
return redirect(url_for("workshop_detail", workshop_id=workshop_id))
@app.route("/workshops/<int:workshop_id>/materials/<int:material_id>/delete", methods=["POST"])
@login_required
def workshop_material_delete(workshop_id, material_id):
with Session(engine) as db_session:
ws = db_session.get(Workshop, workshop_id)
if not ws: return redirect(url_for("workshops_list"))
perms = workshop_permissions(ws, session.get("user_id"), session.get("role"))
if not perms["can_manage"]: return "403: Нет прав", 403
m = db_session.get(WorkshopMaterial, material_id)
if m and m.workshop_id == workshop_id:
db_session.delete(m)
db_session.commit()
return redirect(url_for("workshop_detail", workshop_id=workshop_id))
@app.route("/workshops/<int:workshop_id>/register", methods=["POST"])
@login_required
def workshop_register(workshop_id):
with Session(engine) as db_session:
ws = db_session.get(Workshop, workshop_id)
if not ws: return "Мастер-класс не найден", 404
if ws.status != "published":
return "Регистрация доступна только для опубликованных", 400
existing = _ws_registration(db_session, workshop_id, session.get("user_id"))
if existing and existing.status in ("registered", "attended"):
return redirect(url_for("workshop_detail", workshop_id=workshop_id))
if ws.max_participants is not None:
count = _ws_count_registered(db_session, workshop_id)
if count >= ws.max_participants:
return redirect(url_for("workshop_detail", workshop_id=workshop_id))
if existing:
existing.status = "registered"
existing.registered_at = datetime.now(timezone.utc)
db_session.add(existing)
else:
db_session.add(WorkshopRegistration(
workshop_id=workshop_id, user_id=session.get("user_id"),
status="registered",
))
db_session.commit()
if ws.lead_id != session.get("user_id"):
notify_user(ws.lead_id,
f"Новая запись на «{ws.title}»",
f"Пользователь записался на ваш мастер-класс.",
link_url=url_for("workshop_detail", workshop_id=workshop_id),
kind="success")
return redirect(url_for("workshop_detail", workshop_id=workshop_id))
@app.route("/workshops/<int:workshop_id>/unregister", methods=["POST"])
@login_required
def workshop_unregister(workshop_id):
with Session(engine) as db_session:
ws = db_session.get(Workshop, workshop_id)
existing = _ws_registration(db_session, workshop_id, session.get("user_id"))
if existing:
existing.status = "cancelled"
db_session.add(existing)
db_session.commit()
if ws and ws.lead_id != session.get("user_id"):
notify_user(ws.lead_id,
f"Отмена записи на «{ws.title}»",
"Пользователь отменил запись на ваш мастер-класс.",
link_url=url_for("workshop_detail", workshop_id=workshop_id),
kind="warning")
return redirect(url_for("workshop_detail", workshop_id=workshop_id))
# ══════════════════════════════════════════════════════════════════════
# ФАЙЛЫ
# ══════════════════════════════════════════════════════════════════════
@app.route("/files")
@login_required
def files_list():
with Session(engine) as db_session:
files = db_session.exec(
select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id)
.order_by(PortalFile.uploaded_at.desc())
).all()
projects_map = {p.id: p.title for p in db_session.exec(select(Project)).all()}
return render_template("files.html", files=files, projects_map=projects_map)
@app.route("/files/upload", methods=["POST"])
@login_required
def file_upload():
try:
file = request.files.get('file')
except Exception as e:
print(f"[file_upload] Ошибка парсинга: {e}", flush=True)
return "Ошибка разбора запроса", 400
if not file or file.filename == '':
return redirect(url_for('files_list'))
original_name = file.filename
ext = os.path.splitext(original_name)[1]
stored_name = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name)
try:
file.save(filepath)
size_bytes = os.path.getsize(filepath)
except Exception as e:
print(f"[file_upload] Ошибка сохранения: {e}", flush=True)
return "Ошибка сохранения файла", 500
try:
with Session(engine) as db_session:
db_session.add(PortalFile(
original_name=original_name, stored_name=stored_name,
size_bytes=size_bytes, uploaded_by=session.get("user_id"),
))
db_session.commit()
except Exception as e:
print(f"[file_upload] Ошибка БД: {e}", flush=True)
try: os.remove(filepath)
except Exception: pass
return "Ошибка записи в БД", 500
return redirect(url_for('files_list'))
@app.route("/files/download/<int:file_id>")
@login_required
def file_download(file_id):
with Session(engine) as db_session:
db_file = db_session.get(PortalFile, file_id)
if db_file:
return send_from_directory(app.config['UPLOAD_FOLDER'], db_file.stored_name, as_attachment=True, download_name=db_file.original_name)
return "Файл не найден", 404
@app.route("/files/<int:file_id>/delete", methods=["POST"])
@login_required
def file_delete(file_id):
next_url = request.form.get("next") or "/files"
if not next_url.startswith("/"): next_url = "/files"
with Session(engine) as db_session:
db_file = db_session.get(PortalFile, file_id)
if not db_file: return redirect(next_url)
allowed = session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id")
if not allowed and db_file.project_id:
proj = db_session.get(Project, db_file.project_id)
if proj:
membership = db_session.exec(
select(ProjectMember).where(ProjectMember.project_id == db_file.project_id).where(ProjectMember.user_id == session.get("user_id"))
).first()
perms = project_permissions(proj, session.get("user_id"), session.get("role"), membership)
if perms["can_manage"]: allowed = True
if allowed:
filepath = os.path.join(app.config['UPLOAD_FOLDER'], db_file.stored_name)
if os.path.exists(filepath): os.remove(filepath)
db_session.delete(db_file)
db_session.commit()
return redirect(next_url)
# ══════════════════════════════════════════════════════════════════════
# АДМИНКА: ПОЛЬЗОВАТЕЛИ
# ══════════════════════════════════════════════════════════════════════
@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("/admin/users/<int:user_id>/delete", methods=["POST"])
@require_role("admin")
def delete_user(user_id):
if user_id == session.get("user_id"): return "Нельзя удалить себя", 400
with Session(engine) as db_session:
user = db_session.get(User, user_id)
if not user:
return redirect(url_for("admin_users"))
owned_projects = db_session.exec(select(Project).where(Project.owner_id == user_id)).all()
uploaded_files = db_session.exec(select(PortalFile).where(PortalFile.uploaded_by == user_id)).all()
if owned_projects or uploaded_files:
return (f"Нельзя удалить {user.username}: у него {len(owned_projects)} проект(ов) "
f"и {len(uploaded_files)} файл(ов)."), 400
owned_workshops = db_session.exec(select(Workshop).where(Workshop.lead_id == user_id)).all()
if owned_workshops:
return (f"Нельзя удалить {user.username}: он ведёт {len(owned_workshops)} мастер-класс(ов)."), 400
for pm in db_session.exec(select(ProjectMember).where(ProjectMember.user_id == user_id)).all():
db_session.delete(pm)
for s in db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all():
db_session.delete(s)
for sub in db_session.exec(select(EventSubscriber).where(EventSubscriber.user_id == user_id)).all():
sub.user_id = None
db_session.add(sub)
for t in db_session.exec(select(ProjectTask).where(ProjectTask.assignee_id == user_id)).all():
t.assignee_id = None
db_session.add(t)
for n in db_session.exec(select(Notification).where(Notification.user_id == user_id)).all():
db_session.delete(n)
db_session.delete(user)
db_session.commit()
return redirect(url_for("admin_users"))