"""Роуты файлов v2.0: пространства, папки, версии, ACL, поиск, перемещение, удаление.""" import os import uuid import shutil from flask import ( render_template, request, redirect, url_for, session, send_from_directory, jsonify, ) from sqlmodel import Session, select from sqlalchemy import or_ from ..db import engine from ..models import ( PortalFile, Project, ProjectMember, User, FileSpace, FileFolder, FileSpaceACL, FileFolderACL, FileVersion, ProjectPostAttachment, WorkshopPostAttachment, ) from ..helpers import ( login_required, require_role, project_permissions, _save_uploaded_files, notify_user, _slugify, _unique_space_slug, _get_space_acl, _get_folder_acl, get_space_permissions, get_folder_permissions, check_file_access, build_breadcrumbs, ) # ══════════════════════════════════════════════════════════════════════ # УТИЛИТЫ УДАЛЕНИЯ (на уровне модуля, не роутов) # ══════════════════════════════════════════════════════════════════════ def _gather_and_delete_file(db_session, db_file): """Удаляет запись файла + все версии + вложения из БД. НЕ коммитит, НЕ трогает блобы на диске. Возвращает set(stored_names) для последующей очистки с диска.""" stored = {db_file.stored_name} for v in db_session.exec(select(FileVersion).where(FileVersion.file_id == db_file.id)).all(): stored.add(v.stored_name) db_session.delete(v) for att in db_session.exec(select(ProjectPostAttachment).where(ProjectPostAttachment.file_id == db_file.id)).all(): db_session.delete(att) for att in db_session.exec(select(WorkshopPostAttachment).where(WorkshopPostAttachment.file_id == db_file.id)).all(): db_session.delete(att) db_session.delete(db_file) return stored def _delete_folder_tree(db_session, folder_id): """Рекурсивно удаляет папку + все подпапки + их файлы + ACL. НЕ коммитит. Возвращает set(stored_names) для очистки с диска.""" stored = set() # 1. Файлы в этой папке for f in db_session.exec(select(PortalFile).where(PortalFile.folder_id == folder_id)).all(): stored |= _gather_and_delete_file(db_session, f) # 2. Подпапки (рекурсивно) for child in db_session.exec(select(FileFolder).where(FileFolder.parent_id == folder_id)).all(): stored |= _delete_folder_tree(db_session, child.id) # 3. ACL этой папки for acl in db_session.exec(select(FileFolderACL).where(FileFolderACL.folder_id == folder_id)).all(): db_session.delete(acl) # 4. Сама папка folder = db_session.get(FileFolder, folder_id) if folder: db_session.delete(folder) db_session.flush() return stored def _delete_blobs_from_disk(upload_folder, stored_names): """Удаляет файлы с диска. Вызывать ТОЛЬКО после успешного commit.""" for name in (stored_names or ()): try: path = os.path.join(upload_folder, name) if os.path.exists(path): os.remove(path) except Exception as e: print(f"[delete_blob] {name}: {e}", flush=True) def _delete_file_and_blobs(db_session, db_file, upload_folder): """Совместимость: удалить один файл + закоммитить + очистить диск.""" stored = _gather_and_delete_file(db_session, db_file) db_session.commit() _delete_blobs_from_disk(upload_folder, stored) # ══════════════════════════════════════════════════════════════════════ def register(app): # ══════════════════════════════════════════════════════════════════ # ГЛАВНАЯ: список пространств + «Без пространства» # ══════════════════════════════════════════════════════════════════ @app.route("/files") @login_required def files_list(): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: all_spaces = db_session.exec( select(FileSpace).order_by(FileSpace.created_at.desc()) ).all() visible_spaces = [] for sp in all_spaces: acl = _get_space_acl(db_session, sp.id, user_id) perms = get_space_permissions(sp, user_id, user_role, acl) if perms["can_view"]: fcount = len(db_session.exec( select(PortalFile).where(PortalFile.space_id == sp.id) ).all()) visible_spaces.append({ "space": sp, "perms": perms, "file_count": fcount, }) loose_files_raw = db_session.exec( select(PortalFile, User.username) .join(User, PortalFile.uploaded_by == User.id) .where(PortalFile.space_id == None) # noqa: E711 .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", spaces=visible_spaces, loose_files=loose_files_raw, projects_map=projects_map, can_create_space=user_role in ("admin", "professor", "lead"), view="root", ) # ══════════════════════════════════════════════════════════════════ # ПРОСТРАНСТВО # ══════════════════════════════════════════════════════════════════ @app.route("/files/space/new", methods=["GET", "POST"]) @require_role(["admin", "professor", "lead"]) def filespace_new(): user_id = session.get("user_id") if request.method == "POST": name = (request.form.get("name") or "").strip() if not name: return render_template("files_edit.html", mode="space_new", error="Название обязательно", form=request.form) description = (request.form.get("description") or "").strip() or None visibility = request.form.get("visibility") or "internal" if visibility not in ("public", "internal", "private"): visibility = "internal" project_id = request.form.get("project_id") project_id = int(project_id) if (project_id or "").isdigit() else None with Session(engine) as db_session: slug = _unique_space_slug(db_session, _slugify(name)) sp = FileSpace( name=name, slug=slug, description=description, owner_id=user_id, project_id=project_id, visibility=visibility, ) db_session.add(sp) db_session.flush() space_id = sp.id db_session.commit() return redirect(url_for("filespace_view", space_id=space_id)) with Session(engine) as db_session: my_projects = db_session.exec( select(Project, ProjectMember) .join(ProjectMember, ProjectMember.project_id == Project.id) .where(ProjectMember.user_id == user_id) .order_by(Project.title) ).all() return render_template("files_edit.html", mode="space_new", form={}, projects=my_projects) @app.route("/files/space/") @login_required def filespace_view(space_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: space = db_session.get(FileSpace, space_id) if not space: return "Пространство не найдено", 404 acl = _get_space_acl(db_session, space_id, user_id) perms = get_space_permissions(space, user_id, user_role, acl) if not perms["can_view"]: return "403: Нет доступа", 403 folders = db_session.exec( select(FileFolder) .where(FileFolder.space_id == space_id) .where(FileFolder.parent_id == None) # noqa: E711 .order_by(FileFolder.name) ).all() files_raw = db_session.exec( select(PortalFile, User.username) .join(User, PortalFile.uploaded_by == User.id) .where(PortalFile.space_id == space_id) .where(PortalFile.folder_id == None) # noqa: E711 .order_by(PortalFile.uploaded_at.desc()) ).all() breadcrumbs = build_breadcrumbs(db_session, space=space) return render_template( "files.html", view="space", space=space, perms=perms, folders=folders, files=files_raw, breadcrumbs=breadcrumbs, loose_files=None, spaces=None, ) @app.route("/files/space//edit", methods=["GET", "POST"]) @login_required def filespace_edit(space_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: space = db_session.get(FileSpace, space_id) if not space: return "Пространство не найдено", 404 acl = _get_space_acl(db_session, space_id, user_id) perms = get_space_permissions(space, user_id, user_role, acl) if not perms["can_manage"]: return "403: Нет прав", 403 if request.method == "POST": space.name = (request.form.get("name") or space.name).strip() space.description = (request.form.get("description") or "").strip() or None visibility = request.form.get("visibility") or space.visibility if visibility in ("public", "internal", "private"): space.visibility = visibility db_session.add(space) db_session.commit() return redirect(url_for("filespace_view", space_id=space_id)) return render_template("files_edit.html", mode="space_edit", space=space, perms=perms, form={}) @app.route("/files/space//delete", methods=["POST"]) @login_required def filespace_delete(space_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: space = db_session.get(FileSpace, space_id) if not space: return redirect(url_for("files_list")) acl = _get_space_acl(db_session, space_id, user_id) perms = get_space_permissions(space, user_id, user_role, acl) if not (perms["is_owner"] or user_role == "admin"): return "403: Удалить может только владелец или admin", 403 stored_names = set() # 1. Все файлы пространства (в корне и в папках) all_files = db_session.exec( select(PortalFile).where(PortalFile.space_id == space_id) ).all() for f in all_files: stored_names |= _gather_and_delete_file(db_session, f) # 2. Все корневые папки — рекурсивно (вложенные обработаются внутри) root_folders = db_session.exec( select(FileFolder) .where(FileFolder.space_id == space_id) .where(FileFolder.parent_id == None) # noqa: E711 ).all() for fd in root_folders: stored_names |= _delete_folder_tree(db_session, fd.id) # 3. ACL пространства for acl_e in db_session.exec( select(FileSpaceACL).where(FileSpaceACL.space_id == space_id) ).all(): db_session.delete(acl_e) # 4. Само пространство db_session.delete(space) db_session.commit() # 5. Блобы — после успешного коммита _delete_blobs_from_disk(app.config['UPLOAD_FOLDER'], stored_names) return redirect(url_for("files_list")) # ══════════════════════════════════════════════════════════════════ # ПАПКА # ══════════════════════════════════════════════════════════════════ @app.route("/files/folder/") @login_required def folder_view(folder_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: folder = db_session.get(FileFolder, folder_id) if not folder: return "Папка не найдена", 404 space = db_session.get(FileSpace, folder.space_id) if not space: return "Пространство не найдено", 404 space_acl = _get_space_acl(db_session, space.id, user_id) space_perms = get_space_permissions(space, user_id, user_role, space_acl) folder_acl = _get_folder_acl(db_session, folder_id, user_id) perms = get_folder_permissions(folder, user_id, user_role, space_perms, folder_acl) if not perms["can_view"]: return "403: Нет доступа", 403 subfolders = db_session.exec( select(FileFolder) .where(FileFolder.parent_id == folder_id) .order_by(FileFolder.name) ).all() files_raw = db_session.exec( select(PortalFile, User.username) .join(User, PortalFile.uploaded_by == User.id) .where(PortalFile.folder_id == folder_id) .order_by(PortalFile.uploaded_at.desc()) ).all() breadcrumbs = build_breadcrumbs(db_session, space=space, folder=folder) return render_template( "files.html", view="folder", space=space, folder=folder, perms=perms, folders=subfolders, files=files_raw, breadcrumbs=breadcrumbs, loose_files=None, spaces=None, ) @app.route("/files/folder/new", methods=["POST"]) @login_required def folder_new(): user_id = session.get("user_id") user_role = session.get("role") space_id = request.form.get("space_id") parent_id = request.form.get("parent_id") name = (request.form.get("name") or "").strip() if not space_id or not space_id.isdigit() or not name: return "Ошибка параметров", 400 space_id = int(space_id) parent_id = int(parent_id) if (parent_id or "").isdigit() else None with Session(engine) as db_session: space = db_session.get(FileSpace, space_id) if not space: return "Пространство не найдено", 404 space_acl = _get_space_acl(db_session, space_id, user_id) space_perms = get_space_permissions(space, user_id, user_role, space_acl) if not space_perms["can_upload"]: return "403: Нет прав на создание папки", 403 fd = FileFolder( space_id=space_id, parent_id=parent_id, name=name, description=(request.form.get("description") or "").strip() or None, created_by=user_id, ) db_session.add(fd) db_session.commit() if parent_id: return redirect(url_for("folder_view", folder_id=parent_id)) return redirect(url_for("filespace_view", space_id=space_id)) @app.route("/files/folder//delete", methods=["POST"]) @login_required def folder_delete(folder_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: folder = db_session.get(FileFolder, folder_id) if not folder: return redirect(url_for("files_list")) space = db_session.get(FileSpace, folder.space_id) if not space: return redirect(url_for("files_list")) space_acl = _get_space_acl(db_session, space.id, user_id) space_perms = get_space_permissions(space, user_id, user_role, space_acl) folder_acl = _get_folder_acl(db_session, folder_id, user_id) perms = get_folder_permissions(folder, user_id, user_role, space_perms, folder_acl) if not perms["can_delete"]: return "403: Нет прав", 403 parent_id = folder.parent_id space_id = folder.space_id # Рекурсивное удаление папки + содержимого (коммит внутри хелпера не делаем) stored_names = _delete_folder_tree(db_session, folder_id) db_session.commit() _delete_blobs_from_disk(app.config['UPLOAD_FOLDER'], stored_names) if parent_id: return redirect(url_for("folder_view", folder_id=parent_id)) return redirect(url_for("filespace_view", space_id=space_id)) # ══════════════════════════════════════════════════════════════════ # ЗАГРУЗКА ФАЙЛА # ══════════════════════════════════════════════════════════════════ @app.route("/files/upload", methods=["POST"]) @login_required def file_upload(): user_id = session.get("user_id") user_role = session.get("role") space_id = request.form.get("space_id") folder_id = request.form.get("folder_id") if not space_id: file = request.files.get("file") if not file or not file.filename: return redirect(url_for("files_list")) _save_uploaded_files([file], user_id, upload_folder=app.config['UPLOAD_FOLDER']) return redirect(url_for("files_list")) if not space_id.isdigit(): return "Ошибка параметров", 400 space_id = int(space_id) folder_id = int(folder_id) if (folder_id or "").isdigit() else None with Session(engine) as db_session: space = db_session.get(FileSpace, space_id) if not space: return "Пространство не найдено", 404 space_acl = _get_space_acl(db_session, space_id, user_id) space_perms = get_space_permissions(space, user_id, user_role, space_acl) if folder_id: folder = db_session.get(FileFolder, folder_id) if not folder or folder.space_id != space_id: return "Папка не найдена", 404 folder_acl = _get_folder_acl(db_session, folder_id, user_id) perms = get_folder_permissions(folder, user_id, user_role, space_perms, folder_acl) else: perms = space_perms if not perms.get("can_upload"): return "403: Нет прав на загрузку", 403 file = request.files.get("file") if not file or not file.filename: target = url_for("folder_view", folder_id=folder_id) if folder_id else url_for("filespace_view", space_id=space_id) return redirect(target) _save_uploaded_files( [file], user_id, upload_folder=app.config['UPLOAD_FOLDER'], space_id=space_id, folder_id=folder_id, ) if folder_id: return redirect(url_for("folder_view", folder_id=folder_id)) return redirect(url_for("filespace_view", space_id=space_id)) # ══════════════════════════════════════════════════════════════════ # СКАЧИВАНИЕ / ПРЕВЬЮ # ══════════════════════════════════════════════════════════════════ @app.route("/files/download/") @login_required def file_download(file_id): return _serve_file(file_id, as_attachment=True) @app.route("/files/preview/") @login_required def file_preview(file_id): return _serve_file(file_id, as_attachment=False) def _serve_file(file_id, as_attachment): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: db_file = db_session.get(PortalFile, file_id) if not db_file: return "Файл не найден", 404 perms = check_file_access(db_session, db_file, user_id, user_role) if not perms["can_download"]: return "403: Нет доступа", 403 if db_file.current_version_id: fv = db_session.get(FileVersion, db_file.current_version_id) stored_name = fv.stored_name if fv else db_file.stored_name else: stored_name = db_file.stored_name path = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) if not os.path.exists(path): return "Файл на диске отсутствует", 404 return send_from_directory( app.config['UPLOAD_FOLDER'], stored_name, as_attachment=as_attachment, download_name=db_file.original_name, ) # ══════════════════════════════════════════════════════════════════ # РЕДАКТИРОВАНИЕ / УДАЛЕНИЕ ФАЙЛА # ══════════════════════════════════════════════════════════════════ @app.route("/files//edit", methods=["GET", "POST"]) @login_required def file_edit(file_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: db_file = db_session.get(PortalFile, file_id) if not db_file: return "Файл не найден", 404 perms = check_file_access(db_session, db_file, user_id, user_role) if not perms["can_edit"]: return "403: Нет прав", 403 if request.method == "POST": db_file.description = (request.form.get("description") or "").strip() or None db_file.tags = (request.form.get("tags") or "").strip() or None if db_file.space_id: new_folder = request.form.get("folder_id") db_file.folder_id = int(new_folder) if (new_folder or "").isdigit() else None db_session.add(db_file) db_session.commit() target = url_for("folder_view", folder_id=db_file.folder_id) if db_file.folder_id else url_for("filespace_view", space_id=db_file.space_id) return redirect(target) folders_available = [] if db_file.space_id: folders_available = db_session.exec( select(FileFolder).where(FileFolder.space_id == db_file.space_id).order_by(FileFolder.name) ).all() return render_template("files_edit.html", mode="file_edit", db_file=db_file, perms=perms, folders=folders_available, form={}) @app.route("/files//delete", methods=["POST"]) @login_required def file_delete(file_id): user_id = session.get("user_id") user_role = session.get("role") 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) perms = check_file_access(db_session, db_file, user_id, user_role) if not perms["can_delete"]: return "403: Нет прав", 403 stored = _gather_and_delete_file(db_session, db_file) db_session.commit() _delete_blobs_from_disk(app.config['UPLOAD_FOLDER'], stored) return redirect(next_url) # ══════════════════════════════════════════════════════════════════ # ПОИСК # ══════════════════════════════════════════════════════════════════ @app.route("/files/search") @login_required def file_search(): user_id = session.get("user_id") user_role = session.get("role") q = (request.args.get("q") or "").strip().lower() scope_space = request.args.get("space_id") with Session(engine) as db_session: query = select(PortalFile, User.username).join(User, PortalFile.uploaded_by == User.id) if scope_space and scope_space.isdigit(): query = query.where(PortalFile.space_id == int(scope_space)) if q: query = query.where(or_( PortalFile.original_name.ilike(f"%{q}%"), PortalFile.description.ilike(f"%{q}%"), PortalFile.tags.ilike(f"%{q}%"), )) query = query.order_by(PortalFile.uploaded_at.desc()).limit(200) results = db_session.exec(query).all() visible = [] for f, uploader in results: perms = check_file_access(db_session, f, user_id, user_role) if perms["can_view"]: visible.append((f, uploader)) space_scope = None if scope_space and scope_space.isdigit(): space_scope = db_session.get(FileSpace, int(scope_space)) return render_template( "files.html", view="search", q=q, results=visible, space_scope=space_scope, spaces=None, files=None, folders=None, loose_files=None, breadcrumbs=None, perms={"can_view": True}, ) # ══════════════════════════════════════════════════════════════════ # ACL ПРОСТРАНСТВА # ══════════════════════════════════════════════════════════════════ @app.route("/files/space//acl") @login_required def filespace_acl(space_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: space = db_session.get(FileSpace, space_id) if not space: return "Пространство не найдено", 404 space_acl = _get_space_acl(db_session, space_id, user_id) perms = get_space_permissions(space, user_id, user_role, space_acl) if not perms["can_manage"]: return "403: Нет прав на управление ACL", 403 acl_entries = db_session.exec( select(FileSpaceACL, User) .join(User, FileSpaceACL.user_id == User.id) .where(FileSpaceACL.space_id == space_id) .order_by(User.username) ).all() owner = db_session.get(User, space.owner_id) return render_template("files_acl.html", mode="space", space=space, owner=owner, acl_entries=acl_entries) @app.route("/files/space//acl/add", methods=["POST"]) @login_required def filespace_acl_add(space_id): user_id = session.get("user_id") user_role = session.get("role") username = (request.form.get("username") or "").strip() role = request.form.get("role") or "viewer" if role not in ("viewer", "editor", "manager"): role = "viewer" with Session(engine) as db_session: space = db_session.get(FileSpace, space_id) if not space: return "Пространство не найдено", 404 space_acl = _get_space_acl(db_session, space_id, user_id) perms = get_space_permissions(space, user_id, user_role, space_acl) if not perms["can_manage"]: return "403", 403 target_user = db_session.exec(select(User).where(User.username == username)).first() if not target_user: return redirect(url_for("filespace_acl", space_id=space_id)) if target_user.id == space.owner_id: return redirect(url_for("filespace_acl", space_id=space_id)) existing = db_session.exec( select(FileSpaceACL) .where(FileSpaceACL.space_id == space_id) .where(FileSpaceACL.user_id == target_user.id) ).first() if existing: existing.role = role db_session.add(existing) else: db_session.add(FileSpaceACL( space_id=space_id, user_id=target_user.id, role=role, )) db_session.commit() return redirect(url_for("filespace_acl", space_id=space_id)) @app.route("/files/space//acl//remove", methods=["POST"]) @login_required def filespace_acl_remove(space_id, acl_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: space = db_session.get(FileSpace, space_id) if not space: return redirect(url_for("files_list")) space_acl = _get_space_acl(db_session, space_id, user_id) perms = get_space_permissions(space, user_id, user_role, space_acl) if not perms["can_manage"]: return "403", 403 entry = db_session.get(FileSpaceACL, acl_id) if entry and entry.space_id == space_id: db_session.delete(entry) db_session.commit() return redirect(url_for("filespace_acl", space_id=space_id)) # ══════════════════════════════════════════════════════════════════ # ACL ПАПКИ # ══════════════════════════════════════════════════════════════════ @app.route("/files/folder//acl") @login_required def folder_acl(folder_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: folder = db_session.get(FileFolder, folder_id) if not folder: return "Папка не найдена", 404 space = db_session.get(FileSpace, folder.space_id) if not space: return "Пространство не найдено", 404 space_acl = _get_space_acl(db_session, space.id, user_id) space_perms = get_space_permissions(space, user_id, user_role, space_acl) folder_acl_entry = _get_folder_acl(db_session, folder_id, user_id) if not (space_perms["can_manage"] or (folder_acl_entry and folder_acl_entry.can_manage)): return "403: Нет прав на управление ACL папки", 403 acl_entries = db_session.exec( select(FileFolderACL, User) .join(User, FileFolderACL.user_id == User.id) .where(FileFolderACL.folder_id == folder_id) .order_by(User.username) ).all() return render_template("files_acl.html", mode="folder", folder=folder, space=space, acl_entries=acl_entries) @app.route("/files/folder//acl/add", methods=["POST"]) @login_required def folder_acl_add(folder_id): user_id = session.get("user_id") user_role = session.get("role") username = (request.form.get("username") or "").strip() can_view = request.form.get("can_view") == "on" can_upload = request.form.get("can_upload") == "on" can_delete = request.form.get("can_delete") == "on" can_manage = request.form.get("can_manage") == "on" with Session(engine) as db_session: folder = db_session.get(FileFolder, folder_id) if not folder: return "Папка не найдена", 404 space = db_session.get(FileSpace, folder.space_id) space_acl = _get_space_acl(db_session, space.id, user_id) space_perms = get_space_permissions(space, user_id, user_role, space_acl) folder_acl_entry = _get_folder_acl(db_session, folder_id, user_id) if not (space_perms["can_manage"] or (folder_acl_entry and folder_acl_entry.can_manage)): return "403", 403 target_user = db_session.exec(select(User).where(User.username == username)).first() if not target_user: return redirect(url_for("folder_acl", folder_id=folder_id)) existing = db_session.exec( select(FileFolderACL) .where(FileFolderACL.folder_id == folder_id) .where(FileFolderACL.user_id == target_user.id) ).first() if existing: existing.can_view = can_view existing.can_upload = can_upload existing.can_delete = can_delete existing.can_manage = can_manage db_session.add(existing) else: db_session.add(FileFolderACL( folder_id=folder_id, user_id=target_user.id, can_view=can_view, can_upload=can_upload, can_delete=can_delete, can_manage=can_manage, )) db_session.commit() return redirect(url_for("folder_acl", folder_id=folder_id)) @app.route("/files/folder//acl//save", methods=["POST"]) @login_required def folder_acl_save(folder_id, acl_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: folder = db_session.get(FileFolder, folder_id) if not folder: return "Папка не найдена", 404 space = db_session.get(FileSpace, folder.space_id) space_acl = _get_space_acl(db_session, space.id, user_id) space_perms = get_space_permissions(space, user_id, user_role, space_acl) folder_acl_entry = _get_folder_acl(db_session, folder_id, user_id) if not (space_perms["can_manage"] or (folder_acl_entry and folder_acl_entry.can_manage)): return "403", 403 entry = db_session.get(FileFolderACL, acl_id) if entry and entry.folder_id == folder_id: entry.can_view = request.form.get("can_view") == "on" entry.can_upload = request.form.get("can_upload") == "on" entry.can_delete = request.form.get("can_delete") == "on" entry.can_manage = request.form.get("can_manage") == "on" db_session.add(entry) db_session.commit() return redirect(url_for("folder_acl", folder_id=folder_id)) @app.route("/files/folder//acl//remove", methods=["POST"]) @login_required def folder_acl_remove(folder_id, acl_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: folder = db_session.get(FileFolder, folder_id) if not folder: return redirect(url_for("files_list")) space = db_session.get(FileSpace, folder.space_id) space_acl = _get_space_acl(db_session, space.id, user_id) space_perms = get_space_permissions(space, user_id, user_role, space_acl) folder_acl_entry = _get_folder_acl(db_session, folder_id, user_id) if not (space_perms["can_manage"] or (folder_acl_entry and folder_acl_entry.can_manage)): return "403", 403 entry = db_session.get(FileFolderACL, acl_id) if entry and entry.folder_id == folder_id: db_session.delete(entry) db_session.commit() return redirect(url_for("folder_acl", folder_id=folder_id)) # ══════════════════════════════════════════════════════════════════ # ВЕРСИИ ФАЙЛА # ══════════════════════════════════════════════════════════════════ @app.route("/files//history") @login_required def file_history(file_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: db_file = db_session.get(PortalFile, file_id) if not db_file: return "Файл не найден", 404 perms = check_file_access(db_session, db_file, user_id, user_role) if not perms["can_view"]: return "403: Нет доступа", 403 versions = db_session.exec( select(FileVersion, User) .join(User, FileVersion.uploaded_by == User.id) .where(FileVersion.file_id == file_id) .order_by(FileVersion.version_num.desc()) ).all() owner_username = db_session.exec(select(User).where(User.id == db_file.uploaded_by)).first() return render_template("files_history.html", db_file=db_file, versions=versions, perms=perms, owner=owner_username) @app.route("/files//new_version", methods=["POST"]) @login_required def file_new_version(file_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: db_file = db_session.get(PortalFile, file_id) if not db_file: return "Файл не найден", 404 perms = check_file_access(db_session, db_file, user_id, user_role) if not perms["can_edit"]: return "403", 403 if db_file.space_id is None: return "Версии доступны только для файлов в пространстве", 400 file = request.files.get("file") if not file or not file.filename: return redirect(url_for("file_history", file_id=file_id)) ext = os.path.splitext(file.filename)[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: return f"Ошибка сохранения: {e}", 500 new_version_num = (db_file.version or 1) + 1 fv = FileVersion( file_id=db_file.id, version_num=new_version_num, stored_name=stored_name, size_bytes=size_bytes, uploaded_by=user_id, comment=(request.form.get("comment") or "").strip() or None, ) db_session.add(fv) db_session.flush() db_file.version = new_version_num db_file.current_version_id = fv.id db_file.size_bytes = size_bytes db_session.add(db_file) db_session.commit() return redirect(url_for("file_history", file_id=file_id)) @app.route("/files//version//download") @login_required def file_version_download(file_id, version_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: db_file = db_session.get(PortalFile, file_id) if not db_file: return "Файл не найден", 404 perms = check_file_access(db_session, db_file, user_id, user_role) if not perms["can_download"]: return "403", 403 fv = db_session.get(FileVersion, version_id) if not fv or fv.file_id != file_id: return "Версия не найдена", 404 path = os.path.join(app.config['UPLOAD_FOLDER'], fv.stored_name) if not os.path.exists(path): return "Файл версии отсутствует на диске", 404 base, ext = os.path.splitext(db_file.original_name) dl_name = f"{base}_v{fv.version_num}{ext}" return send_from_directory( app.config['UPLOAD_FOLDER'], fv.stored_name, as_attachment=True, download_name=dl_name, ) @app.route("/files//revert/", methods=["POST"]) @login_required def file_revert(file_id, version_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: db_file = db_session.get(PortalFile, file_id) if not db_file: return "Файл не найден", 404 perms = check_file_access(db_session, db_file, user_id, user_role) if not perms["can_edit"]: return "403", 403 old_fv = db_session.get(FileVersion, version_id) if not old_fv or old_fv.file_id != file_id: return "Версия не найдена", 404 old_path = os.path.join(app.config['UPLOAD_FOLDER'], old_fv.stored_name) if not os.path.exists(old_path): return "Файл старой версии отсутствует на диске", 404 ext = os.path.splitext(old_fv.stored_name)[1] new_stored = f"{uuid.uuid4().hex}{ext}" new_path = os.path.join(app.config['UPLOAD_FOLDER'], new_stored) try: shutil.copy2(old_path, new_path) except Exception as e: return f"Ошибка копирования: {e}", 500 new_version_num = (db_file.version or 1) + 1 fv = FileVersion( file_id=db_file.id, version_num=new_version_num, stored_name=new_stored, size_bytes=old_fv.size_bytes, uploaded_by=user_id, comment=f"Откат к v{old_fv.version_num}", ) db_session.add(fv) db_session.flush() db_file.version = new_version_num db_file.current_version_id = fv.id db_file.size_bytes = old_fv.size_bytes db_session.add(db_file) db_session.commit() return redirect(url_for("file_history", file_id=file_id)) # ══════════════════════════════════════════════════════════════════ # ПЕРЕМЕЩЕНИЕ ФАЙЛА # ══════════════════════════════════════════════════════════════════ @app.route("/files//move", methods=["GET", "POST"]) @login_required def file_move(file_id): user_id = session.get("user_id") user_role = session.get("role") with Session(engine) as db_session: db_file = db_session.get(PortalFile, file_id) if not db_file: return "Файл не найден", 404 perms = check_file_access(db_session, db_file, user_id, user_role) if not perms["can_edit"]: return "403: Нет прав на перемещение", 403 if request.method == "POST": new_space_raw = request.form.get("space_id") new_folder_raw = request.form.get("folder_id") new_space_id = int(new_space_raw) if (new_space_raw or "").isdigit() else None new_folder_id = int(new_folder_raw) if (new_folder_raw or "").isdigit() else None target_folder = None if new_folder_id: target_folder = db_session.get(FileFolder, new_folder_id) if not target_folder: return "Папка не найдена", 404 new_space_id = target_folder.space_id if new_space_id: target_space = db_session.get(FileSpace, new_space_id) if not target_space: return "Пространство не найдено", 404 target_space_acl = _get_space_acl(db_session, new_space_id, user_id) target_perms = get_space_permissions(target_space, user_id, user_role, target_space_acl) if target_folder: target_folder_acl = _get_folder_acl(db_session, new_folder_id, user_id) folder_perms = get_folder_permissions( target_folder, user_id, user_role, target_perms, target_folder_acl ) if not folder_perms.get("can_upload"): return "403: Нет прав на запись в целевую папку", 403 else: if not target_perms.get("can_upload"): return "403: Нет прав на запись в целевое пространство", 403 if new_space_id and not db_file.current_version_id: fv = FileVersion( file_id=db_file.id, version_num=1, stored_name=db_file.stored_name, size_bytes=db_file.size_bytes, uploaded_by=db_file.uploaded_by, comment="Мигрировано при перемещении в пространство", ) db_session.add(fv) db_session.flush() db_file.current_version_id = fv.id db_file.version = 1 if not new_space_id and db_file.current_version_id: versions = db_session.exec( select(FileVersion).where(FileVersion.file_id == db_file.id) ).all() for v in versions: if v.stored_name != db_file.stored_name: try: p = os.path.join(app.config['UPLOAD_FOLDER'], v.stored_name) if os.path.exists(p): os.remove(p) except Exception as e: print(f"[file_move] cleanup {v.stored_name}: {e}", flush=True) db_session.delete(v) db_file.current_version_id = None db_file.version = 1 db_file.space_id = new_space_id db_file.folder_id = new_folder_id db_session.add(db_file) db_session.commit() if new_folder_id: return redirect(url_for("folder_view", folder_id=new_folder_id)) if new_space_id: return redirect(url_for("filespace_view", space_id=new_space_id)) return redirect(url_for("files_list")) all_spaces = db_session.exec(select(FileSpace).order_by(FileSpace.name)).all() available_spaces = [] for sp in all_spaces: space_acl = _get_space_acl(db_session, sp.id, user_id) sp_perms = get_space_permissions(sp, user_id, user_role, space_acl) if sp_perms.get("can_upload"): available_spaces.append(sp) folders_by_space = {} for sp in available_spaces: flds = db_session.exec( select(FileFolder).where(FileFolder.space_id == sp.id).order_by(FileFolder.name) ).all() if flds: folders_by_space[sp.id] = [ {"id": f.id, "name": f.name, "parent_id": f.parent_id} for f in flds ] return render_template("files_edit.html", mode="file_move", db_file=db_file, perms=perms, available_spaces=available_spaces, folders_by_space=folders_by_space)