"""Общие хелперы, константы, декораторы, утилиты для всего приложения.""" import os import uuid import smtplib import pytz import threading from datetime import datetime, timezone, timedelta from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from functools import wraps from itsdangerous import URLSafeTimedSerializer from flask import session, redirect, url_for from sqlmodel import Session, select from .db import engine from .models import ( User, Event, GlobalStatus, Project, PortalFile, UserSession, ProjectMember, EventSubscriber, ProjectTask, ProjectPost, ProjectPostAttachment, ProjectSubscription, Workshop, WorkshopAssistant, WorkshopSession, WorkshopRegistration, WorkshopMaterial, WorkshopFeedback, WorkshopPost, WorkshopPostAttachment, WorkshopSubscription, Notification, # ── v2.0 ──────────────────────────────────────────────── FileSpace, FileFolder, FileSpaceACL, FileFolderACL, FileVersion, ) from . import config, gitea_api serializer = URLSafeTimedSerializer(config.WEB_SECRET) MSK = pytz.timezone("Europe/Moscow") IMAGE_EXTS = {"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"} # ══════════════════════════════════════════════════════════════════════ # ФИЛЬТРЫ И MSK # ══════════════════════════════════════════════════════════════════════ 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) def msk_strftime(dt, fmt): if dt is None: return "" return to_msk(dt).strftime(fmt) 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 is_image(filename): if not filename or "." not in filename: return False return filename.rsplit(".", 1)[-1].lower() in IMAGE_EXTS def file_icon(filename): if not filename or "." not in filename: return "📎" ext = filename.rsplit(".", 1)[-1].lower() if ext in IMAGE_EXTS: return "🖼" if ext == "pdf": return "📄" if ext in {"doc", "docx", "odt", "rtf"}: return "📝" if ext in {"xls", "xlsx", "ods", "csv"}: return "📊" if ext in {"ppt", "pptx", "odp"}: return "📽" if ext in {"zip", "rar", "7z", "tar", "gz", "bz2"}: return "🗜" if ext in {"mp3", "wav", "ogg", "flac", "m4a"}: return "🎵" if ext in {"mp4", "mov", "avi", "mkv", "webm"}: return "🎬" if ext in {"py", "js", "ts", "html", "css", "json", "yml", "yaml", "md", "txt"}: return "📃" return "📎" def format_size(size_bytes): """Человекочитаемый размер: '22.3 КБ', '5.1 МБ', '512 Б'.""" if size_bytes is None: return "—" try: n = int(size_bytes) except (TypeError, ValueError): return "—" if n < 1024: return f"{n} Б" if n < 1024 * 1024: return f"{n / 1024:.1f} КБ" if n < 1024 * 1024 * 1024: return f"{n / 1024 / 1024:.1f} МБ" return f"{n / 1024 / 1024 / 1024:.2f} ГБ" def register_filters(app): app.add_template_filter(msk_strftime, "msk_strftime") app.add_template_filter(date_ru, "date_ru") app.add_template_filter(is_image, "is_image") app.add_template_filter(file_icon, "file_icon") app.add_template_filter(format_size, "format_size") # ══════════════════════════════════════════════════════════════════════ # КОНСТАНТЫ # ══════════════════════════════════════════════════════════════════════ 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": "Внутренний"} RECURRENCE_PRESETS = [ ("once", "Разово"), ("on_demand", "По объявлению"), ("weekly:mon", "1×/нед (пн)"), ("weekly:tue", "1×/нед (вт)"), ("weekly:thu", "1×/нед (чт)"), ("weekly:sat", "1×/нед (сб)"), ("twice_weekly:tue,thu", "2×/нед (вт, чт)"), ("twice_weekly:thu,sat", "2×/нед (чт, сб)"), ("twice_weekly:mon,wed", "2×/нед (пн, ср)"), ] RECURRENCE_LABELS = dict(RECURRENCE_PRESETS) # ══════════════════════════════════════════════════════════════════════ # ДЕКОРАТОРЫ # ══════════════════════════════════════════════════════════════════════ 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 if is_admin or is_owner: return { "is_admin": is_admin, "is_owner": is_owner, "project_role": "manager", "can_manage": True, "can_edit": True, "can_delete": True, "can_upload": True, "can_view_files": True, "can_edit_files": True, "can_view_repo": True, "can_post_feed": True, "can_edit_own_posts": True, "can_edit_any_post": True, "can_manage_members": True, "can_edit_project": True, } if not membership: return { "is_admin": False, "is_owner": False, "project_role": None, "can_manage": False, "can_edit": False, "can_delete": False, "can_upload": False, "can_view_files": False, "can_edit_files": False, "can_view_repo": False, "can_post_feed": False, "can_edit_own_posts": False, "can_edit_any_post": False, "can_manage_members": False, "can_edit_project": False, } m = membership return { "is_admin": False, "is_owner": False, "project_role": m.role, "can_manage": m.can_manage_members, "can_edit": m.can_edit_files or m.can_post_feed, "can_delete": False, "can_upload": m.can_edit_files, "can_view_files": m.can_view_files, "can_edit_files": m.can_edit_files, "can_view_repo": m.can_view_repo, "can_post_feed": m.can_post_feed, "can_edit_own_posts": m.can_edit_own_posts, "can_edit_any_post": m.can_edit_any_post, "can_manage_members": m.can_manage_members, "can_edit_project": m.can_manage_members, } def workshop_permissions(workshop, user_id, global_role, is_assistant=False): is_admin = global_role == "admin" is_lead = user_id is not None and workshop.lead_id == user_id can_manage = is_admin or is_lead or is_assistant return { "is_admin": is_admin, "is_lead": is_lead, "is_assistant": is_assistant, "can_manage": can_manage, "can_delete": can_manage, "can_delete_workshop": is_admin or is_lead, "can_manage_assistants": is_admin or is_lead, "can_post": can_manage, } def _ws_assistant_ids(db_session, workshop_id): rows = db_session.exec(select(WorkshopAssistant) .where(WorkshopAssistant.workshop_id == workshop_id)).all() return {r.user_id for r in rows} def _ws_assistants_full(db_session, workshop_id): return db_session.exec( select(WorkshopAssistant, User) .join(User, WorkshopAssistant.user_id == User.id) .where(WorkshopAssistant.workshop_id == workshop_id) .order_by(User.username) ).all() def _ws_perms_for(db_session, ws, user_id, global_role): is_assistant = (user_id in _ws_assistant_ids(db_session, ws.id)) if user_id else False return workshop_permissions(ws, user_id, global_role, is_assistant) 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 _ws_subscription(db_session, workshop_id, user_id): if not user_id: return None return db_session.exec(select(WorkshopSubscription) .where(WorkshopSubscription.workshop_id == workshop_id) .where(WorkshopSubscription.user_id == user_id)).first() def _project_subscription(db_session, project_id, user_id): if not user_id: return None return db_session.exec(select(ProjectSubscription) .where(ProjectSubscription.project_id == project_id) .where(ProjectSubscription.user_id == user_id)).first() def _ws_subscriber_ids(db_session, workshop_id): subs = db_session.exec(select(WorkshopSubscription) .where(WorkshopSubscription.workshop_id == workshop_id)).all() return [s.user_id for s in subs if s.user_id] def _project_subscriber_ids(db_session, project_id): subs = db_session.exec(select(ProjectSubscription) .where(ProjectSubscription.project_id == project_id)).all() return [s.user_id for s in subs if s.user_id] 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 or (not user.id and not user.email): return None from sqlalchemy import or_ 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() def _save_uploaded_files(file_objs, user_id, project_id=None, upload_folder=None, space_id=None, folder_id=None, description=None, tags=None): """ Сохранить файлы. Поддерживает v2.0 (пространства/папки/версии). Если space_id задан — создаётся FileVersion(version_num=1) и current_version_id указывает на неё (файл «версионированный»). Иначе — старая логика (current_version_id=None, блоб в file.stored_name). """ saved_ids = [] folder = upload_folder or "/var/lib/gitea/portal-files" for file in file_objs: if not file or not file.filename: continue ext = os.path.splitext(file.filename)[1] stored_name = f"{uuid.uuid4().hex}{ext}" filepath = os.path.join(folder, stored_name) try: file.save(filepath) size_bytes = os.path.getsize(filepath) except Exception as e: print(f"[upload] save failed: {e}", flush=True) continue try: with Session(engine) as s: pf = PortalFile( original_name=file.filename, stored_name=stored_name, size_bytes=size_bytes, uploaded_by=user_id, project_id=project_id, space_id=space_id, folder_id=folder_id, description=description, tags=tags, version=1, ) s.add(pf) s.flush() # Версия 1 — только для файлов в пространстве if space_id is not None: fv = FileVersion( file_id=pf.id, version_num=1, stored_name=stored_name, size_bytes=size_bytes, uploaded_by=user_id, comment="Первая версия", ) s.add(fv) s.flush() pf.current_version_id = fv.id s.add(pf) s.commit() saved_ids.append(pf.id) except Exception as e: print(f"[upload] db failed: {e}", flush=True) try: os.remove(filepath) except Exception: pass return saved_ids # ══════════════════════════════════════════════════════════════════════ # EMAIL И УВЕДОМЛЕНИЯ # ══════════════════════════════════════════════════════════════════════ def send_email(to_email, subject, body): if not config.SMTP_SERVER: print("\n" + "="*60, flush=True) print(f"📧 EMAIL ДЛЯ: {to_email}\nТема: {subject}\n{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---\nhttps://portal.morozovka.space/schedule\nОтписаться: " + unsubscribe_url 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() # ══════════════════════════════════════════════════════════════════════ # v2.0: ПРОСТРАНСТВА ФАЙЛОВ # ══════════════════════════════════════════════════════════════════════ import re as _re_for_slug def _slugify(text): """Транслитерация + slug. Простая — для русских названий.""" _map = { 'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z', 'и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r', 'с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'ts','ч':'ch','ш':'sh','щ':'sch', 'ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya', } s = (text or "").lower().strip() s = "".join(_map.get(c, c) for c in s) s = _re_for_slug.sub(r'[^a-z0-9]+', '-', s).strip('-') return s or f"space-{uuid.uuid4().hex[:8]}" def _unique_space_slug(db_session, base): """Гарантировать уникальность slug — добавить -2/-3/... при коллизии.""" slug = base i = 2 while db_session.exec(select(FileSpace).where(FileSpace.slug == slug)).first(): slug = f"{base}-{i}" i += 1 return slug def _get_space_acl(db_session, space_id, user_id): if not user_id: return None return db_session.exec( select(FileSpaceACL) .where(FileSpaceACL.space_id == space_id) .where(FileSpaceACL.user_id == user_id) ).first() def _get_folder_acl(db_session, folder_id, user_id): if not user_id: return None return db_session.exec( select(FileFolderACL) .where(FileFolderACL.folder_id == folder_id) .where(FileFolderACL.user_id == user_id) ).first() def get_space_permissions(space, user_id, global_role, acl_entry=None): """Права на пространство. Возвращает dict.""" is_admin = global_role == "admin" is_owner = user_id is not None and space.owner_id == user_id if is_admin or is_owner: return {"can_view": True, "can_upload": True, "can_delete": True, "can_manage": True, "is_owner": is_owner, "is_admin": is_admin} if acl_entry: role = acl_entry.role return { "can_view": True, "can_upload": role in ("editor", "manager"), "can_delete": role in ("editor", "manager"), "can_manage": role == "manager", "is_owner": False, "is_admin": False, } # Нет ACL — по видимости can_view = False if space.visibility == "public": can_view = True elif space.visibility == "internal" and user_id: can_view = True return {"can_view": can_view, "can_upload": False, "can_delete": False, "can_manage": False, "is_owner": False, "is_admin": is_admin} def get_folder_permissions(folder, user_id, global_role, space_perms, acl_entry=None): """ Права на папку. Если ACL на папке нет — наследуются от пространства. """ if global_role == "admin" or space_perms.get("is_owner") or space_perms.get("can_manage"): return {"can_view": True, "can_upload": True, "can_delete": True, "can_manage": True} if acl_entry: return { "can_view": acl_entry.can_view, "can_upload": acl_entry.can_upload, "can_delete": acl_entry.can_delete, "can_manage": acl_entry.can_manage, } # Наследование от пространства return { "can_view": space_perms.get("can_view", False), "can_upload": space_perms.get("can_upload", False), "can_delete": space_perms.get("can_delete", False), "can_manage": False, # управление ACL папки — только явно } def check_file_access(db_session, file, user_id, global_role): """ Права на конкретный файл. Если file.space_id задан — наследуем от папки/пространства. Если нет — старые правила (uploaded_by / admin / project member). """ # Файл без пространства — старая логика if file.space_id is None: is_admin = global_role == "admin" is_uploader = user_id is not None and file.uploaded_by == user_id return { "can_view": bool(user_id) or is_admin, # залогиненным — видно "can_download": bool(user_id) or is_admin, "can_delete": is_admin or is_uploader, "can_edit": is_admin or is_uploader, } space = db_session.get(FileSpace, file.space_id) if not space: return {"can_view": False, "can_download": False, "can_delete": False, "can_edit": False} space_acl = _get_space_acl(db_session, space.id, user_id) space_perms = get_space_permissions(space, user_id, global_role, space_acl) folder_perms = space_perms if file.folder_id: folder = db_session.get(FileFolder, file.folder_id) if folder: folder_acl = _get_folder_acl(db_session, folder.id, user_id) folder_perms = get_folder_permissions(folder, user_id, global_role, space_perms, folder_acl) can_edit = folder_perms.get("can_upload", False) or (user_id == file.uploaded_by) can_delete = folder_perms.get("can_delete", False) or (user_id == file.uploaded_by) return { "can_view": folder_perms.get("can_view", False), "can_download": folder_perms.get("can_view", False), "can_delete": can_delete or global_role == "admin", "can_edit": can_edit or global_role == "admin", } def build_breadcrumbs(db_session, space=None, folder=None): """ Собирает хлебные крошки до папки (или до пространства). Возвращает список dict: [{id, name, url, type}] Первым всегда идёт «Файлы» → /files. """ crumbs = [{"name": "Файлы", "url": "/files", "type": "root"}] if space: crumbs.append({ "name": space.name, "url": f"/files/space/{space.id}", "type": "space", }) if folder and space: chain = [] cur = folder seen = set() while cur and cur.id not in seen: seen.add(cur.id) chain.append(cur) if cur.parent_id is None: break cur = db_session.get(FileFolder, cur.parent_id) for f in reversed(chain): crumbs.append({ "name": f.name, "url": f"/files/folder/{f.id}", "type": "folder", }) return crumbs