feat: add file storage module with 100MB limit

This commit is contained in:
Ivan Budakov 2026-10-19 18:28:38 +03:00
parent b46c0eceec
commit acb111d7ff
4 changed files with 193 additions and 15 deletions

View File

@ -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 .db import init_db, engine
from . import config, auth, assistant from . import config, auth, assistant
from sqlmodel import Session, select 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 datetime import datetime, timezone
from functools import wraps from functools import wraps
app = Flask(__name__) app = Flask(__name__)
app.secret_key = config.WEB_SECRET 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(): with app.app_context():
init_db() init_db()
with Session(engine) as db_session: with Session(engine) as db_session:
@ -29,6 +38,14 @@ def require_role(allowed_roles):
return decorated_function return decorated_function
return decorator 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") @app.route("/healthz")
def healthz(): def healthz():
return jsonify({"status": "ok", "service": "morozovka-portal"}) return jsonify({"status": "ok", "service": "morozovka-portal"})
@ -90,17 +107,13 @@ def auth_callback():
return redirect(url_for("profile")) return redirect(url_for("profile"))
@app.route("/profile") @app.route("/profile")
@login_required
def profile(): def profile():
user_id = session.get("user_id")
if not user_id:
return redirect(url_for("login"))
with Session(engine) as db_session: 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: if not user:
session.clear() session.clear()
return redirect(url_for("login")) return redirect(url_for("login"))
return render_template("profile.html", user=user) return render_template("profile.html", user=user)
@app.route("/logout") @app.route("/logout")
@ -108,6 +121,81 @@ def logout():
session.clear() session.clear()
return redirect(url_for("index")) 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/<int:file_id>")
@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/<int:file_id>/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") @app.route("/projects")
def projects_list(): def projects_list():
@ -137,7 +225,6 @@ def project_new():
db_session.add(new_proj) db_session.add(new_proj)
db_session.commit() db_session.commit()
return redirect(url_for("projects_list")) return redirect(url_for("projects_list"))
return render_template("project_form.html") return render_template("project_form.html")
@app.route("/projects/<int:project_id>/delete", methods=["POST"]) @app.route("/projects/<int:project_id>/delete", methods=["POST"])
@ -146,7 +233,6 @@ def project_delete(project_id):
with Session(engine) as db_session: with Session(engine) as db_session:
proj = db_session.get(Project, project_id) proj = db_session.get(Project, project_id)
if proj: if proj:
# Удалять может только админ или владелец
if session.get("role") == "admin" or proj.owner_id == session.get("user_id"): if session.get("role") == "admin" or proj.owner_id == session.get("user_id"):
db_session.delete(proj) db_session.delete(proj)
db_session.commit() db_session.commit()
@ -165,7 +251,6 @@ def admin_users():
def change_user_role(user_id): def change_user_role(user_id):
new_role = request.form.get("role") new_role = request.form.get("role")
valid_roles = ["admin", "professor", "lead", "member", "student", "guest"] valid_roles = ["admin", "professor", "lead", "member", "student", "guest"]
if new_role not in valid_roles: if new_role not in valid_roles:
return "Ошибка: неверная роль", 400 return "Ошибка: неверная роль", 400

View File

@ -31,13 +31,21 @@ class GlobalStatus(SQLModel, table=True):
message: Optional[str] = None message: Optional[str] = None
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# --- НОВАЯ МОДЕЛЬ ---
class Project(SQLModel, table=True): class Project(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
title: str title: str
description: Optional[str] = None description: Optional[str] = None
status: str = Field(default="idea") # idea, in_progress, completed, frozen status: str = Field(default="idea")
gitea_url: Optional[str] = None gitea_url: Optional[str] = None
owner_id: int = Field(foreign_key="user.id") owner_id: int = Field(foreign_key="user.id")
is_public: bool = Field(default=True) is_public: bool = Field(default=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 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))

83
app/templates/files.html Normal file
View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Файлы — Morozovka Portal</title>
<style>
body { background: #0d1117; color: #e6edf3; font-family: sans-serif; padding: 40px; }
.container { max-width: 1000px; margin: 0 auto; }
h1 { color: #58a6ff; margin-top: 0; }
.upload-box { background: #161b22; border: 1px dashed #30363d; border-radius: 8px; padding: 30px; text-align: center; margin-bottom: 30px; }
.upload-box input[type="file"] { display: none; }
.upload-label { display: inline-block; padding: 10px 20px; background: #21262d; border: 1px solid #30363d; border-radius: 5px; cursor: pointer; font-weight: bold; margin-bottom: 15px; }
.upload-label:hover { background: #30363d; }
.upload-btn { background: #238636; color: white; border: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; cursor: pointer; }
.upload-btn:hover { background: #2ea043; }
#file-name-display { margin-left: 15px; color: #8b949e; font-size: 0.9rem; }
table { width: 100%; border-collapse: collapse; background: #161b22; border-radius: 8px; overflow: hidden; }
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #8b949e; font-size: 0.85rem; text-transform: uppercase; }
tr:hover { background: #21262d; }
.btn-download { color: #58a6ff; text-decoration: none; font-weight: bold; }
.btn-download:hover { text-decoration: underline; }
.btn-delete { background: none; border: none; color: #da3633; cursor: pointer; font-weight: bold; }
.btn-delete:hover { text-decoration: underline; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← На главную</a>
<h1>Файлохранилище</h1>
<div class="upload-box">
<form action="/files/upload" method="POST" enctype="multipart/form-data">
<label for="file-upload" class="upload-label">Выбрать файл</label>
<input id="file-upload" type="file" name="file" required onchange="document.getElementById('file-name-display').textContent = this.files[0].name">
<span id="file-name-display">Файл не выбран</span>
<br><br>
<button type="submit" class="upload-btn">Загрузить (макс 100 МБ)</button>
</form>
</div>
<table>
<thead>
<tr>
<th>Имя файла</th>
<th>Размер</th>
<th>Загрузил</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for db_file, uploader_name in files %}
<tr>
<td><strong>{{ db_file.original_name }}</strong></td>
<td>{{ (db_file.size_bytes / 1024 / 1024) | round(2) }} МБ</td>
<td>{{ uploader_name }}</td>
<td>{{ db_file.uploaded_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td style="display: flex; gap: 15px;">
<a href="/files/download/{{ db_file.id }}" class="btn-download">Скачать</a>
{% if session.get('role') == 'admin' or db_file.uploaded_by == session.get('user_id') %}
<form action="/files/{{ db_file.id }}/delete" method="POST" onsubmit="return confirm('Удалить файл?');">
<button type="submit" class="btn-delete">Удалить</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="text-align: center; color: #8b949e;">Файлов пока нет. Загрузите первый!</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</body>
</html>

View File

@ -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; } 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; } 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; } .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 { 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:hover { opacity: 0.8; }
.btn-profile { background: #1f6feb; } .btn-profile { background: #1f6feb; }
.btn-admin { background: #da3633; } .btn-admin { background: #da3633; }
.btn-schedule { background: #d29922; } .btn-schedule { background: #d29922; }
.btn-projects { background: #a371f7; } .btn-projects { background: #a371f7; }
.btn-files { background: #2ea043; }
</style> </style>
</head> </head>
<body> <body>
@ -27,6 +28,7 @@
<a href="/profile" class="btn btn-profile">Мой профиль</a> <a href="/profile" class="btn btn-profile">Мой профиль</a>
<a href="/schedule" class="btn btn-schedule">Расписание</a> <a href="/schedule" class="btn btn-schedule">Расписание</a>
<a href="/projects" class="btn btn-projects">Проекты</a> <a href="/projects" class="btn btn-projects">Проекты</a>
<a href="/files" class="btn btn-files">Файлы</a>
{% if session.get('role') in ['admin', 'professor'] %} {% if session.get('role') in ['admin', 'professor'] %}
<a href="/admin/schedule" class="btn btn-schedule">Управление расписанием</a> <a href="/admin/schedule" class="btn btn-schedule">Управление расписанием</a>
@ -45,6 +47,6 @@
</div> </div>
{% endif %} {% endif %}
<div class="badge">Stage 3.1: Projects Module OK</div> <div class="badge">Stage 3.2: File Storage OK</div>
</body> </body>
</html> </html>