1
0
Fork 0
mirror of https://github.com/EDeev/tablo.git synced 2026-10-07 20:49:31 +03:00

Upd Add export / Fix bugs

This commit is contained in:
Деев Егор Викторович 2026-05-25 12:48:41 +03:00
parent 3946aeb81e
commit d102136615
11 changed files with 639 additions and 4 deletions

View file

@ -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

View file

@ -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

95
app/routes/export.py Normal file
View file

@ -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/<int:schedule_id>/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/<int:schedule_id>/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/<int:schedule_id>/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/<int:schedule_id>/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}"

158
app/services/export.py Normal file
View file

@ -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

View file

@ -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: подписи ── */

View file

@ -14,6 +14,7 @@
flex-wrap: wrap;
align-content: center;
align-items: flex-start;
justify-content: center;
overflow: hidden;
gap: 0;
}

View file

@ -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);

View file

@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{{ schedule.name }}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
:root {
--green: #3D7A5F;
--mint: #E8F5EE;
--bg: #F9F9F8;
--surface: #FFFFFF;
--surface2:#F4F4F3;
--ink: #1A1A1A;
--muted: #6B7280;
--border: #E5E7EB;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', Arial, sans-serif;
font-size: 14px;
background: var(--bg);
color: var(--ink);
padding: 28px 24px;
min-width: 900px;
}
/* ── Заголовок ── */
.t-ph { margin-bottom: 22px; }
.t-ph-title { font-size: 19px; font-weight: 700; letter-spacing: -.02em; }
.t-ph-sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
/* ── Сетка дней ── */
.t-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
/* ── Карточка дня ── */
.t-day-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.t-day-card-head {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
display: flex;
align-items: center;
gap: 8px;
}
.t-day-name { font-size: 13px; font-weight: 600; }
/* ── Слот ── */
.t-slot {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
display: flex;
gap: 10px;
align-items: flex-start;
}
.t-slot:last-child { border-bottom: none; }
.t-slot-time {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 11px;
color: var(--muted);
min-width: 66px;
padding-top: 2px;
flex-shrink: 0;
line-height: 1.4;
}
.t-slot-body { flex: 1; min-width: 0; }
.t-slot-name {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.t-slot-room {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
display: flex;
align-items: center;
gap: 3px;
}
.t-slot-teacher-row {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
display: flex;
align-items: center;
gap: 3px;
}
.t-slot-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
/* ── Бейджи типов ── */
.t-type {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
.t-type-blue { background: #dbeafe; color: #1d4ed8; }
.t-type-green { background: #dcfce7; color: #166534; }
.t-type-yellow { background: #fef9c3; color: #854d0e; }
.t-type-purple { background: #ede9fe; color: #5b21b6; }
.t-type-orange { background: #ffedd5; color: #9a3412; }
.t-type-red { background: #fee2e2; color: #b91c1c; }
.t-type-teal { background: #ccfbf1; color: #0f766e; }
.t-type-gray { background: #f1f5f9; color: #475569; }
.t-empty-day {
padding: 14px;
font-size: 12px;
color: var(--muted);
text-align: center;
}
</style>
</head>
<body>
<div class="t-ph">
<div class="t-ph-title">{{ schedule.name }}</div>
<div class="t-ph-sub">Расписание · экспорт</div>
</div>
{% set TYPE_SHORT = {
'Лекция': 'Лек',
'Практика': 'Пр',
'Лаб. работа': 'Лаб',
'Лабораторная': 'Лаб',
'Семинар': 'Сем',
'Не указано': ''
} %}
<div class="t-grid">
{% for i in range(7) %}
{% set slots = week_view[i] %}
{% if slots %}
<div class="t-day-card">
<div class="t-day-card-head">
<span class="t-day-name">{{ days_display[i] }}</span>
</div>
{% for slot in slots %}
<div class="t-slot">
<div class="t-slot-time">{{ slot.time or '—' }}</div>
<div class="t-slot-body">
<span class="t-slot-name">{{ slot.subject }}</span>
{% if slot.classroom %}
<div class="t-slot-room">
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ slot.classroom }}
</div>
{% endif %}
{% if slot.teacher %}
<div class="t-slot-teacher-row">
<svg width="9" height="9" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{{ slot.teacher }}
</div>
{% endif %}
</div>
<div class="t-slot-right">
{% 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 != 'Не указано' %}
<span class="t-type t-type-{{ slot.color or 'gray' }}">{{ t_short }}</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
</body>
</html>

View file

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{{ schedule.name }}</title>
<style>
@page {
size: A4 landscape;
margin: 1.2cm 1.5cm;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DejaVu Sans', Arial, sans-serif;
font-size: 10px;
color: #1e293b;
background: #fff;
}
h1 {
font-size: 16px;
font-weight: 700;
margin-bottom: 4px;
color: #0f172a;
}
.meta {
font-size: 9px;
color: #64748b;
margin-bottom: 14px;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
th {
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 5px 6px;
font-size: 9px;
font-weight: 600;
color: #475569;
text-transform: uppercase;
letter-spacing: .03em;
white-space: nowrap;
}
td {
border: 1px solid #e2e8f0;
padding: 4px 6px;
vertical-align: top;
line-height: 1.4;
}
tr:nth-child(even) td { background: #f8fafc; }
/* Цветовые бейджи типов занятий */
.badge {
display: inline-block;
padding: 1px 5px;
border-radius: 3px;
font-size: 8px;
font-weight: 600;
white-space: nowrap;
}
.badge-blue { background: #dbeafe; color: #1d4ed8; }
.badge-green { background: #dcfce7; color: #166534; }
.badge-yellow { background: #fef9c3; color: #854d0e; }
.badge-purple { background: #ede9fe; color: #5b21b6; }
.badge-orange { background: #ffedd5; color: #9a3412; }
.badge-red { background: #fee2e2; color: #b91c1c; }
.badge-teal { background: #ccfbf1; color: #0f766e; }
.badge-gray { background: #f1f5f9; color: #475569; }
.day-cell { white-space: nowrap; }
.time-cell { white-space: nowrap; font-family: monospace; font-size: 9.5px; }
.muted { color: #94a3b8; }
</style>
</head>
<body>
<h1>{{ schedule.name }}</h1>
<div class="meta">Экспортировано {{ schedule.created_at.strftime('%d.%m.%Y') }} · {{ rows | length }} занятий</div>
{% if rows %}
<table>
<thead>
<tr>
<th>Предмет</th>
<th>Тип</th>
<th>Период</th>
<th>День</th>
<th>Время</th>
<th>Кабинет</th>
<th>Преподаватель</th>
</tr>
</thead>
<tbody>
{% set color_map = {
'Лекция': 'blue',
'Практика': 'green',
'Лаб. работа': 'purple',
'Лабораторная': 'purple',
'Семинар': 'teal',
'Не указано': 'gray'
} %}
{% for row in rows %}
<tr>
<td>{{ row.subject }}</td>
<td>
{% set color = color_map.get(row.type, 'gray') %}
<span class="badge badge-{{ color }}">{{ row.type }}</span>
</td>
<td>{{ row.period }}</td>
<td class="day-cell">{{ row.day | capitalize }}</td>
<td class="time-cell">{{ row.time or '' }}</td>
<td>{{ row.classroom or '<span class="muted">—</span>' }}</td>
<td>{{ row.teacher or '<span class="muted">—</span>' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Расписание пустое.</p>
{% endif %}
</body>
</html>

View file

@ -29,6 +29,17 @@
</div>
<div class="t-sched-card-actions">
<a class="t-btn t-btn-primary t-btn-sm" href="{{ url_for('schedules.view', schedule_id=s.id) }}">Открыть</a>
<div class="t-export-dropdown">
<button type="button" class="t-btn t-btn-outline t-btn-sm t-export-toggle" title="Экспортировать расписание" aria-haspopup="true" aria-expanded="false">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<div class="t-export-menu" hidden>
<a class="t-export-item" href="{{ url_for('export.export_json', schedule_id=s.id) }}">JSON</a>
<a class="t-export-item" href="{{ url_for('export.export_csv', schedule_id=s.id) }}">CSV</a>
<a class="t-export-item" href="{{ url_for('export.export_pdf', schedule_id=s.id) }}">PDF</a>
<a class="t-export-item" href="{{ url_for('export.export_png', schedule_id=s.id) }}">PNG</a>
</div>
</div>
<button type="button" class="t-btn t-btn-outline t-btn-sm share-btn" data-schedule-id="{{ s.id }}" title="Поделиться расписанием">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button>
@ -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');

View file

@ -8,3 +8,4 @@ openai==1.58.1
python-dotenv==1.0.1
Werkzeug==3.1.3
Pillow==11.0.0
playwright>=1.40