diff --git a/app/__init__.py b/app/__init__.py index 94c126a..d1bc8c2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -22,10 +22,11 @@ def create_app(): login_manager.login_message = 'Войдите для доступа' login_manager.login_message_category = 'warning' - from app.routes import auth, schedules, subjects, shares + from app.routes import auth, schedules, subjects, shares, export app.register_blueprint(auth.bp) app.register_blueprint(schedules.bp) app.register_blueprint(subjects.bp) app.register_blueprint(shares.bp) + app.register_blueprint(export.bp) return app diff --git a/app/config.py b/app/config.py index cb05107..4ee8317 100644 --- a/app/config.py +++ b/app/config.py @@ -10,5 +10,9 @@ class Config: f"@{os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}" ) SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_pre_ping': True, + 'pool_recycle': 1800, + } MAX_CONTENT_LENGTH = 20 * 1024 * 1024 WTF_CSRF_ENABLED = True diff --git a/app/routes/export.py b/app/routes/export.py new file mode 100644 index 0000000..242066b --- /dev/null +++ b/app/routes/export.py @@ -0,0 +1,95 @@ +import urllib.parse +from flask import Blueprint, Response, redirect, url_for, flash +from flask_login import login_required, current_user +from app.models.schedule import Schedule +from app.models.share import Share +from app import db + +bp = Blueprint('export', __name__) + + +def _can_view(schedule: Schedule) -> bool: + if schedule.user_id == current_user.id: + return True + share = Share.query.filter_by(schedule_id=schedule.id).first() + return share is not None + + +@bp.route('/schedules//export/json') +@login_required +def export_json(schedule_id: int): + schedule = Schedule.query.get_or_404(schedule_id) + if not _can_view(schedule): + flash('Нет доступа', 'danger') + return redirect(url_for('schedules.profile')) + from app.services.export import export_json as svc_json + data = svc_json(schedule) + filename = _safe_name(schedule.name) + '.json' + return Response( + data, + mimetype='application/json', + headers={'Content-Disposition': _cd(filename)}, + ) + + +@bp.route('/schedules//export/csv') +@login_required +def export_csv(schedule_id: int): + schedule = Schedule.query.get_or_404(schedule_id) + if not _can_view(schedule): + flash('Нет доступа', 'danger') + return redirect(url_for('schedules.profile')) + from app.services.export import export_csv as svc_csv + data = svc_csv(schedule) + filename = _safe_name(schedule.name) + '.csv' + return Response( + data, + mimetype='text/csv; charset=utf-8-sig', + headers={'Content-Disposition': _cd(filename)}, + ) + + +@bp.route('/schedules//export/pdf') +@login_required +def export_pdf(schedule_id: int): + schedule = Schedule.query.get_or_404(schedule_id) + if not _can_view(schedule): + flash('Нет доступа', 'danger') + return redirect(url_for('schedules.profile')) + from app.services.export import export_pdf as svc_pdf + data = svc_pdf(schedule) + filename = _safe_name(schedule.name) + '.pdf' + return Response( + data, + mimetype='application/pdf', + headers={'Content-Disposition': _cd(filename)}, + ) + + +@bp.route('/schedules//export/png') +@login_required +def export_png(schedule_id: int): + schedule = Schedule.query.get_or_404(schedule_id) + if not _can_view(schedule): + flash('Нет доступа', 'danger') + return redirect(url_for('schedules.profile')) + from app.services.export import export_png as svc_png + data = svc_png(schedule) + filename = _safe_name(schedule.name) + '.png' + return Response( + data, + mimetype='image/png', + headers={'Content-Disposition': _cd(filename)}, + ) + + +def _safe_name(name: str) -> str: + safe = ''.join(c if c.isalnum() or c in ' ._-' else '_' for c in name) + return safe.strip() or 'schedule' + + +def _cd(filename: str) -> str: + """Content-Disposition с поддержкой Unicode (RFC 5987).""" + ascii_name = filename.encode('ascii', 'replace').decode('ascii') + utf8_name = urllib.parse.quote(filename, safe='') + return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{utf8_name}" diff --git a/app/services/export.py b/app/services/export.py new file mode 100644 index 0000000..385cec0 --- /dev/null +++ b/app/services/export.py @@ -0,0 +1,158 @@ +import csv +import io +import json + +from flask import current_app, render_template + +DAYS_ORDER = ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье'] + + +def _flatten_schedule(schedule_data: list) -> list[dict]: + """Разворачивает вложенную JSON-структуру расписания в плоский список строк.""" + rows = [] + for item in schedule_data: + subject = item.get('subject', '') + for type_name, type_data in item.get('types', {}).items(): + if isinstance(type_data, dict) and 'dates' in type_data: + date_ranges = type_data['dates'] + else: + date_ranges = type_data + for period, slots in date_ranges.items(): + for slot in slots: + rows.append({ + 'subject': subject, + 'type': type_name, + 'period': period, + 'day': slot[0] if len(slot) > 0 else '', + 'time': slot[1] if len(slot) > 1 else '', + 'classroom': slot[2] if len(slot) > 2 else '', + 'teacher': slot[3] if len(slot) > 3 else '', + }) + rows.sort(key=lambda r: ( + r['subject'], + r['type'], + r['period'], + DAYS_ORDER.index(r['day'].lower()) if r['day'].lower() in DAYS_ORDER else 99, + r['time'], + )) + return rows + + +def export_json(schedule) -> bytes: + return json.dumps(schedule.data, ensure_ascii=False, indent=2).encode('utf-8') + + +def export_csv(schedule) -> bytes: + rows = _flatten_schedule(schedule.data or []) + buf = io.StringIO() + writer = csv.DictWriter( + buf, + fieldnames=['subject', 'type', 'period', 'day', 'time', 'classroom', 'teacher'], + extrasaction='ignore', + ) + writer.writerow({ + 'subject': 'Предмет', + 'type': 'Тип занятия', + 'period': 'Период', + 'day': 'День', + 'time': 'Время', + 'classroom': 'Кабинет', + 'teacher': 'Преподаватель', + }) + writer.writerows(rows) + return buf.getvalue().encode('utf-8-sig') # BOM для корректного открытия в Excel + + +def _build_week_view_active(schedule_data: list) -> dict: + """Слоты по дням — только из периодов, которые ещё не закончились на сегодня.""" + from datetime import date + from app.services.schedule_helpers import _parse_date_range + today = date.today() + days = {i: [] for i in range(7)} + for item in schedule_data: + subject = item.get('subject', '') + for type_name, type_data in item.get('types', {}).items(): + if isinstance(type_data, dict) and 'dates' in type_data: + color = type_data.get('color', 'gray') + date_ranges = type_data['dates'] + else: + color = 'gray' + date_ranges = type_data + for period, slots in date_ranges.items(): + start, end = _parse_date_range(period) + # Пропускаем период только если у него есть явная дата конца и она уже прошла + if end is not None and end < today: + continue + for slot in slots: + day_name = (slot[0] if slot else '').lower() + if day_name not in DAYS_ORDER: + continue + day_idx = DAYS_ORDER.index(day_name) + days[day_idx].append({ + 'subject': subject, + 'type': type_name, + 'color': color, + 'time': slot[1] if len(slot) > 1 else '', + 'classroom': slot[2] if len(slot) > 2 else '', + 'teacher': slot[3] if len(slot) > 3 else '', + 'period': period, + }) + # Убираем дубликаты одного слота (предмет+день+время из нескольких периодов) + for i in days: + seen = set() + unique = [] + for s in days[i]: + key = (s['subject'], s['type'], s['time']) + if key not in seen: + seen.add(key) + unique.append(s) + days[i] = sorted(unique, key=lambda s: s.get('time') or '') + return days + + +def _render_html(schedule, fmt: str) -> str: + rows = _flatten_schedule(schedule.data or []) + return render_template( + 'export/schedule_print.html', + schedule=schedule, + rows=rows, + fmt=fmt, + ) + + +def _render_html_png(schedule) -> str: + week_view = _build_week_view_active(schedule.data or []) + days_display = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье'] + return render_template( + 'export/schedule_png.html', + schedule=schedule, + week_view=week_view, + days_display=days_display, + ) + + +def export_pdf(schedule) -> bytes: + from playwright.sync_api import sync_playwright + html = _render_html(schedule, 'pdf') + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.set_content(html, wait_until='load') + pdf_bytes = page.pdf(format='A4', landscape=True, print_background=True) + browser.close() + return pdf_bytes + + +def export_png(schedule) -> bytes: + from playwright.sync_api import sync_playwright + html = _render_html_png(schedule) + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={'width': 1200, 'height': 800}) + page.set_content(html, wait_until='load') + # Подгоняем высоту под реальный контент + height = page.evaluate('document.documentElement.scrollHeight') + page.set_viewport_size({'width': 1200, 'height': height}) + png_bytes = page.screenshot(full_page=True) + browser.close() + return png_bytes diff --git a/app/static/css/metrics/_checkpoints.css b/app/static/css/metrics/_checkpoints.css index ad9a9a9..8b19d0e 100644 --- a/app/static/css/metrics/_checkpoints.css +++ b/app/static/css/metrics/_checkpoints.css @@ -49,9 +49,11 @@ gap: 5px; align-content: start; overflow-y: auto; + padding-right: 4px; } .t-cp { - aspect-ratio: 1; + aspect-ratio: unset; + min-height: 36px; border: 1.5px solid var(--border); border-radius: 8px; background: none; @@ -84,7 +86,7 @@ @container metric (min-height: 120px) and (max-height: 239px) { .t-cp-fraction { font-size: 14px; } .t-cps { grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 4px; } - .t-cp { font-size: 10px; border-radius: 6px; } + .t-cp { font-size: 10px; border-radius: 6px; aspect-ratio: unset; min-height: 32px; } } /* ── gh-3/4: подписи ── */ diff --git a/app/static/css/metrics/_stages.css b/app/static/css/metrics/_stages.css index ba49264..ffac6e3 100644 --- a/app/static/css/metrics/_stages.css +++ b/app/static/css/metrics/_stages.css @@ -14,6 +14,7 @@ flex-wrap: wrap; align-content: center; align-items: flex-start; + justify-content: center; overflow: hidden; gap: 0; } diff --git a/app/static/css/schedule.css b/app/static/css/schedule.css index 0a52ec8..a918abb 100644 --- a/app/static/css/schedule.css +++ b/app/static/css/schedule.css @@ -265,9 +265,36 @@ body.edit-mode .t-slot-edit-controls { display: flex; } .t-sched-card-title { font-size: 15px; font-weight: 700; } .t-sched-card-meta { font-size: 11.5px; color: var(--muted); display: flex; gap: 12px; flex-wrap: wrap; } .t-sched-card-meta span { display: flex; align-items: center; gap: 3px; } -.t-sched-card-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; } +.t-sched-card-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; align-items: stretch; } +.t-sched-card-actions > form, +.t-sched-card-actions > .t-export-dropdown { display: flex; } +.t-sched-card-actions > form > .t-btn, +.t-sched-card-actions > .t-export-dropdown > .t-btn { align-self: stretch; } .t-sched-card-footer { border-top: 1px solid var(--border); padding-top: 10px; margin-top: 4px; } +/* Выпадающее меню экспорта */ +.t-export-dropdown { position: relative; display: inline-block; } +.t-export-menu { + position: absolute; + right: 0; + top: calc(100% + 4px); + min-width: 110px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,.18); + z-index: 200; + overflow: hidden; +} +.t-export-item { + display: block; + padding: 7px 14px; + font-size: 12px; + color: var(--text); + text-decoration: none; +} +.t-export-item:hover { background: var(--surface); } + /* Объединение расписаний */ .t-merge-panel { background: var(--surface); diff --git a/app/templates/export/schedule_png.html b/app/templates/export/schedule_png.html new file mode 100644 index 0000000..1a21e22 --- /dev/null +++ b/app/templates/export/schedule_png.html @@ -0,0 +1,193 @@ + + + + +{{ schedule.name }} + + + + +
+
{{ schedule.name }}
+
Расписание · экспорт
+
+ +{% set TYPE_SHORT = { + 'Лекция': 'Лек', + 'Практика': 'Пр', + 'Лаб. работа': 'Лаб', + 'Лабораторная': 'Лаб', + 'Семинар': 'Сем', + 'Не указано': '' +} %} + +
+ {% for i in range(7) %} + {% set slots = week_view[i] %} + {% if slots %} +
+
+ {{ days_display[i] }} +
+ {% for slot in slots %} +
+
{{ slot.time or '—' }}
+
+ {{ slot.subject }} + {% if slot.classroom %} +
+ + {{ slot.classroom }} +
+ {% endif %} + {% if slot.teacher %} +
+ + {{ slot.teacher }} +
+ {% endif %} +
+
+ {% set t_short = TYPE_SHORT.get(slot.type, slot.type[:3] if slot.type and slot.type|length > 3 else slot.type) %} + {% if t_short and slot.type != 'Не указано' %} + {{ t_short }} + {% endif %} +
+
+ {% endfor %} +
+ {% endif %} + {% endfor %} +
+ + + diff --git a/app/templates/export/schedule_print.html b/app/templates/export/schedule_print.html new file mode 100644 index 0000000..97249c7 --- /dev/null +++ b/app/templates/export/schedule_print.html @@ -0,0 +1,122 @@ + + + + +{{ schedule.name }} + + + +

{{ schedule.name }}

+
Экспортировано {{ schedule.created_at.strftime('%d.%m.%Y') }} · {{ rows | length }} занятий
+ + {% if rows %} + + + + + + + + + + + + + + {% set color_map = { + 'Лекция': 'blue', + 'Практика': 'green', + 'Лаб. работа': 'purple', + 'Лабораторная': 'purple', + 'Семинар': 'teal', + 'Не указано': 'gray' + } %} + {% for row in rows %} + + + + + + + + + + {% endfor %} + +
ПредметТипПериодДеньВремяКабинетПреподаватель
{{ row.subject }} + {% set color = color_map.get(row.type, 'gray') %} + {{ row.type }} + {{ row.period }}{{ row.day | capitalize }}{{ row.time or '' }}{{ row.classroom or '—' }}{{ row.teacher or '—' }}
+ {% else %} +

Расписание пустое.

+ {% endif %} + + diff --git a/app/templates/profile/index.html b/app/templates/profile/index.html index 425b23b..5b3a41c 100644 --- a/app/templates/profile/index.html +++ b/app/templates/profile/index.html @@ -29,6 +29,17 @@
Открыть +
+ + +
@@ -169,6 +180,26 @@ document.querySelectorAll('.copy-btn').forEach(btn => { }); }); +/* Дропдаун экспорта */ +document.querySelectorAll('.t-export-toggle').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const menu = btn.nextElementSibling; + const open = !menu.hidden; + // закрыть все остальные + document.querySelectorAll('.t-export-menu').forEach(m => { m.hidden = true; }); + document.querySelectorAll('.t-export-toggle').forEach(b => b.setAttribute('aria-expanded', 'false')); + if (!open) { + menu.hidden = false; + btn.setAttribute('aria-expanded', 'true'); + } + }); +}); +document.addEventListener('click', () => { + document.querySelectorAll('.t-export-menu').forEach(m => { m.hidden = true; }); + document.querySelectorAll('.t-export-toggle').forEach(b => b.setAttribute('aria-expanded', 'false')); +}); + /* Объединение расписаний */ const mergePanel = document.getElementById('merge-panel'); const mergeIds = document.getElementById('merge-ids'); diff --git a/requirements.txt b/requirements.txt index bef465f..9dc05bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ openai==1.58.1 python-dotenv==1.0.1 Werkzeug==3.1.3 Pillow==11.0.0 +playwright>=1.40