morozovka-portal/app/helpers.py

629 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Общие хелперы, константы, декораторы, утилиты для всего приложения."""
import os
import uuid
import smtplib
import pytz
import threading
from 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