Compare commits

...
2 Commits
Author SHA1 Message Date
jfmcarreira 5ca0a6c239 feat: add export to PDF 2026-10-07 01:47:55 +01:00
jfmcarreira 2caef99560 feat(ui): minor cosmetic changes 2026-10-07 00:48:00 +01:00
15 changed files with 685 additions and 22 deletions
+3 -2
View File
@@ -15,6 +15,7 @@ live in `requirements-dev.txt`.
- `app/scheduling.py`: monthly pattern and rotation generation. - `app/scheduling.py`: monthly pattern and rotation generation.
- `app/bootstrap.py`: initial administrator and seeded schedules. - `app/bootstrap.py`: initial administrator and seeded schedules.
- `app/calendar.py`: iCalendar feeds; `app/i18n.py`: Portuguese labels and formatting. - `app/calendar.py`: iCalendar feeds; `app/i18n.py`: Portuguese labels and formatting.
- `app/pdf.py`: single-page A4 schedule lists using ReportLab.
- `app/notifications.py`: synchronous SMTP delivery and notification history. - `app/notifications.py`: synchronous SMTP delivery and notification history.
- `app/security.py`: password hashing and CSRF token generation. - `app/security.py`: password hashing and CSRF token generation.
- `app/cli.py`: database initialization/migration, demo seeding, shell, reminders. - `app/cli.py`: database initialization/migration, demo seeding, shell, reminders.
@@ -22,7 +23,7 @@ live in `requirements-dev.txt`.
- `migrations/`: Alembic environment and the squashed `0001_initial` revision. - `migrations/`: Alembic environment and the squashed `0001_initial` revision.
- `alembic.ini`: migration paths and logging; the database URL comes from settings. - `alembic.ini`: migration paths and logging; the database URL comes from settings.
- `compose.yaml`: example production deployment with persistent SQLite storage. - `compose.yaml`: example production deployment with persistent SQLite storage.
- `tests/`: pytest discovery directory; currently empty. - `tests/`: export behavior tests and isolated database/client fixtures.
Read `docs/DOMAIN.md` for business rules and `docs/ARCHITECTURE.md` for lifecycle Read `docs/DOMAIN.md` for business rules and `docs/ARCHITECTURE.md` for lifecycle
and deployment details. See `README.md` for the test workflow. and deployment details. See `README.md` for the test workflow.
@@ -83,7 +84,7 @@ needed. Python 3.12+ with `venv`/pip and `make` are prerequisites.
and keep tests independent of local `.env` values and persistent application data. and keep tests independent of local `.env` values and persistent application data.
- Prefer behavior and domain invariants over assertions about implementation details. - Prefer behavior and domain invariants over assertions about implementation details.
- An empty suite succeeds through `make test`; test failures and pytest errors still - An empty suite succeeds through `make test`; test failures and pytest errors still
fail the command. No tests or application fixtures have been added yet. fail the command. PDF tests inspect downloaded documents with pypdf.
Run `make test` after relevant changes and report its result accurately. An empty Run `make test` after relevant changes and report its result accurately. An empty
suite verifies the runner only, not application behavior. suite verifies the runner only, not application behavior.
+4 -3
View File
@@ -106,8 +106,9 @@ or run Docker or database setup commands.
The framework is **pytest**, with **HTTPX** available for FastAPI's `TestClient`. The framework is **pytest**, with **HTTPX** available for FastAPI's `TestClient`.
Configuration is in `pyproject.toml`; tests are discovered under `tests/`. Configuration is in `pyproject.toml`; tests are discovered under `tests/`.
There are no tests yet: pytest will report `no tests ran`, and `make test` treats The suite covers PDF downloads, date boundaries, permissions, preservation of
that result as success. Actual failures and pytest errors return a nonzero status. assignments and single-page A4 output. Fixtures use temporary SQLite storage and
disable SMTP. Actual failures and pytest errors return a nonzero status.
Optional pytest arguments use the same command: Optional pytest arguments use the same command:
@@ -115,7 +116,7 @@ Optional pytest arguments use the same command:
make test ARGS="-k rotation -v" make test ARGS="-k rotation -v"
``` ```
## Adding tests later ## Adding tests
- Put files in `tests/test_*.py`, name functions `test_*`, and use plain `assert`. - Put files in `tests/test_*.py`, name functions `test_*`, and use plain `assert`.
- Use pytest's `tmp_path` and `monkeypatch` for isolated data and settings. - Use pytest's `tmp_path` and `monkeypatch` for isolated data and settings.
+167
View File
@@ -0,0 +1,167 @@
"""Single-page A4 schedule lists, downloaded without a browser print dialog."""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from datetime import date
from io import BytesIO
from itertools import combinations
from pathlib import Path
from xml.sax.saxutils import escape
import reportlab
from reportlab.lib.colors import HexColor
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
from .config import settings
from .i18n import month_name
from .models import Assignment, Schedule
# ReportLab ships these Unicode fonts, including Portuguese accents, in its wheel.
FONT_DIR = Path(reportlab.__file__).parent / "fonts"
pdfmetrics.registerFont(TTFont("Rota", str(FONT_DIR / "Vera.ttf")))
pdfmetrics.registerFont(TTFont("RotaBold", str(FONT_DIR / "VeraBd.ttf")))
RED = HexColor("#c81018")
GREEN = HexColor("#183c2f")
MUTED = HexColor("#6f6567")
@dataclass
class MonthBlock:
title: str
rows: list[tuple[str, Paragraph, float]]
height: float
def _columns(blocks: list[MonthBlock], count: int) -> list[list[MonthBlock]]:
"""Balance complete months across columns, retaining chronological order."""
best: list[list[MonthBlock]] = []
best_height = float("inf")
for cuts in combinations(range(1, len(blocks)), count - 1):
edges = (0, *cuts, len(blocks))
groups = [blocks[left:right] for left, right in zip(edges, edges[1:])]
height = max(sum(block.height for block in group) for group in groups)
if height < best_height:
best, best_height = groups, height
return best
def _layout(
months: dict[tuple[int, int], list[Assignment]], width: float, available: float
) -> tuple[list[list[MonthBlock]], float, float, float]:
"""Fit the entire list on one page, wrapping names instead of truncating them."""
for count in range(1, min(4, len(months)) + 1):
column_width = (width - (count - 1) * 7 * mm) / count
for size in range(16, 7, -1):
style = ParagraphStyle(
"team", fontName="RotaBold", fontSize=size, leading=size * 1.3,
textColor=GREEN, splitLongWords=True,
)
blocks = []
for (year, month), assignments in months.items():
rows = []
for assignment in assignments:
text = assignment.team.name if assignment.team else "Sem atribuição"
paragraph = Paragraph(escape(text), style)
_, height = paragraph.wrap(column_width - size * 2.5, available)
rows.append((str(assignment.date.day), paragraph, height + size * 0.3))
if not rows:
paragraph = Paragraph("Sem turnos neste período.", style)
_, height = paragraph.wrap(column_width - size * 2.5, available)
rows.append(("", paragraph, height + size * 0.3))
blocks.append(MonthBlock(
f"{month_name(month)} {year}", rows,
size * 2 + sum(row[2] for row in rows) + size,
))
groups = _columns(blocks, count)
height = max(sum(block.height for block in group) for group in groups)
if height <= available:
return groups, column_width, size, 1.0
# Very dense ranges or unusually long names still retain every row on A4.
return groups, column_width, size, min(1.0, available / height)
def build_schedule_pdf(
schedule: Schedule, assignments: list[Assignment], start: date, end: date
) -> bytes:
output = BytesIO()
canvas = Canvas(output, pagesize=A4)
canvas.setTitle(schedule.name)
canvas.setAuthor(settings.app_name)
width, height = A4
margin = 16 * mm
left_margin = 24 * mm
content_width = width - left_margin - margin
logo = Path(__file__).parent / "static" / "logo-crr.png"
text_x = left_margin + 94
text_width = content_width - 112
brand = Paragraph(escape(settings.app_name.upper()), ParagraphStyle(
"brand", fontName="RotaBold", fontSize=9, leading=12, textColor=MUTED,
))
_, brand_height = brand.wrap(text_width, height)
title = Paragraph(escape(schedule.name), ParagraphStyle(
"title", fontName="RotaBold", fontSize=24, leading=29, textColor=RED,
))
_, title_height = title.wrap(text_width, height)
header_height = max(94, brand_height + title_height + 43)
header_top = height - margin
header_bottom = header_top - header_height
# A softly tinted banner and white logo badge frame the CRR identity.
canvas.setFillColor(HexColor("#fff4f4"))
canvas.roundRect(left_margin, header_bottom, content_width, header_height, 10, stroke=0, fill=1)
badge_y = header_bottom + (header_height - 68) / 2
canvas.setFillColor(HexColor("#ffffff"))
canvas.roundRect(left_margin + 12, badge_y, 68, 68, 8, stroke=0, fill=1)
canvas.drawImage(
str(logo), left_margin + 18, badge_y + 6, width=56, height=56,
preserveAspectRatio=True, anchor="c", mask="auto",
)
text_top = header_bottom + (header_height + brand_height + 7 + title_height) / 2
brand.drawOn(canvas, text_x, text_top - brand_height)
title.drawOn(canvas, text_x, text_top - brand_height - 7 - title_height)
canvas.setStrokeColor(HexColor("#eedada"))
canvas.setLineWidth(0.7)
canvas.line(left_margin, header_bottom - 12, width - margin, header_bottom - 12)
canvas.setStrokeColor(RED)
canvas.setLineWidth(3)
canvas.line(left_margin, header_bottom - 12, left_margin + 44, header_bottom - 12)
top = header_bottom - 30
available = top - margin
months: dict[tuple[int, int], list[Assignment]] = defaultdict(list)
for assignment in sorted(assignments, key=lambda row: row.date):
months[(assignment.date.year, assignment.date.month)].append(assignment)
if not months:
months[(start.year, start.month)] = []
groups, column_width, size, scale = _layout(months, content_width, available)
canvas.saveState()
canvas.translate(left_margin, top)
canvas.scale(scale, scale)
for index, group in enumerate(groups):
x = index * (column_width + 7 * mm)
y = 0.0
for block in group:
canvas.setFillColor(RED)
canvas.setFont("RotaBold", size)
canvas.drawString(x, y - size, block.title)
y -= size * 2
for day, paragraph, row_height in block.rows:
canvas.setFillColor(GREEN)
canvas.setFont("RotaBold", size)
canvas.drawRightString(x + size * 1.5, y - size, day)
paragraph.drawOn(canvas, x + size * 2.5, y - paragraph.height)
y -= row_height
y -= size
canvas.restoreState()
canvas.showPage()
canvas.save()
return output.getvalue()
+20
View File
@@ -0,0 +1,20 @@
{% if error %}<div class="flash error" role="alert">{{ error }}</div>{% endif %}
<form class="stack" method="get" action="{{ app_path('/export/schedule.pdf') }}">
<label>Escala
<select name="schedule_id" required>
{% for item in schedules %}
<option value="{{ item.id }}" {% if item.id == selected_schedule_id %}selected{% endif %}>{{ item.name }}</option>
{% endfor %}
</select>
</label>
<div class="form-grid">
<label>Data inicial
<input type="date" name="start_date" value="{{ start }}" min="2000-01-01" max="2100-12-31" required>
</label>
<label>Data final
<input type="date" name="end_date" value="{{ end }}" min="2000-01-01" max="2100-12-31" required>
</label>
</div>
<p class="muted">As datas inicial e final estão incluídas. Máximo de 12 meses; o tamanho do texto adapta-se ao intervalo.</p>
<div class="row end"><button class="primary" type="submit">Descarregar PDF</button></div>
</form>
+18 -13
View File
@@ -20,7 +20,7 @@
{% if card.kind == 'rotation' %} {% if card.kind == 'rotation' %}
<section class="panel"> <section class="panel">
<div class="row between wrap gap"> <div class="row between wrap gap">
<div><h3>{{ month_name }} {{ year }}</h3><p class="muted">Pré-preenchido com a ordem da rotação abaixo. Alterar uma data nunca reescreve as outras.</p></div> <div><h3>{{ month_name }} {{ year }}</h3></div>
<div class="month-nav"> <div class="month-nav">
<a class="button secondary" href="{{ app_path('/admin/schedules/' ~ schedule.id ~ '?year=' ~ prev[0] ~ '&month=' ~ prev[1] ) }}">← Anterior</a> <a class="button secondary" href="{{ app_path('/admin/schedules/' ~ schedule.id ~ '?year=' ~ prev[0] ~ '&month=' ~ prev[1] ) }}">← Anterior</a>
<a class="button secondary" href="{{ app_path('/admin/schedules/' ~ schedule.id ~ '?year=' ~ nxt[0] ~ '&month=' ~ nxt[1] ) }}">Seguinte →</a> <a class="button secondary" href="{{ app_path('/admin/schedules/' ~ schedule.id ~ '?year=' ~ nxt[0] ~ '&month=' ~ nxt[1] ) }}">Seguinte →</a>
@@ -31,15 +31,6 @@
<input type="hidden" name="year" value="{{ year }}"><input type="hidden" name="month" value="{{ month }}"> <input type="hidden" name="year" value="{{ year }}"><input type="hidden" name="month" value="{{ month }}">
<button class="secondary" type="submit">Aplicar rotação em {{ month_name }}</button> <button class="secondary" type="submit">Aplicar rotação em {{ month_name }}</button>
</form> </form>
<form method="post" action="{{ app_path('/admin/schedules/' ~ schedule.id ~ '/clear-from') }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>A partir de<select name="month">
{% for name in months %}<option value="{{ loop.index }}" {% if loop.index == month %}selected{% endif %}>{{ name }}</option>{% endfor %}
</select></label>
<label>de<input type="number" name="year" value="{{ year }}" min="2000" max="2100" required></label>
<button class="danger" type="submit">Limpar turnos</button>
</form>
<p class="muted">Deixa sem atribuição os turnos <strong>automáticos</strong> do mês e ano escolhidos e de todos os seguintes. Nenhum turno é apagado e as atribuições manuais e as trocas concluídas são mantidas. Use Limpar turnos antes de Aplicar rotação, para recalcular tudo a partir de um ponto.</p> <p class="muted">Deixa sem atribuição os turnos <strong>automáticos</strong> do mês e ano escolhidos e de todos os seguintes. Nenhum turno é apagado e as atribuições manuais e as trocas concluídas são mantidas. Use Limpar turnos antes de Aplicar rotação, para recalcular tudo a partir de um ponto.</p>
<form method="post" action="{{ app_path('/admin/schedules/' ~ schedule.id ~ '/assignments') }}"> <form method="post" action="{{ app_path('/admin/schedules/' ~ schedule.id ~ '/assignments') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
@@ -99,18 +90,32 @@
</div> </div>
<div class="sticky-actions"><button class="primary" type="submit">Guardar padrão</button></div> <div class="sticky-actions"><button class="primary" type="submit">Guardar padrão</button></div>
</form> </form>
</section>
<section class="panel">
<h3>Aplicar novo padrão</h3>
<form method="post" action="{{ app_path('/admin/schedules/' ~ schedule.id ~ '/apply-pattern') }}" class="inline-form"> <form method="post" action="{{ app_path('/admin/schedules/' ~ schedule.id ~ '/apply-pattern') }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>A partir de<select name="month"> <label><select name="month">
{% for name in months %}<option value="{{ loop.index }}" {% if loop.index == month %}selected{% endif %}>{{ name }}</option>{% endfor %} {% for name in months %}<option value="{{ loop.index }}" {% if loop.index == month %}selected{% endif %}>{{ name }}</option>{% endfor %}
</select></label> </select></label>
<label>de<input type="number" name="year" value="{{ year }}" min="2000" max="2100" required></label> <label><input type="number" name="year" value="{{ year }}" min="2000" max="2100" required></label>
<button class="secondary" type="submit">Aplicar padrão</button> <button class="secondary" type="submit">Aplicar padrão</button>
</form> </form>
<p class="muted">Atualiza os turnos <strong>automáticos</strong> já existentes do mês e ano escolhidos e de todos os seguintes, para refletirem o padrão guardado acima. As atribuições manuais e as trocas concluídas são mantidas, os dias sem equipa no padrão ficam como estão e nenhum turno é criado.</p> <p class="muted">Atualiza os turnos <strong>automáticos</strong> já existentes do mês e ano escolhidos e de todos os seguintes, para refletirem o padrão guardado acima. As atribuições manuais e as trocas concluídas são mantidas, os dias sem equipa no padrão ficam como estão e nenhum turno é criado.</p>
</section> </section>
{% endif %} {% endif %}
<section class="panel">
<h3>Limpar atribuições</h3>
<p class="muted">Limpar atribuições a partir do mês de:</p>
<form method="post" action="{{ app_path('/admin/schedules/' ~ schedule.id ~ '/clear-from') }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label><select name="month">
{% for name in months %}<option value="{{ loop.index }}" {% if loop.index == month %}selected{% endif %}>{{ name }}</option>{% endfor %}
</select></label>
<label><input type="number" name="year" value="{{ year }}" min="2000" max="2100" required></label>
<button class="danger" type="submit">Limpar turnos</button>
</form>
</section>
<section class="panel" id="swap-requests"> <section class="panel" id="swap-requests">
<h3>Pedidos de troca</h3> <h3>Pedidos de troca</h3>
<p class="muted">Um administrador pode atribuir o turno a outra equipa, mesmo que nenhuma pessoa entre na aplicação por ela. Só se aplica se quem pediu a troca continuar a ser o titular do turno.</p> <p class="muted">Um administrador pode atribuir o turno a outra equipa, mesmo que nenhuma pessoa entre na aplicação por ela. Só se aplica se quem pediu a troca continuar a ser o titular do turno.</p>
+12 -2
View File
@@ -8,6 +8,13 @@
<a class="button primary" href="{{ app_path('/admin/assign') }}">Atribuir por dia</a> <a class="button primary" href="{{ app_path('/admin/assign') }}">Atribuir por dia</a>
</div> </div>
<div class="stack gap"> <div class="stack gap">
{% if schedules %}
<section class="panel" id="export-pdf">
<h2>Exportar PDF</h2>
<p class="muted">Escolhe uma escala e as datas para descarregar a lista de dias e equipas numa única folha A4.</p>
{% include "_schedule_export_form.html" %}
</section>
{% endif %}
{% for card in cards %} {% for card in cards %}
{% set schedule = card.schedule %} {% set schedule = card.schedule %}
<section class="panel"> <section class="panel">
@@ -16,7 +23,10 @@
<h2>{{ schedule.name }}</h2> <h2>{{ schedule.name }}</h2>
<p class="muted">{{ card.kind_label }} · {{ schedule|horario }}{% if not schedule.is_active %} · inativa{% endif %}</p> <p class="muted">{{ card.kind_label }} · {{ schedule|horario }}{% if not schedule.is_active %} · inativa{% endif %}</p>
</div> </div>
<a class="button primary" href="{{ app_path('/admin/schedules/' ~ schedule.id ) }}">Configurar</a> <div class="actions">
{% if schedule.is_active %}<a class="button secondary" href="{{ app_path('/admin/schedules?schedule_id=' ~ schedule.id ~ '#export-pdf') }}">Exportar PDF</a>{% endif %}
<a class="button primary" href="{{ app_path('/admin/schedules/' ~ schedule.id ) }}">Configurar</a>
</div>
</div> </div>
{% if card.kind == 'rotation' %} {% if card.kind == 'rotation' %}
<p class="muted">Próximas datas</p> <p class="muted">Próximas datas</p>
@@ -39,7 +49,6 @@
{% else %} {% else %}
<div class="empty">Ainda não há escalas. Execute <code>python -m app.cli init-db</code> para criar as predefinidas.</div> <div class="empty">Ainda não há escalas. Execute <code>python -m app.cli init-db</code> para criar as predefinidas.</div>
{% endfor %} {% endfor %}
</div>
<section class="panel"> <section class="panel">
<h2>Calendário da equipa</h2> <h2>Calendário da equipa</h2>
<p class="muted">Um único endereço com a rota completa, para qualquer calendário.</p> <p class="muted">Um único endereço com a rota completa, para qualquer calendário.</p>
@@ -49,4 +58,5 @@
<button class="secondary" type="button" data-copy="{{ calendar_feed_url }}">Copiar</button> <button class="secondary" type="button" data-copy="{{ calendar_feed_url }}">Copiar</button>
</div> </div>
</section> </section>
</div>
{% endblock %} {% endblock %}
+1
View File
@@ -18,6 +18,7 @@
<nav class="nav" aria-label="Navegação principal"> <nav class="nav" aria-label="Navegação principal">
{% if current_user %} {% if current_user %}
<a href="{{ app_path('/swaps') }}">Trocas</a> <a href="{{ app_path('/swaps') }}">Trocas</a>
{% if not current_user.is_admin %}<a href="{{ app_path('/export/schedule') }}">Exportar PDF</a>{% endif %}
{% if current_user.is_admin %} {% if current_user.is_admin %}
<a href="{{ app_path('/admin/schedules') }}">Gerir escalas</a> <a href="{{ app_path('/admin/schedules') }}">Gerir escalas</a>
<a href="{{ app_path('/admin/teams') }}">Equipas</a> <a href="{{ app_path('/admin/teams') }}">Equipas</a>
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Exportar escala · {{ settings.app_name }}{% endblock %}
{% block content %}
<div class="page-head">
<div>
<p class="eyebrow"><a href="{{ app_path('/') }}">Rota</a></p>
<h1>Exportar escala</h1>
<p class="muted">Escolhe uma escala e as datas. O PDF é descarregado numa única folha A4, com os dias e as equipas agrupados por mês.</p>
</div>
</div>
<section class="panel">
{% include "_schedule_export_form.html" %}
</section>
{% endblock %}
+130 -1
View File
@@ -34,6 +34,7 @@ from .i18n import (
window_label, window_label,
) )
from .notifications import send_email_notification, send_many from .notifications import send_email_notification, send_many
from .pdf import build_schedule_pdf
from .scheduling import ( from .scheduling import (
assignments_by_date, assignments_by_date,
ensure_month_assignments, ensure_month_assignments,
@@ -332,6 +333,103 @@ def calendar_page(request: Request, db: Session = Depends(get_db)):
) )
@router.get("/export/schedule", response_class=HTMLResponse)
def export_schedule(
request: Request,
schedule_id: int | None = None,
db: Session = Depends(get_db),
):
"""Choose a schedule and exact dates before downloading its PDF."""
user = _require_user(request, db)
schedules = _active_schedules(db)
if not schedules:
raise HTTPException(status_code=404, detail="Não há escalas ativas")
if schedule_id is None:
schedule = schedules[0]
else:
schedule = _get_schedule(db, schedule_id)
if not schedule.is_active:
raise HTTPException(status_code=404, detail="Escala não encontrada")
if user.is_admin:
return _redirect(f"/admin/schedules?schedule_id={schedule.id}#export-pdf")
today = date.today()
start, end = month_bounds(today.year, today.month)
return request.app.state.templates.TemplateResponse(
request=request,
name="export_schedule.html",
context=_context(
request,
db,
schedules=schedules,
selected_schedule_id=schedule.id,
start=start.isoformat(),
end=end.isoformat(),
),
)
@router.get("/export/schedule.pdf")
def download_schedule_pdf(
request: Request,
schedule_id: int,
start_date: date,
end_date: date,
db: Session = Depends(get_db),
):
user = _require_user(request, db)
schedule = _get_schedule(db, schedule_id)
if not schedule.is_active:
raise HTTPException(status_code=404, detail="Escala não encontrada")
error = None
if not (2000 <= start_date.year <= 2100 and 2000 <= end_date.year <= 2100):
error = "Escolhe datas entre 2000 e 2100."
elif end_date < start_date:
error = "A data final deve ser igual ou posterior à data inicial."
elif (end_date.year - start_date.year) * 12 + end_date.month - start_date.month >= 12:
error = "Escolhe um intervalo de no máximo 12 meses."
if error:
if user.is_admin:
return _render_admin_schedules(
request, db, schedule.id, start_date, end_date, error, status_code=400,
)
return request.app.state.templates.TemplateResponse(
request=request,
name="export_schedule.html",
status_code=400,
context=_context(
request,
db,
schedules=_active_schedules(db),
selected_schedule_id=schedule.id,
start=start_date.isoformat(),
end=end_date.isoformat(),
error=error,
),
)
# Generate chronologically, keeping existing manual and swapped assignments.
first = start_date.year * 12 + start_date.month - 1
last = end_date.year * 12 + end_date.month - 1
assignments: list[Assignment] = []
for index in range(first, last + 1):
year, month = divmod(index, 12)
assignments.extend(
row for row in ensure_month_assignments(db, year, month + 1)
if row.schedule_id == schedule.id and start_date <= row.date <= end_date
)
filename = f"escala-{schedule.id}-{start_date.isoformat()}-{end_date.isoformat()}.pdf"
return Response(
content=build_schedule_pdf(schedule, assignments, start_date, end_date),
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "private, no-store",
},
)
@router.get("/health") @router.get("/health")
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
@@ -1507,17 +1605,48 @@ def admin_user_delete(
@router.get("/admin/schedules", response_class=HTMLResponse) @router.get("/admin/schedules", response_class=HTMLResponse)
def admin_schedules(request: Request, db: Session = Depends(get_db)): def admin_schedules(
request: Request,
schedule_id: int | None = None,
db: Session = Depends(get_db),
):
_require_admin(request, db) _require_admin(request, db)
return _render_admin_schedules(request, db, schedule_id)
def _render_admin_schedules(
request: Request,
db: Session,
schedule_id: int | None = None,
start: date | None = None,
end: date | None = None,
error: str | None = None,
status_code: int = 200,
):
cards = _schedule_groups(_all_schedules(db), db) cards = _schedule_groups(_all_schedules(db), db)
schedules = [card["schedule"] for card in cards if card["schedule"].is_active]
if schedule_id is not None:
selected = _get_schedule(db, schedule_id)
if not selected.is_active:
raise HTTPException(status_code=404, detail="Escala não encontrada")
else:
selected = schedules[0] if schedules else None
today = date.today()
first, last = month_bounds(today.year, today.month)
return request.app.state.templates.TemplateResponse( return request.app.state.templates.TemplateResponse(
request=request, request=request,
name="admin_schedules.html", name="admin_schedules.html",
status_code=status_code,
context=_context( context=_context(
request, request,
db, db,
cards=cards, cards=cards,
calendar_feed_url=_shared_calendar_url(request), calendar_feed_url=_shared_calendar_url(request),
schedules=schedules,
selected_schedule_id=selected.id if selected else None,
start=(start or first).isoformat(),
end=(end or last).isoformat(),
error=error,
), ),
) )
+3 -1
View File
@@ -13,6 +13,8 @@ For local development, `make dev` prepares the host virtualenv and default `data
- `scheduling.py`: deterministic month/rotation generation. - `scheduling.py`: deterministic month/rotation generation.
- `web.py`: authentication, admin editing and change-request orchestration. - `web.py`: authentication, admin editing and change-request orchestration.
- `notifications.py`: synchronous SMTP adapter + durable send log. - `notifications.py`: synchronous SMTP adapter + durable send log.
- `pdf.py`: ReportLab-rendered A4 schedule lists, with embedded Unicode fonts and
adaptive layout to keep the selected range on one page.
- `cli.py`: maintenance commands that can be run manually or by cron. - `cli.py`: maintenance commands that can be run manually or by cron.
## Data lifecycle ## Data lifecycle
@@ -36,4 +38,4 @@ For larger installations, the SQLAlchemy configuration can point to PostgreSQL,
## Testing ## Testing
Run `make test` on the host. It creates/reuses `.venv`, installs development dependencies, and invokes pytest; it needs no running application or container. `pyproject.toml` limits discovery to `tests/`, and HTTPX supports future FastAPI `TestClient` tests. The suite is currently empty. See [the testing guide](../README.md#run-tests) for conventions and automatic database-isolation requirements for future fixtures. Run `make test` on the host. It creates/reuses `.venv`, installs development dependencies, and invokes pytest; it needs no running application or container. `pyproject.toml` limits discovery to `tests/`. HTTPX supports FastAPI `TestClient` tests, and pypdf verifies PDF downloads. Fixtures configure temporary SQLite storage before importing the app, disable SMTP, run migrations and clean up after the suite. See [the testing guide](../README.md#run-tests).
+16
View File
@@ -91,6 +91,22 @@ Every assignment on a lunch schedule page has a **Regenerate** button. An admini
## Access ## Access
### PDF export
- Administrators use the **Exportar PDF** controls directly on `/admin/schedules`.
Each schedule card links to those controls with that schedule selected. Other
signed-in users can open `/export/schedule`. Both forms select one active
schedule and exact start/end dates, both inclusive (up to 12 calendar months).
- `/export/schedule.pdf` downloads an A4 PDF directly. It lists day numbers and
teams under Portuguese month headings, with the CRR logo. Font size and columns
adapt to keep the selected range on one page; long names wrap without truncation.
- Generation fills missing assignments in chronological order using the ordinary
monthly generator. Existing assignments, including manual overrides and swaps,
keep their current owners. Only rows for the selected schedule and dates appear
in the PDF.
### Page access
- `/` is the rota and is the landing page for everyone, signed in or not. Signed-out visitors see the month read-only: no "mine" highlighting, no change-request actions. - `/` is the rota and is the landing page for everyone, signed in or not. Signed-out visitors see the month read-only: no "mine" highlighting, no change-request actions.
- `/admin/schedules` is the hub of the schedules and the only place that lists them: each card shows its kind and hours, the next dates of a rotation, its rotation order or the night pattern, and links to its configuration page. Signed-out visitors have no navigation at all; the brand logo is the way back to the rota. - `/admin/schedules` is the hub of the schedules and the only place that lists them: each card shows its kind and hours, the next dates of a rotation, its rotation order or the night pattern, and links to its configuration page. Signed-out visitors have no navigation at all; the brand logo is the way back to the rota.
- Change requests, `/swaps`, and every `/admin` page still require a signed-in, active user. - Change requests, `/swaps`, and every `/admin` page still require a signed-in, active user.
+1
View File
@@ -1,3 +1,4 @@
-r requirements.txt -r requirements.txt
pytest>=8,<9 pytest>=8,<9
httpx>=0.27,<1 httpx>=0.27,<1
pypdf>=5,<7
+1
View File
@@ -7,3 +7,4 @@ itsdangerous>=2.2,<3
email-validator>=2.2,<3 email-validator>=2.2,<3
pydantic-settings>=2.5,<3 pydantic-settings>=2.5,<3
alembic>=1.13,<2 alembic>=1.13,<2
reportlab>=4.2,<5
+118
View File
@@ -0,0 +1,118 @@
import os
import re
from collections.abc import Iterator
from datetime import date
from tempfile import TemporaryDirectory
# Settings and the engine are built at import time, so the environment has to be
# set before any app module is imported: a temporary database and no real SMTP.
_TMP = TemporaryDirectory(prefix="crr-export-tests-")
os.environ.update({
"DATABASE_URL": f"sqlite:///{_TMP.name}/test.db",
"BACKUP_DIR": f"{_TMP.name}/backups",
"SECRET_KEY": "test-secret",
"APP_NAME": "Contínuos CRR",
"BASE_URL": "http://testserver",
"CALENDAR_TOKEN": "",
"TIMEZONE": "Europe/Lisbon",
"TRUSTED_PROXY_HOSTS": "*",
"SMTP_HOST": "",
"SMTP_USERNAME": "",
"SMTP_PASSWORD": "",
"SMTP_PORT": "587",
"SMTP_FROM": "test@example.com",
"SMTP_STARTTLS": "false",
"REMINDER_DAYS_AHEAD": "1",
"ROOT_PATH": "",
"SESSION_HTTPS_ONLY": "false",
"INITIAL_ADMIN_USERNAME": "test-admin",
"INITIAL_ADMIN_PASSWORD": "secret123",
"INITIAL_ADMIN_NAME": "Administrador de teste",
"INITIAL_ADMIN_EMAIL": "admin@example.com",
})
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.bootstrap import ensure_initial_data
from app.database import Base, SessionLocal, engine, run_migrations
from app.main import app
from app.models import Schedule, Team, User
from app.security import hash_password
def sign_in(client: TestClient, username: str, password: str) -> TestClient:
page = client.get("/login")
match = re.search(r'name="csrf_token" value="([^"]+)"', page.text)
assert match, "the login form should carry a CSRF token"
client.post(
"/login",
data={"username": username, "password": password, "csrf_token": match.group(1)},
follow_redirects=True,
)
return client
@pytest.fixture
def db() -> Iterator[Session]:
# The Alembic stamp has to go too, or `upgrade` finds nothing left to do.
Base.metadata.drop_all(bind=engine)
with engine.begin() as connection:
connection.exec_driver_sql("DROP TABLE IF EXISTS alembic_version")
run_migrations()
with SessionLocal() as session:
ensure_initial_data(session)
yield session
@pytest.fixture(scope="session", autouse=True)
def cleanup_database() -> Iterator[None]:
yield
engine.dispose()
_TMP.cleanup()
@pytest.fixture
def client(db: Session) -> Iterator[TestClient]:
with TestClient(app) as test_client:
yield test_client
@pytest.fixture
def admin(db: Session) -> User:
"""The administrator the app lifespan bootstraps, with a known password."""
user = db.scalar(select(User).where(User.username == settings.initial_admin_username))
assert user is not None
user.password_hash = hash_password("secret123")
db.commit()
return user
@pytest.fixture
def logged_in(client: TestClient, admin: User) -> TestClient:
return sign_in(client, admin.username, "secret123")
@pytest.fixture
def schedule(db: Session) -> Schedule:
row = Schedule(
name="Almoço de sábado",
slug="almoco-sabado",
schedule_type="rotation",
weekdays="5",
rotation_anchor_date=date(2026, 1, 3),
)
db.add(row)
db.commit()
return row
@pytest.fixture
def team(db: Session) -> Team:
row = Team(name="Equipa Norte")
db.add(row)
db.commit()
return row
+177
View File
@@ -0,0 +1,177 @@
from datetime import date, time, timedelta
from io import BytesIO
import pytest
from pypdf import PdfReader
from sqlalchemy import select
from app.config import settings
from app.models import Assignment, Schedule, Team
from app.pdf import build_schedule_pdf
def download(client, schedule, start="2026-09-19", end="2026-12-26"):
return client.get("/export/schedule.pdf", params={
"schedule_id": schedule.id, "start_date": start, "end_date": end,
})
def test_form_has_editable_dates_and_download_action(logged_in, schedule, monkeypatch):
monkeypatch.setattr(settings, "root_path", "/crr")
response = logged_in.get("/admin/schedules", params={"schedule_id": schedule.id})
assert response.status_code == 200
assert 'type="date" name="start_date"' in response.text
assert 'type="date" name="end_date"' in response.text
assert 'action="/crr/export/schedule.pdf"' in response.text
assert "Descarregar PDF" in response.text
assert "window.print" not in response.text
assert f'value="{schedule.id}" selected' in response.text
assert 'id="export-pdf"' in response.text
assert f'/crr/admin/schedules?schedule_id={schedule.id}#export-pdf' in response.text
def test_old_admin_export_link_redirects_to_integrated_controls(logged_in, schedule):
response = logged_in.get("/export/schedule", params={"schedule_id": schedule.id}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == f"/admin/schedules?schedule_id={schedule.id}#export-pdf"
def test_member_export_does_not_grant_administration_access(logged_in, db, admin, schedule):
admin.is_admin = False
db.commit()
assert logged_in.get("/admin/schedules").status_code == 403
response = logged_in.get("/export/schedule", params={"schedule_id": schedule.id})
assert response.status_code == 200
assert 'type="date" name="start_date"' in response.text
assert f'value="{schedule.id}" selected' in response.text
assert download(logged_in, schedule).headers["content-type"] == "application/pdf"
def test_both_export_routes_require_login(client, schedule):
assert client.get("/export/schedule").status_code == 401
assert download(client, schedule).status_code == 401
def test_pdf_is_one_a4_page_with_month_lists_and_inclusive_dates(logged_in, db, schedule):
included = Team(name="João & Luís (Farragão)")
excluded = Team(name="Não incluir")
db.add_all([included, excluded])
db.flush()
db.add_all([
Assignment(schedule_id=schedule.id, date=day, team_id=team.id, source=source)
for day, team, source in [
(date(2026, 9, 12), excluded, "generated"),
(date(2026, 9, 19), included, "manual"),
(date(2026, 12, 26), included, "swap"),
(date(2027, 1, 2), excluded, "generated"),
]
])
db.commit()
response = download(logged_in, schedule)
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
assert response.headers["content-disposition"].startswith("attachment;")
assert "2026-09-19-2026-12-26.pdf" in response.headers["content-disposition"]
assert response.content.startswith(b"%PDF-")
pdf = PdfReader(BytesIO(response.content))
assert len(pdf.pages) == 1
page = pdf.pages[0]
assert float(page.mediabox.width) == pytest.approx(595.28, abs=0.1)
assert float(page.mediabox.height) == pytest.approx(841.89, abs=0.1)
text = page.extract_text()
assert "João & Luís (Farragão)" in text
assert "Não incluir" not in text
headings = ["Setembro 2026", "Outubro 2026", "Novembro 2026", "Dezembro 2026"]
assert all(heading in text for heading in headings)
assert [text.index(heading) for heading in headings] == sorted(text.index(h) for h in headings)
assert "19\nJoão" in text and "26\nJoão" in text
assert "Sem atribuição" in text
db.expire_all()
preserved = db.scalars(select(Assignment).where(
Assignment.schedule_id == schedule.id,
Assignment.source.in_(["manual", "swap"]),
).order_by(Assignment.date)).all()
assert [(row.team_id, row.source) for row in preserved] == [
(included.id, "manual"), (included.id, "swap"),
]
def test_other_schedule_assignments_are_excluded(logged_in, db, schedule, team):
other = Schedule(name="Outra escala", slug="other", schedule_type="fixed", weekdays="5")
db.add(other)
db.flush()
db.add(Assignment(schedule_id=other.id, date=date(2026, 9, 19), team_id=team.id, source="manual"))
db.commit()
response = download(logged_in, schedule)
text = PdfReader(BytesIO(response.content)).pages[0].extract_text()
assert team.name not in text
assert other.name not in text
def test_full_daily_year_fits_one_page_without_losing_rows(logged_in, db, team):
daily = Schedule(name="Noites de teste", slug="test-night", schedule_type="fixed", weekdays="0,1,2,3,4,5,6")
db.add(daily)
db.flush()
from app.models import MonthlyPattern
for day in range(1, 32):
db.add(MonthlyPattern(schedule_id=daily.id, day_of_month=day, team_id=team.id))
db.commit()
response = download(logged_in, daily, "2028-01-01", "2028-12-31")
pdf = PdfReader(BytesIO(response.content))
assert len(pdf.pages) == 1
assert pdf.pages[0].extract_text().count(team.name) == 366
@pytest.mark.parametrize(("start", "end", "message"), [
("2026-12-26", "2026-09-19", "data final"),
("2026-01-01", "2027-01-01", "12 meses"),
("1999-12-01", "2000-01-01", "2000 e 2100"),
])
def test_invalid_ranges_keep_form_values_and_show_error(logged_in, schedule, start, end, message):
response = download(logged_in, schedule, start, end)
assert response.status_code == 400
assert message in response.text
assert f'value="{start}"' in response.text and f'value="{end}"' in response.text
assert 'id="export-pdf"' in response.text
assert f'value="{schedule.id}" selected' in response.text
def test_malformed_date_is_rejected(logged_in, schedule):
assert download(logged_in, schedule, "2026-02-30", "2026-03-01").status_code == 422
def test_single_day_and_empty_range_still_produce_pdf(logged_in, schedule):
response = download(logged_in, schedule, "2026-09-19", "2026-09-19")
text = PdfReader(BytesIO(response.content)).pages[0].extract_text()
assert text.count("Sem atribuição") == 1
response = download(logged_in, schedule, "2026-09-20", "2026-09-20")
assert "Sem turnos neste período." in PdfReader(BytesIO(response.content)).pages[0].extract_text()
def test_inactive_or_unknown_schedule_is_rejected(logged_in, db, schedule):
schedule.is_active = False
db.commit()
assert download(logged_in, schedule).status_code == 404
assert logged_in.get("/export/schedule", params={"schedule_id": 999999}).status_code == 404
def test_dense_long_names_wrap_and_remain_inside_page():
schedule = Schedule(name="Noite", start_time=time(20, 30), end_time=time(0))
team = Team(name="João " + "Carreira Ligeiro Farragão " * 4)
first, last = date(2028, 1, 1), date(2028, 12, 31)
rows = [Assignment(date=first + timedelta(days=index), team=team) for index in range(366)]
pdf = PdfReader(BytesIO(build_schedule_pdf(schedule, rows, first, last)))
assert len(pdf.pages) == 1
page = pdf.pages[0]
assert page.extract_text().count("João") == 366
positions = []
def record(text, cm, tm, font, size):
if text.strip():
x = tm[4] * cm[0] + tm[5] * cm[2] + cm[4]
y = tm[4] * cm[1] + tm[5] * cm[3] + cm[5]
positions.append((x, y))
page.extract_text(visitor_text=record)
assert all(0 <= x <= float(page.mediabox.width) and 0 <= y <= float(page.mediabox.height)
for x, y in positions)