feat: add file storage module with 100MB limit
This commit is contained in:
parent
b46c0eceec
commit
acb111d7ff
107
app/main.py
107
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/<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")
|
||||
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/<int:project_id>/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
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
83
app/templates/files.html
Normal file
83
app/templates/files.html
Normal 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>
|
||||
|
|
@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
<a href="/profile" class="btn btn-profile">Мой профиль</a>
|
||||
<a href="/schedule" class="btn btn-schedule">Расписание</a>
|
||||
<a href="/projects" class="btn btn-projects">Проекты</a>
|
||||
<a href="/files" class="btn btn-files">Файлы</a>
|
||||
|
||||
{% if session.get('role') in ['admin', 'professor'] %}
|
||||
<a href="/admin/schedule" class="btn btn-schedule">Управление расписанием</a>
|
||||
|
|
@ -45,6 +47,6 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="badge">Stage 3.1: Projects Module OK</div>
|
||||
<div class="badge">Stage 3.2: File Storage OK</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user