morozovka-portal/app/modules/files_routes.py

1064 lines
51 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.

"""Роуты файлов 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/<int:space_id>")
@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/<int:space_id>/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/<int:space_id>/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/<int:folder_id>")
@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/<int:folder_id>/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/<int:file_id>")
@login_required
def file_download(file_id):
return _serve_file(file_id, as_attachment=True)
@app.route("/files/preview/<int:file_id>")
@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/<int:file_id>/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/<int:file_id>/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/<int:space_id>/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/<int:space_id>/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/<int:space_id>/acl/<int:acl_id>/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/<int:folder_id>/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/<int:folder_id>/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/<int:folder_id>/acl/<int:acl_id>/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/<int:folder_id>/acl/<int:acl_id>/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/<int:file_id>/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/<int:file_id>/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/<int:file_id>/version/<int:version_id>/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/<int:file_id>/revert/<int:version_id>", 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/<int:file_id>/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)