morozovka-portal/app/main.py

498 lines
21 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
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from itsdangerous import URLSafeTimedSerializer
from werkzeug.security import generate_password_hash, check_password_hash
from flask import Flask, render_template, jsonify, redirect, url_for, request, session, send_from_directory
from .db import init_db, engine
from . import config, auth, assistant
from sqlmodel import Session, select
from .models import User, Event, GlobalStatus, Project, PortalFile, UserSession
from datetime import datetime, timezone
from functools import wraps
app = Flask(__name__)
app.secret_key = config.WEB_SECRET
serializer = URLSafeTimedSerializer(app.secret_key)
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()
@app.before_request
def validate_session():
if "session_id" in session:
with Session(engine) as db_session:
db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first()
if not db_sess:
session.clear()
return redirect(url_for("login"))
def send_email(to_email, subject, body):
if not config.SMTP_SERVER:
print("\n" + "="*50, flush=True)
print(f"📧 EMAIL ДЛЯ: {to_email}", flush=True)
print(f"Тема: {subject}", flush=True)
print(body, flush=True)
print("="*50 + "\n", flush=True)
return True
try:
msg = MIMEMultipart()
msg['From'] = config.SMTP_FROM
msg['To'] = to_email
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain'))
server = smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT)
server.starttls()
server.login(config.SMTP_USER, config.SMTP_PASSWORD)
server.send_message(msg)
server.quit()
return True
except Exception as e:
print(f"Ошибка отправки email: {e}", flush=True)
return False
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"})
@app.route("/")
def index():
return render_template("index.html", username=session.get("username"))
# --- АУТЕНТИФИКАЦИЯ ---
@app.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
username = request.form.get("username")
email = request.form.get("email")
password = request.form.get("password")
full_name = request.form.get("full_name")
organization = request.form.get("organization")
with Session(engine) as db_session:
if db_session.exec(select(User).where(User.username == username)).first():
return render_template("register.html", error="Логин уже занят")
if db_session.exec(select(User).where(User.email == email)).first():
return render_template("register.html", error="Email уже используется")
new_user = User(
username=username,
email=email,
password_hash=generate_password_hash(password),
full_name=full_name,
organization=organization,
role="student",
is_verified=False
)
db_session.add(new_user)
db_session.commit()
token = serializer.dumps(email, salt='email-confirm')
confirm_url = url_for('verify_email', token=token, _external=True)
confirm_url = confirm_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
body = f"Привет, {full_name}!\n\nДля подтверждения почты перейди по ссылке:\n{confirm_url}\n"
send_email(email, "Подтверждение почты в Morozovka Portal", body)
return render_template("login.html", msg="Регистрация успешна. Проверьте почту для подтверждения.")
return render_template("register.html")
@app.route("/verify_email/<token>")
def verify_email(token):
try:
email = serializer.loads(token, salt='email-confirm', max_age=86400)
except Exception:
return "Ссылка недействительна или устарела.", 400
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.email == email)).first()
if user:
user.is_verified = True
db_session.add(user)
db_session.commit()
return redirect(url_for("login", msg="Почта подтверждена! Теперь можно войти."))
return "Пользователь не найден.", 404
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.username == username)).first()
if not user or not user.password_hash or not check_password_hash(user.password_hash, password):
return render_template("login.html", error="Неверный логин или пароль")
if not user.is_verified:
return render_template("login.html", error="Подтвердите почту перед входом")
session_token = str(uuid.uuid4())
db_session.add(UserSession(
session_token=session_token,
user_id=user.id,
ip_address=request.remote_addr,
user_agent=request.user_agent.string
))
db_session.commit()
session["user_id"] = user.id
session["username"] = user.username
session["role"] = user.role
session["session_id"] = session_token
return redirect(url_for("index"))
msg = request.args.get("msg")
return render_template("login.html", msg=msg)
@app.route("/login/gitea")
def login_gitea():
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 "Ошибка профиля Gitea", 400
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.gitea_id == gitea_user["id"])).first()
if not user:
user = db_session.exec(select(User).where(User.email == gitea_user.get("email"))).first()
if user:
user.gitea_id = gitea_user["id"]
else:
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",
is_verified=True
)
db_session.add(user)
db_session.commit()
session_token = str(uuid.uuid4())
db_session.add(UserSession(
session_token=session_token,
user_id=user.id,
ip_address=request.remote_addr,
user_agent=request.user_agent.string
))
db_session.commit()
session["user_id"] = user.id
session["username"] = user.username
session["role"] = user.role
session["session_id"] = session_token
return redirect(url_for("index"))
@app.route("/logout")
def logout():
if "session_id" in session:
with Session(engine) as db_session:
db_sess = db_session.exec(select(UserSession).where(UserSession.session_token == session["session_id"])).first()
if db_sess:
db_session.delete(db_sess)
db_session.commit()
session.clear()
return redirect(url_for("index"))
# --- СБРОС ПАРОЛЯ ---
@app.route("/forgot_password", methods=["GET", "POST"])
def forgot_password():
if request.method == "POST":
email = request.form.get("email")
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.email == email)).first()
if user:
token = serializer.dumps(email, salt='password-reset')
reset_url = url_for('reset_password', token=token, _external=True)
reset_url = reset_url.replace("127.0.0.1:8000", "portal.morozovka.space").replace("http://", "https://")
body = f"Для сброса пароля перейди по ссылке:\n{reset_url}\n"
send_email(email, "Сброс пароля Morozovka Portal", body)
return render_template("login.html", msg="Если такой email есть, инструкция отправлена.")
return render_template("forgot_password.html")
@app.route("/reset_password/<token>", methods=["GET", "POST"])
def reset_password(token):
try:
email = serializer.loads(token, salt='password-reset', max_age=3600)
except Exception:
return "Ссылка недействительна или устарела.", 400
if request.method == "POST":
new_password = request.form.get("password")
with Session(engine) as db_session:
user = db_session.exec(select(User).where(User.email == email)).first()
if user:
user.password_hash = generate_password_hash(new_password)
db_session.add(user)
db_session.commit()
return redirect(url_for("login", msg="Пароль успешно изменен!"))
return render_template("reset_password.html", token=token)
# --- УПРАВЛЕНИЕ СЕССИЯМИ ---
@app.route("/sessions")
@login_required
def sessions_list():
with Session(engine) as db_session:
sessions = db_session.exec(
select(UserSession)
.where(UserSession.user_id == session.get("user_id"))
.order_by(UserSession.created_at.desc())
).all()
return render_template("sessions.html", sessions=sessions, current_token=session.get("session_id"))
@app.route("/sessions/logout_all", methods=["POST"])
@login_required
def logout_all():
with Session(engine) as db_session:
user_sessions = db_session.exec(select(UserSession).where(UserSession.user_id == session.get("user_id"))).all()
for s in user_sessions:
db_session.delete(s)
db_session.commit()
session.clear()
return redirect(url_for("login", msg="Вы вышли со всех устройств."))
# --- ПРОФИЛЬ ---
@app.route("/profile")
@login_required
def profile():
with Session(engine) as db_session:
user = db_session.get(User, session.get("user_id"))
return render_template("profile.html", user=user)
# --- РАСПИСАНИЕ ---
@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)
@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
return jsonify({"reply": assistant.get_response(data["message"])})
@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_time = datetime.strptime(request.form.get("start_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
end_time = datetime.strptime(request.form.get("end_time"), "%Y-%m-%dT%H:%M").replace(tzinfo=timezone.utc)
with Session(engine) as db_session:
db_session.add(Event(title=title, event_type=event_type, start_time=start_time, end_time=end_time, location=request.form.get("location"), description=request.form.get("description"), is_public=request.form.get("is_public") == "on"))
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"))
# --- ПРОЕКТЫ ---
@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":
with Session(engine) as db_session:
db_session.add(Project(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", owner_id=session.get("user_id")))
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 and (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("/admin/users/<int:user_id>/delete", methods=["POST"])
@require_role("admin")
def delete_user(user_id):
if user_id == session.get("user_id"):
return "Ошибка: нельзя удалить самого себя", 400
with Session(engine) as db_session:
# Сначала удаляем все сессии пользователя (чтобы не было ошибки внешнего ключа)
sessions = db_session.exec(select(UserSession).where(UserSession.user_id == user_id)).all()
for s in sessions:
db_session.delete(s)
# Затем удаляем самого пользователя
user = db_session.get(User, user_id)
if user:
db_session.delete(user)
db_session.commit()
return redirect(url_for("admin_users"))
# --- ФАЙЛЫ ---
@app.route("/files")
@login_required
def files_list():
with Session(engine) as db_session:
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)
with Session(engine) as db_session:
db_session.add(PortalFile(original_name=original_name, stored_name=stored_name, size_bytes=os.path.getsize(filepath), uploaded_by=session.get("user_id")))
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 and (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"))