mirror of
https://github.com/EDeev/tablo.git
synced 2026-10-07 20:49:31 +03:00
- экспорт в JSON/CSV/PDF/PNG пускал любого вошедшего к любому расписанию, у которого есть хоть одна ссылка-приглашение (достаточно перебрать id); теперь — только владелец и редакторы, как и просмотр по id; - три копии проверки прав сведены в app/access.py; - без SECRET_KEY приложение не запускается (кроме отладки и тестов): со значением по умолчанию cookie сессии можно было подделать и войти под любым пользователем; - текст исключения от ИИ-провайдера больше не уходит в интерфейс, ошибка пишется в лог; - DATABASE_URL как альтернатива DB_*; debug в run.py — из FLASK_DEBUG; - неиспользуемые импорты и переменные удалены (ruff).
294 lines
13 KiB
Python
294 lines
13 KiB
Python
from flask import Blueprint, current_app, jsonify, render_template, request
|
||
from flask_login import login_required
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
from app import db, csrf
|
||
from app.models.schedule import Schedule, SubjectConfig, COMPLETION_TYPES
|
||
from app.models.metric import Metric, METRIC_TYPES, METRIC_LABELS
|
||
from app.services.ai_metrics import generate_metric_from_prompt
|
||
from app.access import can_edit as _check_edit_access
|
||
|
||
bp = Blueprint('subjects', __name__)
|
||
|
||
|
||
# ─── Страница предметов ───────────────────────────────────────────────────────
|
||
|
||
@bp.route('/schedules/<int:schedule_id>/subjects')
|
||
@login_required
|
||
def subjects_page(schedule_id):
|
||
schedule = Schedule.query.get_or_404(schedule_id)
|
||
can_edit = _check_edit_access(schedule)
|
||
configs = {sc.subject_name: sc for sc in schedule.subject_configs}
|
||
return render_template('schedule/subjects.html',
|
||
schedule=schedule,
|
||
configs=configs,
|
||
can_edit=can_edit,
|
||
completion_types=COMPLETION_TYPES)
|
||
|
||
|
||
# ─── Описание предмета ────────────────────────────────────────────────────────
|
||
|
||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/description',
|
||
methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def update_description(schedule_id, subject_name):
|
||
schedule = Schedule.query.get_or_404(schedule_id)
|
||
if not _check_edit_access(schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
config = _get_or_create_config(schedule_id, subject_name)
|
||
config.description = request.json.get('description', '')
|
||
db.session.commit()
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
# ─── Добавить метрику (вручную) ───────────────────────────────────────────────
|
||
|
||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/metrics',
|
||
methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def add_metric(schedule_id, subject_name):
|
||
schedule = Schedule.query.get_or_404(schedule_id)
|
||
if not _check_edit_access(schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
|
||
body = request.json or {}
|
||
metric_type = body.get('type', '')
|
||
label = body.get('label', '').strip()
|
||
config = body.get('config', {})
|
||
|
||
if metric_type not in METRIC_TYPES:
|
||
return jsonify({'error': 'Неизвестный тип'}), 400
|
||
if not label:
|
||
label = METRIC_LABELS.get(metric_type, 'Метрика')
|
||
|
||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||
metric = Metric(subject_config_id=cfg.id, type=metric_type,
|
||
label=label, config=config, progress={})
|
||
db.session.add(metric)
|
||
db.session.commit()
|
||
return jsonify({'ok': True, 'html': _render_metric_html(metric)})
|
||
|
||
|
||
# ─── Добавить метрику через AI ────────────────────────────────────────────────
|
||
|
||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/metrics/ai',
|
||
methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def add_metric_ai(schedule_id, subject_name):
|
||
schedule = Schedule.query.get_or_404(schedule_id)
|
||
if not _check_edit_access(schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
|
||
body = request.json or {}
|
||
prompt = body.get('prompt', '').strip()
|
||
if not prompt:
|
||
return jsonify({'error': 'Пустой промт'}), 400
|
||
|
||
try:
|
||
result = generate_metric_from_prompt(subject_name, prompt)
|
||
except Exception:
|
||
current_app.logger.exception('Ошибка генерации метрики')
|
||
return jsonify({'error': 'Не удалось сгенерировать метрику, попробуйте позже'}), 500
|
||
|
||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||
metric = Metric(subject_config_id=cfg.id,
|
||
type=result.get('type', 'checkpoints'),
|
||
label=result.get('label', 'Метрика'),
|
||
config=result.get('config', {}),
|
||
progress={},
|
||
ai_prompt=prompt)
|
||
db.session.add(metric)
|
||
db.session.commit()
|
||
return jsonify({'ok': True, 'html': _render_metric_html(metric)})
|
||
|
||
|
||
# ─── Сохранить прогресс метрики ───────────────────────────────────────────────
|
||
# Принимает весь объект progress целиком и перезаписывает его в БД.
|
||
|
||
@bp.route('/api/metrics/<int:metric_id>/progress', methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def save_progress(metric_id):
|
||
metric = Metric.query.get_or_404(metric_id)
|
||
if not _check_edit_access(metric.subject_config.schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
|
||
metric.progress = request.json or {}
|
||
flag_modified(metric, 'progress')
|
||
db.session.commit()
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
# ─── Сохранить позицию/размер метрики в сетке ────────────────────────────────
|
||
|
||
@bp.route('/api/metrics/<int:metric_id>/layout', methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def save_layout(metric_id):
|
||
metric = Metric.query.get_or_404(metric_id)
|
||
if not _check_edit_access(metric.subject_config.schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
body = request.json or {}
|
||
for field in ('grid_col', 'grid_row', 'grid_w', 'grid_h'):
|
||
if field in body:
|
||
setattr(metric, field, int(body[field]))
|
||
db.session.commit()
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
# ─── Переименовать метрику ────────────────────────────────────────────────────
|
||
|
||
@bp.route('/api/metrics/<int:metric_id>/rename', methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def rename_metric(metric_id):
|
||
metric = Metric.query.get_or_404(metric_id)
|
||
if not _check_edit_access(metric.subject_config.schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
label = (request.json or {}).get('label', '').strip()
|
||
if not label:
|
||
return jsonify({'error': 'Пустое название'}), 400
|
||
metric.label = label
|
||
db.session.commit()
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
# ─── Скрыть / показать предмет в расписании ──────────────────────────────────
|
||
|
||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/visibility',
|
||
methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def toggle_subject_visibility(schedule_id, subject_name):
|
||
schedule = Schedule.query.get_or_404(schedule_id)
|
||
if not _check_edit_access(schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||
cfg.hidden = not cfg.hidden
|
||
db.session.commit()
|
||
return jsonify({'ok': True, 'hidden': cfg.hidden})
|
||
|
||
|
||
# ─── Тип сдачи предмета ──────────────────────────────────────────────────────
|
||
|
||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/completion_type',
|
||
methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def set_completion_type(schedule_id, subject_name):
|
||
schedule = Schedule.query.get_or_404(schedule_id)
|
||
if not _check_edit_access(schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
value = (request.json or {}).get('completion_type')
|
||
if value and value not in COMPLETION_TYPES:
|
||
return jsonify({'error': 'Неверный тип'}), 400
|
||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||
cfg.completion_type = value or None
|
||
db.session.commit()
|
||
return jsonify({'ok': True, 'completion_type': cfg.completion_type})
|
||
|
||
|
||
# ─── Завершить / возобновить предмет ─────────────────────────────────────────
|
||
|
||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/completed',
|
||
methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def toggle_completed(schedule_id, subject_name):
|
||
schedule = Schedule.query.get_or_404(schedule_id)
|
||
if not _check_edit_access(schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||
cfg.completed = not cfg.completed
|
||
db.session.commit()
|
||
return jsonify({'ok': True, 'completed': cfg.completed})
|
||
|
||
|
||
# ─── Ссылка на СДО ───────────────────────────────────────────────────────────
|
||
|
||
@bp.route('/schedules/<int:schedule_id>/subjects/<path:subject_name>/sdo_url',
|
||
methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def set_sdo_url(schedule_id, subject_name):
|
||
schedule = Schedule.query.get_or_404(schedule_id)
|
||
if not _check_edit_access(schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
url = (request.json or {}).get('sdo_url', '').strip()
|
||
cfg = _get_or_create_config(schedule_id, subject_name)
|
||
cfg.sdo_url = url or None
|
||
db.session.commit()
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
# ─── Получить промт AI-трекера ────────────────────────────────────────────────
|
||
|
||
@bp.route('/api/metrics/<int:metric_id>/ai_prompt', methods=['GET'])
|
||
@login_required
|
||
def get_metric_ai_prompt(metric_id):
|
||
metric = Metric.query.get_or_404(metric_id)
|
||
if not _check_edit_access(metric.subject_config.schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
return jsonify({'ok': True, 'prompt': metric.ai_prompt or ''})
|
||
|
||
|
||
# ─── Сохранить порядок/размеры метрик батчем ─────────────────────────────────
|
||
|
||
@bp.route('/api/metrics/layout_batch', methods=['POST'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def save_layout_batch():
|
||
items = (request.json or {}).get('items', [])
|
||
if not items:
|
||
return jsonify({'ok': True})
|
||
ids = [int(item['id']) for item in items]
|
||
metrics_map = {m.id: m for m in Metric.query.filter(Metric.id.in_(ids)).all()}
|
||
for item in items:
|
||
metric = metrics_map.get(int(item['id']))
|
||
if not metric:
|
||
continue
|
||
if not _check_edit_access(metric.subject_config.schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
for field in ('grid_col', 'grid_row', 'grid_w', 'grid_h'):
|
||
if field in item:
|
||
setattr(metric, field, int(item[field]))
|
||
db.session.commit()
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
# ─── Удалить метрику ──────────────────────────────────────────────────────────
|
||
|
||
@bp.route('/api/metrics/<int:metric_id>', methods=['DELETE'])
|
||
@csrf.exempt
|
||
@login_required
|
||
def delete_metric(metric_id):
|
||
metric = Metric.query.get_or_404(metric_id)
|
||
if not _check_edit_access(metric.subject_config.schedule):
|
||
return jsonify({'error': 'Нет доступа'}), 403
|
||
db.session.delete(metric)
|
||
db.session.commit()
|
||
return jsonify({'ok': True})
|
||
|
||
|
||
# ─── Вспомогательные функции ──────────────────────────────────────────────────
|
||
|
||
def _get_or_create_config(schedule_id: int, subject_name: str) -> SubjectConfig:
|
||
config = SubjectConfig.query.filter_by(
|
||
schedule_id=schedule_id, subject_name=subject_name).first()
|
||
if not config:
|
||
config = SubjectConfig(schedule_id=schedule_id, subject_name=subject_name)
|
||
db.session.add(config)
|
||
db.session.flush()
|
||
return config
|
||
|
||
|
||
|
||
|
||
def _render_metric_html(metric: Metric) -> str:
|
||
from flask import render_template_string
|
||
return render_template_string(
|
||
'{% from "macros/metrics.html" import render_metric %}{{ render_metric(metric, True) }}',
|
||
metric=metric
|
||
)
|