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 .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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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; }
|
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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user