1064 lines
51 KiB
Python
1064 lines
51 KiB
Python
"""Роуты файлов 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)
|