morozovka-portal/app/main.py

360 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, 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:
status = db_session.exec(select(GlobalStatus)).first()
if not status:
db_session.add(GlobalStatus(status="free", message="Вне расписания"))
db_session.commit()
def require_role(allowed_roles):
if isinstance(allowed_roles, str):
allowed_roles = [allowed_roles]
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if session.get("role") not in allowed_roles:
return "403: Доступ запрещен. Недостаточно прав.", 403
return f(*args, **kwargs)
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"})
@app.route("/")
def index():
current_user = session.get("username")
return render_template("index.html", username=current_user)
# --- АВТОРИЗАЦИЯ ---
@app.route("/login")
def login():
gitea_auth_url = (
f"{config.GITEA_PUBLIC_URL}/login/oauth/authorize"
f"?client_id={config.GITEA_CLIENT_ID}&response_type=code"
f"&redirect_uri=https://portal.morozovka.space/auth/callback"
f"&scope=read:user"
)
return redirect(gitea_auth_url)
@app.route("/auth/callback")
def auth_callback():
code = request.args.get("code")
if not code:
return "Ошибка: Gitea не передала код авторизации.", 400
token = auth.get_gitea_token(code)
if not token:
return "Ошибка: не удалось получить токен от Gitea.", 400
gitea_user = auth.get_gitea_user(token)
if not gitea_user:
return "Ошибка: не удалось получить профиль пользователя.", 400
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first()
if not user:
new_user = User(
gitea_id=gitea_user["id"],
username=gitea_user["login"],
full_name=gitea_user.get("full_name"),
email=gitea_user.get("email"),
role="admin" if gitea_user["login"] == "git_admin" else "student"
)
db_session.add(new_user)
db_session.commit()
user = new_user
else:
user.username = gitea_user["login"]
user.full_name = gitea_user.get("full_name")
user.email = gitea_user.get("email")
db_session.add(user)
db_session.commit()
session["user_id"] = user.id
session["username"] = user.username
session["role"] = user.role
return redirect(url_for("profile"))
@app.route("/profile")
@login_required
def profile():
with Session(engine) as db_session:
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")
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():
with Session(engine) as db_session:
projects = db_session.exec(select(Project).order_by(Project.created_at.desc())).all()
return render_template("projects.html", projects=projects, role=session.get("role"))
@app.route("/projects/new", methods=["GET", "POST"])
@require_role(["admin", "professor", "lead"])
def project_new():
if request.method == "POST":
title = request.form.get("title")
description = request.form.get("description")
status = request.form.get("status")
gitea_url = request.form.get("gitea_url")
is_public = request.form.get("is_public") == "on"
with Session(engine) as db_session:
new_proj = Project(
title=title,
description=description,
status=status,
gitea_url=gitea_url,
is_public=is_public,
owner_id=session.get("user_id")
)
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"])
@require_role(["admin", "professor", "lead"])
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()
return redirect(url_for("projects_list"))
# --- АДМИНКА: ПОЛЬЗОВАТЕЛИ ---
@app.route("/admin/users")
@require_role("admin")
def admin_users():
with Session(engine) as db_session:
users = db_session.exec(select(User).order_by(User.username)).all()
return render_template("admin_users.html", users=users)
@app.route("/admin/users/<int:user_id>/role", methods=["POST"])
@require_role("admin")
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
with Session(engine) as db_session:
user = db_session.get(User, user_id)
if user:
if user.id == session.get("user_id") and new_role != "admin":
return "Ошибка: нельзя лишить самого себя прав администратора", 400
user.role = new_role
db_session.add(user)
db_session.commit()
return redirect(url_for("admin_users"))
# --- РАСПИСАНИЕ: ПУБЛИЧНАЯ ЧАСТЬ ---
@app.route("/schedule")
def public_schedule():
with Session(engine) as db_session:
current_status = db_session.exec(select(GlobalStatus)).first()
now = datetime.now(timezone.utc)
events = db_session.exec(
select(Event)
.where(Event.is_public == True)
.where(Event.start_time >= now)
.order_by(Event.start_time)
.limit(10)
).all()
return render_template("schedule.html", status=current_status, events=events)
# --- АССИСТЕНТ: API ---
@app.route("/api/assistant", methods=["POST"])
def api_assistant():
data = request.get_json()
if not data or "message" not in data:
return jsonify({"error": "Нет сообщения"}), 400
reply = assistant.get_response(data["message"])
return jsonify({"reply": reply})
# --- РАСПИСАНИЕ: АДМИН-ПАНЕЛЬ ---
@app.route("/admin/schedule")
@require_role(["admin", "professor"])
def admin_schedule():
with Session(engine) as db_session:
current_status = db_session.exec(select(GlobalStatus)).first()
now = datetime.now(timezone.utc)
events = db_session.exec(
select(Event)
.where(Event.start_time >= now)
.order_by(Event.start_time)
).all()
return render_template("admin_schedule.html", status=current_status, events=events)
@app.route("/admin/schedule/status", methods=["POST"])
@require_role(["admin", "professor"])
def update_status():
status_val = request.form.get("status")
delay = int(request.form.get("delay_minutes") or 0)
message = request.form.get("message")
with Session(engine) as db_session:
current_status = db_session.exec(select(GlobalStatus)).first()
if current_status:
current_status.status = status_val
current_status.delay_minutes = delay
current_status.message = message
current_status.updated_at = datetime.now(timezone.utc)
db_session.add(current_status)
db_session.commit()
return redirect(url_for("admin_schedule"))
@app.route("/admin/schedule/event/add", methods=["POST"])
@require_role(["admin", "professor"])
def add_event():
title = request.form.get("title")
event_type = request.form.get("event_type")
start_str = request.form.get("start_time")
end_str = request.form.get("end_time")
location = request.form.get("location")
description = request.form.get("description")
is_public = request.form.get("is_public") == "on"
start_time = datetime.strptime(start_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
end_time = datetime.strptime(end_str, "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
with Session(engine) as db_session:
new_event = Event(
title=title,
event_type=event_type,
start_time=start_time,
end_time=end_time,
location=location,
description=description,
is_public=is_public
)
db_session.add(new_event)
db_session.commit()
return redirect(url_for("admin_schedule"))
@app.route("/admin/schedule/event/<int:event_id>/delete", methods=["POST"])
@require_role(["admin", "professor"])
def delete_event(event_id):
with Session(engine) as db_session:
event = db_session.get(Event, event_id)
if event:
db_session.delete(event)
db_session.commit()
return redirect(url_for("admin_schedule"))