feat: add rule-based assistant, seed script and projects module

This commit is contained in:
Ivan Budakov 2026-10-19 18:22:31 +03:00
parent d2fbee3a12
commit b46c0eceec
6 changed files with 247 additions and 21 deletions

View File

@ -2,14 +2,13 @@ from flask import Flask, render_template, jsonify, redirect, url_for, request, s
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 from .models import User, Event, GlobalStatus, Project
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
# Инициализация БД и дефолтного статуса
with app.app_context(): with app.app_context():
init_db() init_db()
with Session(engine) as db_session: with Session(engine) as db_session:
@ -18,7 +17,6 @@ with app.app_context():
db_session.add(GlobalStatus(status="free", message="Вне расписания")) db_session.add(GlobalStatus(status="free", message="Вне расписания"))
db_session.commit() db_session.commit()
# --- Декоратор проверки ролей ---
def require_role(allowed_roles): def require_role(allowed_roles):
if isinstance(allowed_roles, str): if isinstance(allowed_roles, str):
allowed_roles = [allowed_roles] allowed_roles = [allowed_roles]
@ -110,6 +108,50 @@ def logout():
session.clear() session.clear()
return redirect(url_for("index")) return redirect(url_for("index"))
# --- ПРОЕКТЫ ---
@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") @app.route("/admin/users")
@require_role("admin") @require_role("admin")
@ -132,11 +174,9 @@ def change_user_role(user_id):
if user: if user:
if user.id == session.get("user_id") and new_role != "admin": if user.id == session.get("user_id") and new_role != "admin":
return "Ошибка: нельзя лишить самого себя прав администратора", 400 return "Ошибка: нельзя лишить самого себя прав администратора", 400
user.role = new_role user.role = new_role
db_session.add(user) db_session.add(user)
db_session.commit() db_session.commit()
return redirect(url_for("admin_users")) return redirect(url_for("admin_users"))
# --- РАСПИСАНИЕ: ПУБЛИЧНАЯ ЧАСТЬ --- # --- РАСПИСАНИЕ: ПУБЛИЧНАЯ ЧАСТЬ ---
@ -144,7 +184,6 @@ def change_user_role(user_id):
def public_schedule(): def public_schedule():
with Session(engine) as db_session: with Session(engine) as db_session:
current_status = db_session.exec(select(GlobalStatus)).first() current_status = db_session.exec(select(GlobalStatus)).first()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
events = db_session.exec( events = db_session.exec(
select(Event) select(Event)
@ -153,7 +192,6 @@ def public_schedule():
.order_by(Event.start_time) .order_by(Event.start_time)
.limit(10) .limit(10)
).all() ).all()
return render_template("schedule.html", status=current_status, events=events) return render_template("schedule.html", status=current_status, events=events)
# --- АССИСТЕНТ: API --- # --- АССИСТЕНТ: API ---
@ -162,10 +200,7 @@ def api_assistant():
data = request.get_json() data = request.get_json()
if not data or "message" not in data: if not data or "message" not in data:
return jsonify({"error": "Нет сообщения"}), 400 return jsonify({"error": "Нет сообщения"}), 400
reply = assistant.get_response(data["message"])
user_message = data["message"]
reply = assistant.get_response(user_message)
return jsonify({"reply": reply}) return jsonify({"reply": reply})
# --- РАСПИСАНИЕ: АДМИН-ПАНЕЛЬ --- # --- РАСПИСАНИЕ: АДМИН-ПАНЕЛЬ ---
@ -198,7 +233,6 @@ def update_status():
current_status.updated_at = datetime.now(timezone.utc) current_status.updated_at = datetime.now(timezone.utc)
db_session.add(current_status) db_session.add(current_status)
db_session.commit() db_session.commit()
return redirect(url_for("admin_schedule")) return redirect(url_for("admin_schedule"))
@app.route("/admin/schedule/event/add", methods=["POST"]) @app.route("/admin/schedule/event/add", methods=["POST"])
@ -227,7 +261,6 @@ def add_event():
) )
db_session.add(new_event) db_session.add(new_event)
db_session.commit() db_session.commit()
return redirect(url_for("admin_schedule")) return redirect(url_for("admin_schedule"))
@app.route("/admin/schedule/event/<int:event_id>/delete", methods=["POST"]) @app.route("/admin/schedule/event/<int:event_id>/delete", methods=["POST"])

View File

@ -8,27 +8,36 @@ class User(SQLModel, table=True):
username: str = Field(index=True) username: str = Field(index=True)
full_name: Optional[str] = None full_name: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
role: str = Field(default="student") # admin, professor, lead, member, student role: str = Field(default="student")
is_active: bool = Field(default=True) is_active: 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 Event(SQLModel, table=True): class Event(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
event_type: str = Field(default="lecture") # lecture, workshop, meeting, personal event_type: str = Field(default="lecture")
start_time: datetime start_time: datetime
end_time: datetime end_time: datetime
location: Optional[str] = None location: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
status: str = Field(default="scheduled") # scheduled, moved, cancelled, delayed status: str = Field(default="scheduled")
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 GlobalStatus(SQLModel, table=True): class GlobalStatus(SQLModel, table=True):
"""Текущий статус Морозова."""
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
status: str = Field(default="free") # free, on_place, delayed, away, cancelled status: str = Field(default="free")
delay_minutes: int = Field(default=0) delay_minutes: int = Field(default=0)
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):
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
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))

View File

@ -15,6 +15,7 @@
.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; }
</style> </style>
</head> </head>
<body> <body>
@ -25,6 +26,7 @@
<div class="actions"> <div class="actions">
<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>
{% 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>
@ -35,13 +37,14 @@
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
<p>Скелет системы успешно запущен! (Flask Edition)</p> <p>Скелет системы успешно запущен!</p>
<div class="actions"> <div class="actions">
<a href="/login" class="btn">Войти через Gitea</a> <a href="/login" class="btn">Войти через Gitea</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>
</div> </div>
{% endif %} {% endif %}
<div class="badge">Stage 2.2: Schedule Management OK</div> <div class="badge">Stage 3.1: Projects Module OK</div>
</body> </body>
</html> </html>

View File

@ -0,0 +1,54 @@
<!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: 600px; margin: 0 auto; }
h2 { color: #58a6ff; }
.form-group { margin-bottom: 15px; display: flex; flex-direction: column; }
label { margin-bottom: 5px; color: #8b949e; font-size: 0.9rem; }
input, select, textarea { padding: 10px; background: #0d1117; color: white; border: 1px solid #30363d; border-radius: 5px; font-family: inherit; }
textarea { resize: vertical; min-height: 100px; }
button { padding: 10px 20px; background: #238636; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; margin-top: 10px; }
button:hover { background: #2ea043; }
.checkbox-group { flex-direction: row; align-items: center; gap: 10px; }
.back-link { display: block; margin-bottom: 20px; color: #8b949e; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<a href="/projects" class="back-link">← К проектам</a>
<h2>Создание нового проекта</h2>
<form method="POST">
<div class="form-group">
<label>Название проекта *</label>
<input type="text" name="title" required>
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description"></textarea>
</div>
<div class="form-group">
<label>Статус</label>
<select name="status">
<option value="idea">Идея</option>
<option value="in_progress">В работе</option>
<option value="completed">Завершён</option>
<option value="frozen">Заморожен</option>
</select>
</div>
<div class="form-group">
<label>Ссылка на репозиторий Gitea</label>
<input type="url" name="gitea_url" placeholder="https://git.morozovka.space/user/repo">
</div>
<div class="form-group checkbox-group">
<input type="checkbox" name="is_public" id="is_public" checked>
<label for="is_public" style="margin: 0;">Показывать публично</label>
</div>
<button type="submit">Создать проект</button>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,74 @@
<!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; }
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.btn-add { background: #238636; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; font-weight: bold; }
.btn-add:hover { background: #2ea043; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; }
.card h3 { margin: 0 0 10px 0; color: #e6edf3; }
.card p { color: #8b949e; font-size: 0.9rem; flex-grow: 1; margin-bottom: 15px; }
.status-badge { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; text-transform: uppercase; margin-bottom: 10px; }
.status-idea { background: #30363d; color: white; }
.status-in_progress { background: #1f6feb; color: white; }
.status-completed { background: #238636; color: white; }
.status-frozen { background: #da3633; color: white; }
.card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; border-top: 1px solid #30363d; padding-top: 15px; }
.repo-link { color: #58a6ff; text-decoration: none; font-size: 0.85rem; }
.delete-btn { background: none; border: none; color: #da3633; cursor: pointer; font-size: 0.85rem; }
.delete-btn: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>
<div class="header-actions">
<h1>Каталог проектов</h1>
{% if role in ['admin', 'professor', 'lead'] %}
<a href="/projects/new" class="btn-add">+ Добавить проект</a>
{% endif %}
</div>
<div class="grid">
{% for proj in projects %}
<div class="card">
<div>
<span class="status-badge status-{{ proj.status }}">
{% if proj.status == 'idea' %}Идея
{% elif proj.status == 'in_progress' %}В работе
{% elif proj.status == 'completed' %}Завершён
{% else %}Заморожен{% endif %}
</span>
</div>
<h3>{{ proj.title }}</h3>
<p>{{ proj.description or 'Описание отсутствует.' }}</p>
<div class="card-footer">
{% if proj.gitea_url %}
<a href="{{ proj.gitea_url }}" target="_blank" class="repo-link">📦 Репозиторий</a>
{% else %}
<span style="color: #8b949e; font-size: 0.85rem;">Без репозитория</span>
{% endif %}
{% if role == 'admin' or (role in ['professor', 'lead'] and proj.owner_id == session.get('user_id')) %}
<form action="/projects/{{ proj.id }}/delete" method="POST" onsubmit="return confirm('Удалить проект?');">
<button type="submit" class="delete-btn">Удалить</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<p style="color: #8b949e;">Проектов пока нет.</p>
{% endfor %}
</div>
</div>
</body>
</html>

53
seed_schedule.py Normal file
View File

@ -0,0 +1,53 @@
from datetime import datetime, timezone, timedelta
from sqlmodel import Session, select
from app.db import engine
from app.models import Event, GlobalStatus
def seed_data():
with Session(engine) as session:
# Обновляем статус
status = session.exec(select(GlobalStatus)).first()
if status:
status.status = "on_place"
status.message = "Провожу пары по расписанию"
status.updated_at = datetime.now(timezone.utc)
session.add(status)
# Добавляем события (относительно текущего времени)
now = datetime.now(timezone.utc)
events = [
Event(
title="Лекция: Введение в веб-разработку",
event_type="lecture",
start_time=now + timedelta(days=1, hours=2),
end_time=now + timedelta(days=1, hours=4),
location="Ауд. 405",
description="Разбираем основы HTTP и клиент-серверной архитектуры."
),
Event(
title="Мастер-класс по пайке",
event_type="workshop",
start_time=now + timedelta(days=2, hours=5),
end_time=now + timedelta(days=2, hours=8),
location="Лаборатория 101",
description="Сборка простого светодиодного индикатора."
),
Event(
title="Встреча с командой Неймарк",
event_type="meeting",
start_time=now + timedelta(days=3, hours=1),
end_time=now + timedelta(days=3, hours=2),
location="Переговорная",
is_public=False
)
]
for e in events:
session.add(e)
session.commit()
print("[+] Тестовые события успешно добавлены в БД!")
if __name__ == "__main__":
seed_data()