1971 lines
92 KiB
Python
1971 lines
92 KiB
Python
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"))
|