From acb111d7ffade0a9379eafc616b65fa10c6b2217 Mon Sep 17 00:00:00 2001 From: Ivan Budakov Date: Mon, 19 Oct 2026 18:28:38 +0300 Subject: [PATCH] feat: add file storage module with 100MB limit --- app/main.py | 107 +++++++++++++++++++++++++++++++++++---- app/models.py | 12 ++++- app/templates/files.html | 83 ++++++++++++++++++++++++++++++ app/templates/index.html | 6 ++- 4 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 app/templates/files.html diff --git a/app/main.py b/app/main.py index e932ae9..ce744bf 100644 --- a/app/main.py +++ b/app/main.py @@ -1,14 +1,23 @@ -from flask import Flask, render_template, jsonify, redirect, url_for, request, session + +import os +import uuid +from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory +from werkzeug.utils import secure_filename from .db import init_db, engine from . import config, auth, assistant from sqlmodel import Session, select -from .models import User, Event, GlobalStatus, Project +from .models import User, Event, GlobalStatus, Project, PortalFile from datetime import datetime, timezone from functools import wraps app = Flask(__name__) app.secret_key = config.WEB_SECRET +# Настройки для загрузки файлов (макс 100 МБ) +UPLOAD_FOLDER = '/var/lib/gitea/portal-files' +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 + with app.app_context(): init_db() with Session(engine) as db_session: @@ -29,6 +38,14 @@ def require_role(allowed_roles): return decorated_function return decorator +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get("user_id"): + return redirect(url_for("login")) + return f(*args, **kwargs) + return decorated_function + @app.route("/healthz") def healthz(): return jsonify({"status": "ok", "service": "morozovka-portal"}) @@ -90,17 +107,13 @@ def auth_callback(): return redirect(url_for("profile")) @app.route("/profile") +@login_required def profile(): - user_id = session.get("user_id") - if not user_id: - return redirect(url_for("login")) - with Session(engine) as db_session: - user = db_session.get(User, user_id) + user = db_session.get(User, session.get("user_id")) if not user: session.clear() return redirect(url_for("login")) - return render_template("profile.html", user=user) @app.route("/logout") @@ -108,6 +121,81 @@ def logout(): session.clear() return redirect(url_for("index")) +# --- ФАЙЛОХРАНИЛИЩЕ --- +@app.route("/files") +@login_required +def files_list(): + with Session(engine) as db_session: + # Сортируем по дате (новые сверху), джойним с User чтобы знать кто загрузил + files = db_session.exec( + select(PortalFile, User.username) + .join(User, PortalFile.uploaded_by == User.id) + .order_by(PortalFile.uploaded_at.desc()) + ).all() + return render_template("files.html", files=files) + +@app.route("/files/upload", methods=["POST"]) +@login_required +def file_upload(): + if 'file' not in request.files: + return redirect(url_for('files_list')) + + file = request.files['file'] + if file.filename == '': + return redirect(url_for('files_list')) + + if file: + original_name = file.filename + # Генерируем уникальное имя для хранения на диске (чтобы файлы с одинаковыми именами не перезаписывали друг друга) + ext = os.path.splitext(original_name)[1] + stored_name = f"{uuid.uuid4().hex}{ext}" + + filepath = os.path.join(app.config['UPLOAD_FOLDER'], stored_name) + file.save(filepath) + + size_bytes = os.path.getsize(filepath) + + with Session(engine) as db_session: + new_file = PortalFile( + original_name=original_name, + stored_name=stored_name, + size_bytes=size_bytes, + uploaded_by=session.get("user_id") + ) + db_session.add(new_file) + db_session.commit() + + return redirect(url_for('files_list')) + +@app.route("/files/download/") +@login_required +def file_download(file_id): + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if db_file: + return send_from_directory( + app.config['UPLOAD_FOLDER'], + db_file.stored_name, + as_attachment=True, + download_name=db_file.original_name + ) + return "Файл не найден", 404 + +@app.route("/files//delete", methods=["POST"]) +@login_required +def file_delete(file_id): + with Session(engine) as db_session: + db_file = db_session.get(PortalFile, file_id) + if db_file: + # Удалять может только админ или тот, кто загрузил + if session.get("role") == "admin" or db_file.uploaded_by == session.get("user_id"): + filepath = os.path.join(app.config['UPLOAD_FOLDER'], db_file.stored_name) + if os.path.exists(filepath): + os.remove(filepath) # Удаляем с диска + db_session.delete(db_file) # Удаляем из БД + db_session.commit() + return redirect(url_for("files_list")) + # --- ПРОЕКТЫ --- @app.route("/projects") def projects_list(): @@ -137,7 +225,6 @@ def project_new(): db_session.add(new_proj) db_session.commit() return redirect(url_for("projects_list")) - return render_template("project_form.html") @app.route("/projects//delete", methods=["POST"]) @@ -146,7 +233,6 @@ def project_delete(project_id): with Session(engine) as db_session: proj = db_session.get(Project, project_id) if proj: - # Удалять может только админ или владелец if session.get("role") == "admin" or proj.owner_id == session.get("user_id"): db_session.delete(proj) db_session.commit() @@ -165,7 +251,6 @@ def admin_users(): def change_user_role(user_id): new_role = request.form.get("role") valid_roles = ["admin", "professor", "lead", "member", "student", "guest"] - if new_role not in valid_roles: return "Ошибка: неверная роль", 400 diff --git a/app/models.py b/app/models.py index 90bf487..721be17 100644 --- a/app/models.py +++ b/app/models.py @@ -31,13 +31,21 @@ class GlobalStatus(SQLModel, table=True): message: Optional[str] = None updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) -# --- НОВАЯ МОДЕЛЬ --- class Project(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) title: str description: Optional[str] = None - status: str = Field(default="idea") # idea, in_progress, completed, frozen + status: str = Field(default="idea") gitea_url: Optional[str] = None owner_id: int = Field(foreign_key="user.id") is_public: bool = Field(default=True) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + +# --- НОВАЯ МОДЕЛЬ --- +class PortalFile(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + original_name: str + stored_name: str # Имя на диске (уникальное) + size_bytes: int + uploaded_by: int = Field(foreign_key="user.id") + uploaded_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/app/templates/files.html b/app/templates/files.html new file mode 100644 index 0000000..9bc6266 --- /dev/null +++ b/app/templates/files.html @@ -0,0 +1,83 @@ + + + + + Файлы — Morozovka Portal + + + +
+ ← На главную +

Файлохранилище

+ +
+
+ + + Файл не выбран +

+ +
+
+ + + + + + + + + + + + + {% for db_file, uploader_name in files %} + + + + + + + + {% else %} + + + + {% endfor %} + +
Имя файлаРазмерЗагрузилДатаДействия
{{ db_file.original_name }}{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ{{ uploader_name }}{{ db_file.uploaded_at.strftime('%d.%m.%Y %H:%M') }} + Скачать + + {% if session.get('role') == 'admin' or db_file.uploaded_by == session.get('user_id') %} +
+ +
+ {% endif %} +
Файлов пока нет. Загрузите первый!
+
+ + diff --git a/app/templates/index.html b/app/templates/index.html index 86710ac..d577d4e 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -9,13 +9,14 @@ h1 { font-size: 2.5rem; margin-bottom: 10px; background: linear-gradient(90deg, #58a6ff, #a371f7, #f778ba); -webkit-background-clip: text; background-clip: text; color: transparent; } p { color: #8b949e; margin-bottom: 20px; font-size: 1.1rem; } .badge { background: #238636; color: white; padding: 5px 10px; border-radius: 5px; font-size: 0.8rem; margin-top: 30px; font-weight: bold; } - .actions { display: flex; gap: 15px; margin-top: 20px; flex-wrap: wrap; justify-content: center; } + .actions { display: flex; gap: 15px; margin-top: 20px; flex-wrap: wrap; justify-content: center; max-width: 800px; } .btn { background: #238636; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; transition: opacity 0.2s; } .btn:hover { opacity: 0.8; } .btn-profile { background: #1f6feb; } .btn-admin { background: #da3633; } .btn-schedule { background: #d29922; } .btn-projects { background: #a371f7; } + .btn-files { background: #2ea043; } @@ -27,6 +28,7 @@ Мой профиль Расписание Проекты + Файлы {% if session.get('role') in ['admin', 'professor'] %} Управление расписанием @@ -45,6 +47,6 @@ {% endif %} -
Stage 3.1: Projects Module OK
+
Stage 3.2: File Storage OK