Compare commits
2
Commits
e4ac508ec2
...
5ca0a6c239
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ca0a6c239 | ||
|
|
2caef99560 |
@@ -15,6 +15,7 @@ live in `requirements-dev.txt`.
|
||||
- `app/scheduling.py`: monthly pattern and rotation generation.
|
||||
- `app/bootstrap.py`: initial administrator and seeded schedules.
|
||||
- `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/security.py`: password hashing and CSRF token generation.
|
||||
- `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.
|
||||
- `alembic.ini`: migration paths and logging; the database URL comes from settings.
|
||||
- `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
|
||||
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.
|
||||
- Prefer behavior and domain invariants over assertions about implementation details.
|
||||
- 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
|
||||
suite verifies the runner only, not application behavior.
|
||||
|
||||
@@ -106,8 +106,9 @@ or run Docker or database setup commands.
|
||||
|
||||
The framework is **pytest**, with **HTTPX** available for FastAPI's `TestClient`.
|
||||
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
|
||||
that result as success. Actual failures and pytest errors return a nonzero status.
|
||||
The suite covers PDF downloads, date boundaries, permissions, preservation of
|
||||
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:
|
||||
|
||||
@@ -115,7 +116,7 @@ Optional pytest arguments use the same command:
|
||||
make test ARGS="-k rotation -v"
|
||||
```
|
||||
|
||||
## Adding tests later
|
||||
## Adding tests
|
||||
|
||||
- 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.
|
||||
|
||||
+167
@@ -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()
|
||||
@@ -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>
|
||||
@@ -20,7 +20,7 @@
|
||||
{% if card.kind == 'rotation' %}
|
||||
<section class="panel">
|
||||
<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">
|
||||
<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>
|
||||
@@ -31,15 +31,6 @@
|
||||
<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>
|
||||
</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>
|
||||
<form method="post" action="{{ app_path('/admin/schedules/' ~ schedule.id ~ '/assignments') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
@@ -99,18 +90,32 @@
|
||||
</div>
|
||||
<div class="sticky-actions"><button class="primary" type="submit">Guardar padrão</button></div>
|
||||
</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">
|
||||
<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 %}
|
||||
</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>
|
||||
</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>
|
||||
</section>
|
||||
{% 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">
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
<a class="button primary" href="{{ app_path('/admin/assign') }}">Atribuir por dia</a>
|
||||
</div>
|
||||
<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 %}
|
||||
{% set schedule = card.schedule %}
|
||||
<section class="panel">
|
||||
@@ -16,7 +23,10 @@
|
||||
<h2>{{ schedule.name }}</h2>
|
||||
<p class="muted">{{ card.kind_label }} · {{ schedule|horario }}{% if not schedule.is_active %} · inativa{% endif %}</p>
|
||||
</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>
|
||||
{% if card.kind == 'rotation' %}
|
||||
<p class="muted">Próximas datas</p>
|
||||
@@ -39,7 +49,6 @@
|
||||
{% else %}
|
||||
<div class="empty">Ainda não há escalas. Execute <code>python -m app.cli init-db</code> para criar as predefinidas.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<section class="panel">
|
||||
<h2>Calendário da equipa</h2>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<nav class="nav" aria-label="Navegação principal">
|
||||
{% if current_user %}
|
||||
<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 %}
|
||||
<a href="{{ app_path('/admin/schedules') }}">Gerir escalas</a>
|
||||
<a href="{{ app_path('/admin/teams') }}">Equipas</a>
|
||||
|
||||
@@ -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
@@ -34,6 +34,7 @@ from .i18n import (
|
||||
window_label,
|
||||
)
|
||||
from .notifications import send_email_notification, send_many
|
||||
from .pdf import build_schedule_pdf
|
||||
from .scheduling import (
|
||||
assignments_by_date,
|
||||
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")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -1507,17 +1605,48 @@ def admin_user_delete(
|
||||
|
||||
|
||||
@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)
|
||||
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)
|
||||
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(
|
||||
request=request,
|
||||
name="admin_schedules.html",
|
||||
status_code=status_code,
|
||||
context=_context(
|
||||
request,
|
||||
db,
|
||||
cards=cards,
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ For local development, `make dev` prepares the host virtualenv and default `data
|
||||
- `scheduling.py`: deterministic month/rotation generation.
|
||||
- `web.py`: authentication, admin editing and change-request orchestration.
|
||||
- `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.
|
||||
|
||||
## Data lifecycle
|
||||
@@ -36,4 +38,4 @@ For larger installations, the SQLAlchemy configuration can point to PostgreSQL,
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -91,6 +91,22 @@ Every assignment on a lunch schedule page has a **Regenerate** button. An admini
|
||||
|
||||
## 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.
|
||||
- `/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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest>=8,<9
|
||||
httpx>=0.27,<1
|
||||
pypdf>=5,<7
|
||||
|
||||
@@ -7,3 +7,4 @@ itsdangerous>=2.2,<3
|
||||
email-validator>=2.2,<3
|
||||
pydantic-settings>=2.5,<3
|
||||
alembic>=1.13,<2
|
||||
reportlab>=4.2,<5
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user