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 . import config, auth, assistant
from sqlmodel import Session, select
from .models import User, Event, GlobalStatus
from .models import User, Event, GlobalStatus, Project
from datetime import datetime, timezone
from functools import wraps
app = Flask(__name__)
app.secret_key = config.WEB_SECRET
# Инициализация БД и дефолтного статуса
with app.app_context():
init_db()
with Session(engine) as db_session:
@ -18,7 +17,6 @@ with app.app_context():
db_session.add(GlobalStatus(status="free", message="Вне расписания"))
db_session.commit()
# --- Декоратор проверки ролей ---
def require_role(allowed_roles):
if isinstance(allowed_roles, str):
allowed_roles = [allowed_roles]
@ -110,6 +108,50 @@ def logout():
session.clear()
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")
@require_role("admin")
@ -132,11 +174,9 @@ def change_user_role(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"))
# --- РАСПИСАНИЕ: ПУБЛИЧНАЯ ЧАСТЬ ---
@ -144,7 +184,6 @@ def change_user_role(user_id):
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)
@ -153,7 +192,6 @@ def public_schedule():
.order_by(Event.start_time)
.limit(10)
).all()
return render_template("schedule.html", status=current_status, events=events)
# --- АССИСТЕНТ: API ---
@ -162,10 +200,7 @@ def api_assistant():
data = request.get_json()
if not data or "message" not in data:
return jsonify({"error": "Нет сообщения"}), 400
user_message = data["message"]
reply = assistant.get_response(user_message)
reply = assistant.get_response(data["message"])
return jsonify({"reply": reply})
# --- РАСПИСАНИЕ: АДМИН-ПАНЕЛЬ ---
@ -198,7 +233,6 @@ def update_status():
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"])
@ -227,7 +261,6 @@ def add_event():
)
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"])

View File

@ -8,27 +8,36 @@ class User(SQLModel, table=True):
username: str = Field(index=True)
full_name: 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)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Event(SQLModel, table=True):
"""Событие в расписании."""
id: Optional[int] = Field(default=None, primary_key=True)
title: str
event_type: str = Field(default="lecture") # lecture, workshop, meeting, personal
event_type: str = Field(default="lecture")
start_time: datetime
end_time: datetime
location: 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)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class GlobalStatus(SQLModel, table=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)
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
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-admin { background: #da3633; }
.btn-schedule { background: #d29922; }
.btn-projects { background: #a371f7; }
</style>
</head>
<body>
@ -25,6 +26,7 @@
<div class="actions">
<a href="/profile" class="btn btn-profile">Мой профиль</a>
<a href="/schedule" class="btn btn-schedule">Расписание</a>
<a href="/projects" class="btn btn-projects">Проекты</a>
{% if session.get('role') in ['admin', 'professor'] %}
<a href="/admin/schedule" class="btn btn-schedule">Управление расписанием</a>
@ -35,13 +37,14 @@
{% endif %}
</div>
{% else %}
<p>Скелет системы успешно запущен! (Flask Edition)</p>
<p>Скелет системы успешно запущен!</p>
<div class="actions">
<a href="/login" class="btn">Войти через Gitea</a>
<a href="/schedule" class="btn btn-schedule">Расписание</a>
<a href="/projects" class="btn btn-projects">Проекты</a>
</div>
{% endif %}
<div class="badge">Stage 2.2: Schedule Management OK</div>
<div class="badge">Stage 3.1: Projects Module OK</div>
</body>
</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()