629 lines
27 KiB
Python
629 lines
27 KiB
Python
"""Общие хелперы, константы, декораторы, утилиты для всего приложения."""
|
||
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
|