feat: export calendar and backup db
This commit is contained in:
@@ -27,6 +27,14 @@ SMTP_PASSWORD=
|
||||
SMTP_FROM=bar-rota@example.com
|
||||
SMTP_STARTTLS=true
|
||||
|
||||
# Secret token of the shared whole-rota calendar feed (/calendar/<token>.ics).
|
||||
# Leave empty and it is generated the first time an admin opens the schedules page.
|
||||
CALENDAR_TOKEN=
|
||||
|
||||
# Where the startup backups of the SQLite file go. Empty means "backups" next to
|
||||
# the database file. One copy per start, kept forever; names are never reused.
|
||||
BACKUP_DIR=
|
||||
|
||||
# Reminder command looks this many days ahead.
|
||||
REMINDER_DAYS_AHEAD=1
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ To move to another machine, stop the app, copy the **entire `data/` directory**
|
||||
|
||||
Behind a reverse proxy, set `BASE_URL` to the public address (used in e-mail links), `ROOT_PATH` to the prefix the app is served under when it is not at the domain root (for example `/crr` for `https://host/crr/`), and `SESSION_HTTPS_ONLY=true` when the proxy terminates TLS. Uvicorn runs with `--proxy-headers --forwarded-allow-ips '*'`, so `X-Forwarded-Proto`/`X-Forwarded-Host` set the scheme and host used in generated URLs and cookies; the proxy must forward them and keep the prefix it advertises.
|
||||
|
||||
On every start the application writes a copy of the SQLite file to `backups/` beside the database (override with `BACKUP_DIR`), named `rota-<timestamp>.db` and never overwriting an older copy. Nothing is deleted, so the folder grows by one file per start; prune it yourself if that becomes too much.
|
||||
|
||||
`DATA_DIR` changes the Docker host directory mounted at `/app/data`. Keep `DATABASE_URL=sqlite:///./data/bar_rota.db` in Docker: this path is relative to `/app` inside the container. For seamless switching between host and Docker, use the default `DATA_DIR=./data`; a custom directory requires a matching host `DATABASE_URL` when running locally. The port for Make development commands is selected with `PORT=...`; direct Compose commands use `.env`'s `PORT`. Update `BASE_URL` when changing the URL used in notification links.
|
||||
|
||||
## Email
|
||||
|
||||
+7
-4
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, time
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -32,7 +32,8 @@ def ensure_initial_data(db: Session) -> None:
|
||||
"slug": "night",
|
||||
"schedule_type": "fixed",
|
||||
"weekdays": "0,1,2,3,4,5,6",
|
||||
"label_time": "20:30–00:00",
|
||||
"start_time": time(20, 30),
|
||||
"end_time": time(0, 0),
|
||||
"rotation_anchor_date": None,
|
||||
},
|
||||
{
|
||||
@@ -40,7 +41,8 @@ def ensure_initial_data(db: Session) -> None:
|
||||
"slug": "saturday-lunch",
|
||||
"schedule_type": "rotation",
|
||||
"weekdays": "5",
|
||||
"label_time": "13:00–16:00",
|
||||
"start_time": time(13, 0),
|
||||
"end_time": time(16, 0),
|
||||
"rotation_anchor_date": date(2024, 1, 6),
|
||||
},
|
||||
{
|
||||
@@ -48,7 +50,8 @@ def ensure_initial_data(db: Session) -> None:
|
||||
"slug": "sunday-lunch",
|
||||
"schedule_type": "rotation",
|
||||
"weekdays": "6",
|
||||
"label_time": "13:00–16:00",
|
||||
"start_time": time(13, 0),
|
||||
"end_time": time(16, 0),
|
||||
"rotation_anchor_date": date(2024, 1, 7),
|
||||
},
|
||||
]
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
"""iCalendar feed of the rota, so members can subscribe from any calendar app.
|
||||
|
||||
Events use floating local times (`DTSTART:20261017T203000`): the rota is read in
|
||||
the bar's own timezone, and every client resolves a floating time against the
|
||||
device it is viewed on, which keeps the feed free of a timezone database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .config import settings
|
||||
from .i18n import day_long, day_numeric_long, window_label
|
||||
from .models import Assignment, Schedule, User
|
||||
|
||||
# How much of the rota the feed publishes: recent history and the next months.
|
||||
MONTHS_BACK = 1
|
||||
MONTHS_FORWARD = 6
|
||||
|
||||
|
||||
def _stamp(moment: datetime) -> str:
|
||||
return moment.strftime("%Y%m%dT%H%M%S")
|
||||
|
||||
|
||||
def _fold(line: str) -> list[str]:
|
||||
"""RFC 5545 folds long lines at 75 octets."""
|
||||
raw = line.encode("utf-8")
|
||||
if len(raw) <= 75:
|
||||
return [line]
|
||||
chunks = [raw[:75]]
|
||||
rest = raw[75:]
|
||||
while rest:
|
||||
chunks.append(rest[:74])
|
||||
rest = rest[74:]
|
||||
return [chunks[0].decode("utf-8")] + [" " + c.decode("utf-8") for c in chunks[1:]]
|
||||
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace(";", "\\;")
|
||||
.replace(",", "\\,")
|
||||
.replace("\n", "\\n")
|
||||
)
|
||||
|
||||
|
||||
def _event(assignment: Assignment, person: User | None) -> list[str]:
|
||||
schedule = assignment.schedule
|
||||
start = datetime.combine(assignment.date, schedule.start_time)
|
||||
end = datetime.combine(assignment.date, schedule.end_time)
|
||||
if end <= start: # a shift that runs past midnight
|
||||
end += timedelta(days=1)
|
||||
summary = f"{schedule.name} · {window_label(schedule.start_time, schedule.end_time)}"
|
||||
description = [
|
||||
day_long(assignment.date),
|
||||
f"{schedule.start_time:%H:%M}–{schedule.end_time:%H:%M}",
|
||||
]
|
||||
if person is not None:
|
||||
description.append(f"Pessoa: {person.name}")
|
||||
description.append(settings.app_name)
|
||||
return [
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:assignment-{assignment.id}@{_host()}",
|
||||
f"DTSTAMP:{_stamp(datetime.utcnow())}",
|
||||
f"DTSTART:{start:%Y%m%dT%H%M%S}",
|
||||
f"DTEND:{end:%Y%m%dT%H%M%S}",
|
||||
f"SUMMARY:{_escape(summary)}",
|
||||
f"DESCRIPTION:{_escape(chr(10).join(description))}",
|
||||
f"LOCATION:{_escape(settings.app_name)}",
|
||||
"TRANSP:TRANSPARENT",
|
||||
"END:VEVENT",
|
||||
]
|
||||
|
||||
|
||||
def _host() -> str:
|
||||
return (settings.base_url.split("//")[-1] or "localhost").split("/")[0]
|
||||
|
||||
|
||||
def build_feed(db: Session, person: User | None, name: str) -> str:
|
||||
"""The whole rota, or only the shifts of one person."""
|
||||
today = date.today()
|
||||
first = (today.replace(day=1) - timedelta(days=31 * MONTHS_BACK)).replace(day=1)
|
||||
last = (today.replace(day=1) + timedelta(days=32 * MONTHS_FORWARD)).replace(day=1) - timedelta(days=1)
|
||||
|
||||
schedules = db.scalars(select(Schedule).where(Schedule.is_active.is_(True))).all()
|
||||
assignments = db.scalars(
|
||||
select(Assignment)
|
||||
.where(
|
||||
Assignment.date >= first,
|
||||
Assignment.date <= last,
|
||||
Assignment.schedule_id.in_([s.id for s in schedules]),
|
||||
)
|
||||
.options(selectinload(Assignment.schedule), selectinload(Assignment.user))
|
||||
.order_by(Assignment.date)
|
||||
).all()
|
||||
if person is not None:
|
||||
assignments = [a for a in assignments if a.user_id == person.id]
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
f"PRODID:-//{_escape(settings.app_name)}//Rota//PT",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"METHOD:PUBLISH",
|
||||
f"X-WR-CALNAME:{_escape(name)}",
|
||||
f"X-WR-CALDESC:{_escape(f'{settings.app_name} — {day_numeric_long(first)} a {day_numeric_long(last)}')}",
|
||||
"REFRESH-INTERVAL;VALUE=DURATION:PT12H",
|
||||
"X-PUBLISHED-TTL:PT12H",
|
||||
]
|
||||
for assignment in assignments:
|
||||
lines.extend(_event(assignment, assignment.user))
|
||||
lines.append("END:VCALENDAR")
|
||||
folded: list[str] = []
|
||||
for line in lines:
|
||||
folded.extend(_fold(line))
|
||||
return "\r\n".join(folded) + "\r\n"
|
||||
@@ -13,6 +13,10 @@ class Settings(BaseSettings):
|
||||
root_path: str = ""
|
||||
# Networks allowed to set X-Forwarded-*; "*" for a private docker network.
|
||||
trusted_proxy_hosts: str = "*"
|
||||
# Secret token of the whole-rota calendar feed; every person also has their own.
|
||||
calendar_token: str = ""
|
||||
# Where the startup copies of the SQLite file go; empty means "backups" beside it.
|
||||
backup_dir: str = ""
|
||||
|
||||
initial_admin_name: str = "Administrador"
|
||||
initial_admin_username: str = "admin"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
@@ -60,3 +64,44 @@ def get_db() -> Generator[Session, None, None]:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def database_file(url: str | None = None) -> Path | None:
|
||||
"""The SQLite file behind DATABASE_URL, or None for another engine."""
|
||||
raw = url or settings.database_url
|
||||
if not raw.startswith("sqlite"):
|
||||
return None
|
||||
path = urlparse(raw).path
|
||||
if not path:
|
||||
return None
|
||||
# sqlite:///./data/x.db is relative, sqlite:////srv/x.db is absolute.
|
||||
if path.startswith("/"):
|
||||
path = path[1:]
|
||||
if not path or path.startswith(":memory:"):
|
||||
return None
|
||||
return Path(path)
|
||||
|
||||
|
||||
def backup_database(destination_dir: str | None = None) -> Path | None:
|
||||
"""Copy the database aside on every start, never overwriting an older copy.
|
||||
|
||||
Uses SQLite's own backup API so the copy is consistent even if another
|
||||
process is writing. Returns the new file, or None when there is nothing to copy.
|
||||
"""
|
||||
source = database_file()
|
||||
if source is None or not source.is_file():
|
||||
return None
|
||||
|
||||
folder = Path(destination_dir or settings.backup_dir or source.parent / "backups")
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = folder / f"{source.stem}-{stamp}{source.suffix}"
|
||||
index = 1
|
||||
while target.exists(): # two starts in the same second
|
||||
target = folder / f"{source.stem}-{stamp}-{index}{source.suffix}"
|
||||
index += 1
|
||||
|
||||
with sqlite3.connect(source) as origin, sqlite3.connect(target) as copy:
|
||||
origin.backup(copy)
|
||||
shutil.copystat(source, target)
|
||||
return target
|
||||
|
||||
+6
-1
@@ -7,7 +7,7 @@ month names come from the tables below instead of the `calendar` module.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, time
|
||||
|
||||
MONTHS = (
|
||||
"Janeiro",
|
||||
@@ -145,3 +145,8 @@ def notification_status_label(status: str) -> str:
|
||||
|
||||
def event_type_label(event_type: str) -> str:
|
||||
return EVENT_TYPE_LABELS.get(event_type, event_type)
|
||||
|
||||
|
||||
def window_label(start: time, end: time) -> str:
|
||||
"""`20:30–00:00`, the hours a shift runs."""
|
||||
return f"{start:%H:%M}\u2013{end:%H:%M}"
|
||||
|
||||
+10
@@ -12,6 +12,7 @@ from .bootstrap import ensure_initial_data
|
||||
from .config import settings
|
||||
from .database import (
|
||||
SessionLocal,
|
||||
backup_database,
|
||||
current_revision,
|
||||
has_tables,
|
||||
run_migrations,
|
||||
@@ -28,6 +29,7 @@ from .i18n import (
|
||||
source_label,
|
||||
swap_status_label,
|
||||
weekday_name,
|
||||
window_label,
|
||||
)
|
||||
from .web import router
|
||||
|
||||
@@ -36,6 +38,13 @@ BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# A copy of the database before anything else runs; never fatal.
|
||||
try:
|
||||
backup = backup_database()
|
||||
if backup is not None:
|
||||
print(f"Database backup written to {backup}")
|
||||
except Exception as exc: # a failed backup must never stop the app
|
||||
print(f"Could not back up the database: {exc}")
|
||||
# Databases created before Alembic existed have no version stamp: adopt them
|
||||
# at the baseline revision so the migrations can continue from there.
|
||||
if has_tables() and current_revision() is None:
|
||||
@@ -70,6 +79,7 @@ templates.env.filters.update(
|
||||
"estado": swap_status_label,
|
||||
"estado_email": notification_status_label,
|
||||
"evento": event_type_label,
|
||||
"horario": lambda schedule: window_label(schedule.start_time, schedule.end_time),
|
||||
}
|
||||
)
|
||||
templates.env.globals["app_path"] = settings.url
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import (
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
Time,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -27,6 +28,7 @@ class User(Base):
|
||||
email: Mapped[str | None] = mapped_column(String(254), unique=True, nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
calendar_token: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
notify_email: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
@@ -45,7 +47,9 @@ class Schedule(Base):
|
||||
slug: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
|
||||
schedule_type: Mapped[str] = mapped_column(String(20), nullable=False) # fixed | rotation
|
||||
weekdays: Mapped[str] = mapped_column(String(30), nullable=False) # comma-separated 0..6
|
||||
label_time: Mapped[str] = mapped_column(String(80), default="", nullable=False)
|
||||
# When the shift runs, in the bar's local time. An end at or before the start means the next day.
|
||||
start_time: Mapped[time] = mapped_column(Time, default=time(20, 30), nullable=False)
|
||||
end_time: Mapped[time] = mapped_column(Time, default=time(23, 59), nullable=False)
|
||||
requires_manager_approval: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
rotation_anchor_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
@@ -104,6 +104,7 @@ th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line)}
|
||||
th{font-size:.8rem;text-transform:uppercase;color:var(--muted);background:#fff7f7}
|
||||
.sticky-actions{position:sticky;bottom:10px;padding:15px 0;text-align:right}
|
||||
.inline-form select{width:auto;min-width:190px}
|
||||
.inline-form input[type=text]{min-width:260px;flex:1}
|
||||
@media(max-width:700px){
|
||||
.topbar-inner{padding:8px 0}
|
||||
.brand-logo{width:40px;height:36px}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<input type="hidden" name="assignment_id" value="{{ row.assignment.id }}">
|
||||
<div>
|
||||
<strong>{{ row.schedule.name }}</strong>
|
||||
{% if row.schedule.label_time %}<span class="muted"> · {{ row.schedule.label_time }}</span>{% endif %}
|
||||
<span class="muted"> · {{ row.schedule|horario }}</span>
|
||||
<div class="muted">
|
||||
{% if row.assignment.user %}
|
||||
Agora: {{ row.assignment.user.name }} ({{ row.assignment.source|origem }})
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
<div>
|
||||
<p class="eyebrow"><a href="{{ app_path('/admin/schedules') }}">Escalas</a></p>
|
||||
<h1>{{ schedule.name }}</h1>
|
||||
<p class="muted">{{ card.kind_label }}{% if schedule.label_time %} · {{ schedule.label_time }}{% endif %}</p>
|
||||
<p class="muted">{{ card.kind_label }} · {{ schedule|horario }}</p>
|
||||
</div>
|
||||
<form method="post" action="{{ app_path('/admin/schedules/' ~ schedule.id ~ '/settings') }}" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Das horas<input type="time" name="start_time" value="{{ schedule.start_time.strftime('%H:%M') }}" required></label>
|
||||
<label>Às horas<input type="time" name="end_time" value="{{ schedule.end_time.strftime('%H:%M') }}" required></label>
|
||||
<label class="check"><input type="checkbox" name="requires_manager_approval" {% if schedule.requires_manager_approval %}checked{% endif %}> Aprovação da gestão</label>
|
||||
<label class="check"><input type="checkbox" name="is_active" {% if schedule.is_active %}checked{% endif %}> Ativa</label>
|
||||
<button class="secondary" type="submit">Guardar</button>
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
</div>
|
||||
<a class="button primary" href="{{ app_path('/admin/assign') }}">Atribuir por dia</a>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<h3>Calendário da rota</h3>
|
||||
<p class="muted">Um link para partilhar com a equipa, com os turnos de toda a gente, para adicionar a qualquer calendário. Cada pessoa tem também o seu feed só com os turnos dela.</p>
|
||||
<p><code>{{ calendar_feed_url }}</code></p>
|
||||
<a class="button secondary" href="webcal://{{ calendar_feed_url.split('//')[-1] }}">Subscrever o calendário do bar</a>
|
||||
</section>
|
||||
<div class="stack gap">
|
||||
{% for card in cards %}
|
||||
{% set schedule = card.schedule %}
|
||||
@@ -14,7 +20,7 @@
|
||||
<div class="row between wrap gap">
|
||||
<div>
|
||||
<h2>{{ schedule.name }}</h2>
|
||||
<p class="muted">{{ card.kind_label }}{% if schedule.label_time %} · {{ schedule.label_time }}{% endif %}{% 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>
|
||||
<a class="button primary" href="{{ app_path('/admin/schedules/' ~ schedule.id ) }}">Configurar</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Calendário · {{ settings.app_name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<p class="eyebrow"><a href="{{ app_path('/') }}">Rota</a></p>
|
||||
<h1>Calendário</h1>
|
||||
<p class="muted">Adiciona o endereço ao Google Calendar, Apple Calendar ou Outlook. O calendário atualiza-se sozinho, não é preciso repetir o processo.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack gap-lg">
|
||||
{% for feed in feeds %}
|
||||
<section class="panel">
|
||||
<h2>{{ feed.title }}</h2>
|
||||
<div class="inline-form">
|
||||
<input type="text" readonly value="{{ feed.url }}" aria-label="Endereço do calendário">
|
||||
<button class="secondary" type="button" data-copy="{{ feed.url }}">Copiar</button>
|
||||
<a class="button secondary" href="webcal://{{ feed.url.split('//')[-1] }}">Subscrever</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Copy the address without leaving the page; falls back to selecting the field.
|
||||
document.querySelectorAll("[data-copy]").forEach(function (button) {
|
||||
button.addEventListener("click", function () {
|
||||
var url = button.dataset.copy;
|
||||
var field = button.parentElement.querySelector("input");
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(url);
|
||||
button.textContent = "Copiado";
|
||||
setTimeout(function () { button.textContent = "Copiar"; }, 1500);
|
||||
return;
|
||||
}
|
||||
field.focus();
|
||||
field.select();
|
||||
button.textContent = "Copie com Ctrl+C";
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -5,7 +5,10 @@
|
||||
<h1>{{ month_name }} {{ year }}</h1>
|
||||
</div>
|
||||
<div class="month-nav">
|
||||
{% if current_user %}<a class="button primary" href="{{ app_path('/swaps/new?year=' ~ year ~ '&month=' ~ month ) }}">Pedir troca</a>{% endif %}
|
||||
{% if current_user %}
|
||||
<a class="button secondary" href="{{ app_path('/calendar') }}">Calendário</a>
|
||||
<a class="button primary" href="{{ app_path('/swaps/new?year=' ~ year ~ '&month=' ~ month ) }}">Pedir troca</a>
|
||||
{% endif %}
|
||||
<a class="button secondary" href="{{ app_path('/?year=' ~ prev[0] ~ '&month=' ~ prev[1] ) }}">← Anterior</a>
|
||||
<a class="button secondary" href="{{ app_path('/?year=' ~ nxt[0] ~ '&month=' ~ nxt[1] ) }}">Seguinte →</a>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div>
|
||||
<p class="eyebrow"><a href="{{ app_path('/schedules') }}">Escalas</a></p>
|
||||
<h1>{{ schedule.name }}</h1>
|
||||
<p class="muted">{{ card.kind_label }}{% if schedule.label_time %} · {{ schedule.label_time }}{% endif %}</p>
|
||||
<p class="muted">{{ card.kind_label }} · {{ schedule|horario }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack gap-lg">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="row between wrap gap">
|
||||
<div>
|
||||
<h2>{{ card.schedule.name }}</h2>
|
||||
<p class="muted">{{ card.kind_label }}{% if card.schedule.label_time %} · {{ card.schedule.label_time }}{% endif %}</p>
|
||||
<p class="muted">{{ card.kind_label }} · {{ card.schedule|horario }}</p>
|
||||
</div>
|
||||
<a class="button secondary" href="{{ app_path('/schedules/' ~ card.schedule.id ) }}">Abrir escala</a>
|
||||
</div>
|
||||
|
||||
+85
-3
@@ -2,13 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from collections.abc import Sequence
|
||||
from datetime import date, datetime, timedelta
|
||||
import secrets
|
||||
from datetime import date, datetime, time, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .calendar import build_feed
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .models import (
|
||||
@@ -26,6 +28,7 @@ from .i18n import (
|
||||
day_short,
|
||||
month_name,
|
||||
weekday_names,
|
||||
window_label,
|
||||
)
|
||||
from .notifications import send_email_notification, send_many
|
||||
from .scheduling import assignments_by_date, ensure_month_assignments, month_bounds, rotation_user_for_date
|
||||
@@ -38,6 +41,13 @@ def _redirect(path: str, status_code: int = 303) -> RedirectResponse:
|
||||
return RedirectResponse(settings.url(path), status_code=status_code)
|
||||
|
||||
|
||||
def _parse_time(value: str) -> time:
|
||||
try:
|
||||
return time.fromisoformat(value.strip())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Hora inválida")
|
||||
|
||||
|
||||
def _current_user(request: Request, db: Session) -> User | None:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
@@ -192,6 +202,60 @@ def _month_nav(year: int, month: int) -> tuple[tuple[int, int], tuple[int, int]]
|
||||
return prev, nxt
|
||||
|
||||
|
||||
def _calendar_token(db: Session, user: User) -> str:
|
||||
"""A private feed token for this person, created the first time it is needed."""
|
||||
if not user.calendar_token:
|
||||
user.calendar_token = secrets.token_urlsafe(24)
|
||||
db.commit()
|
||||
return user.calendar_token
|
||||
|
||||
|
||||
@router.get("/calendar/{token}.ics")
|
||||
def calendar_feed(
|
||||
token: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Subscribe from any calendar app: one feed per person, plus a shared whole-rota feed."""
|
||||
person = db.scalar(select(User).where(User.calendar_token == token, User.is_active.is_(True)))
|
||||
shared = bool(settings.calendar_token) and token == settings.calendar_token
|
||||
if person is None and not shared:
|
||||
raise HTTPException(status_code=404, detail="Calendário não encontrado")
|
||||
name = f"{settings.app_name} — {person.name}" if person else settings.app_name
|
||||
return Response(
|
||||
content=build_feed(db, person, name),
|
||||
media_type="text/calendar; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": 'inline; filename="rota.ics"',
|
||||
"Cache-Control": "private, max-age=300",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/calendar", response_class=HTMLResponse)
|
||||
def calendar_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""The links to subscribe, kept off the rota so that page stays about the shifts."""
|
||||
user = _require_user(request, db)
|
||||
feeds = [
|
||||
{
|
||||
"title": "Os meus turnos",
|
||||
"url": f"{request.base_url.scheme}://{request.base_url.netloc}"
|
||||
f"{settings.url(f'/calendar/{_calendar_token(db, user)}.ics')}",
|
||||
}
|
||||
]
|
||||
if user.is_admin:
|
||||
feeds.append(
|
||||
{
|
||||
"title": "Rota completa",
|
||||
"url": _shared_calendar_url(request),
|
||||
}
|
||||
)
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="calendar.html",
|
||||
context=_context(request, db, feeds=feeds),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -1156,10 +1220,23 @@ def admin_schedules(request: Request, db: Session = Depends(get_db)):
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admin_schedules.html",
|
||||
context=_context(request, db, cards=cards),
|
||||
context=_context(
|
||||
request,
|
||||
db,
|
||||
cards=cards,
|
||||
calendar_feed_url=_shared_calendar_url(request),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _shared_calendar_url(request: Request) -> str:
|
||||
"""The whole-rota feed, with a team token created the first time it is asked for."""
|
||||
if not settings.calendar_token:
|
||||
settings.calendar_token = secrets.token_urlsafe(24)
|
||||
path = settings.url(f"/calendar/{settings.calendar_token}.ics")
|
||||
return f"{request.base_url.scheme}://{request.base_url.netloc}{path}"
|
||||
|
||||
|
||||
@router.get("/admin/schedules/{schedule_id}", response_class=HTMLResponse)
|
||||
def admin_schedule(
|
||||
schedule_id: int,
|
||||
@@ -1314,6 +1391,8 @@ def admin_schedule_settings(
|
||||
request: Request,
|
||||
requires_manager_approval: str = Form(""),
|
||||
is_active: str = Form(""),
|
||||
start_time: str = Form(""),
|
||||
end_time: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -1322,6 +1401,9 @@ def admin_schedule_settings(
|
||||
schedule = db.get(Schedule, schedule_id)
|
||||
if not schedule:
|
||||
raise HTTPException(status_code=404, detail="Escala não encontrada")
|
||||
if start_time and end_time:
|
||||
schedule.start_time = _parse_time(start_time)
|
||||
schedule.end_time = _parse_time(end_time)
|
||||
schedule.requires_manager_approval = bool(requires_manager_approval)
|
||||
schedule.is_active = bool(is_active)
|
||||
db.commit()
|
||||
|
||||
+13
-1
@@ -9,7 +9,19 @@ This distinction is intentional: editing a rotation must not retroactively chang
|
||||
## Seeded schedules
|
||||
|
||||
Seeded names are Portuguese (`Noite`, `Almoço - Sábados`, `Almoço - Domingo`); the slugs stay `night`, `saturday-lunch` and `sunday-lunch`.
|
||||
The seeded `label_time` carries the hours the shift runs, shown next to the schedule name: `20:30–00:00` for the night and `13:00–16:00` for both lunches. Both migrations are guarded by slug and previous value, so a label an administrator chose is never overwritten.
|
||||
Each schedule carries `start_time`/`end_time`, the hours the shift runs, editable by an administrator on its configuration page: `20:30–00:00` for the night and `13:00–16:00` for both lunches. An end at or before the start means the shift runs into the next day.
|
||||
|
||||
## Backups
|
||||
|
||||
Every start copies the SQLite file aside before anything else runs, using SQLite's own backup API so the copy is consistent. Files go to `backups/` next to the database (or `BACKUP_DIR`), are named `rota-YYYYmmdd-HHMMSS.db` and are never overwritten: two starts in the same second increment the name. Nothing is copied for another engine or an in-memory database, and a failed backup only logs a warning instead of stopping the app. Nothing is ever pruned, so the folder grows one file per start.
|
||||
|
||||
## Calendar feeds
|
||||
|
||||
- `/calendar/<token>.ics` is an iCalendar feed anybody can subscribe to from Google Calendar, Apple Calendar or Outlook. Every person has their own token, created the first time they open `/calendar`, and their feed only carries the shifts assigned to them.
|
||||
- `/calendar` is the page holding those links: the rota only has a button pointing there, so it keeps showing nothing but who works each day. Each address sits in a readonly field with a **Copiar** button (clipboard when the browser allows it, selecting the field otherwise) plus a `webcal://` shortcut.
|
||||
- Administrators also get one shared feed with the whole rota, using the team token in `CALENDAR_TOKEN` (generated on first use and shown on `/admin/schedules`).
|
||||
- A feed publishes one month back and six months forward, with floating local times so no timezone database is needed. Times and names are private member data: the token is the only protection, so the shared link goes only to the team.
|
||||
- An unknown token returns 404, and a deactivated person's token stops working.
|
||||
|
||||
### Night (`Noite`)
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""schedule times and personal calendar tokens
|
||||
|
||||
Revision ID: 77b487b8a09b
|
||||
Revises: 258f14bb3fcc
|
||||
Create Date: 2026-10-06 13:02:11.884512
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '77b487b8a09b'
|
||||
down_revision: Union[str, Sequence[str], None] = '258f14bb3fcc'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# slug -> hours taken from the text label this replaces
|
||||
TIMES = {
|
||||
"night": ("20:30", "00:00"),
|
||||
"saturday-lunch": ("13:00", "16:00"),
|
||||
"sunday-lunch": ("13:00", "16:00"),
|
||||
}
|
||||
|
||||
DEFAULT_LABEL = {"night": "Noite", "saturday-lunch": "Almoço", "sunday-lunch": "Almoço"}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Real hours per schedule, read from the label when it still holds them."""
|
||||
connection = op.get_bind()
|
||||
with op.batch_alter_table('schedules', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('start_time', sa.Time(), nullable=True))
|
||||
batch_op.add_column(sa.Column('end_time', sa.Time(), nullable=True))
|
||||
|
||||
for slug, (start, end) in TIMES.items():
|
||||
row = connection.execute(
|
||||
sa.text("SELECT label_time FROM schedules WHERE slug = :slug"), {"slug": slug}
|
||||
).fetchone()
|
||||
label = row[0] if row else ""
|
||||
if label and "–" in label:
|
||||
left, _, right = label.partition("–")
|
||||
start, end = left.strip(), right.strip()
|
||||
connection.execute(
|
||||
sa.text("UPDATE schedules SET start_time = :start, end_time = :end WHERE slug = :slug"),
|
||||
{"start": start, "end": end, "slug": slug},
|
||||
)
|
||||
connection.execute(sa.text("UPDATE schedules SET start_time = '20:30', end_time = '23:59' WHERE start_time IS NULL"))
|
||||
connection.execute(sa.text("UPDATE schedules SET end_time = '23:59' WHERE end_time IS NULL"))
|
||||
|
||||
with op.batch_alter_table('schedules', schema=None) as batch_op:
|
||||
batch_op.alter_column('start_time', nullable=False)
|
||||
batch_op.alter_column('end_time', nullable=False)
|
||||
batch_op.drop_column('label_time')
|
||||
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('calendar_token', sa.String(length=64), nullable=True))
|
||||
batch_op.create_unique_constraint('uq_users_calendar_token', ['calendar_token'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Put the text label back, built from the hours the schedule runs."""
|
||||
connection = op.get_bind()
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('uq_users_calendar_token', type_='unique')
|
||||
batch_op.drop_column('calendar_token')
|
||||
|
||||
with op.batch_alter_table('schedules', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('label_time', sa.VARCHAR(length=80), nullable=True))
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"UPDATE schedules SET label_time = "
|
||||
"CASE WHEN slug = 'night' THEN 'Noite' ELSE 'Almoço' END"
|
||||
)
|
||||
)
|
||||
with op.batch_alter_table('schedules', schema=None) as batch_op:
|
||||
batch_op.alter_column('label_time', nullable=False)
|
||||
batch_op.drop_column('end_time')
|
||||
batch_op.drop_column('start_time')
|
||||
@@ -49,7 +49,6 @@ def seed(db):
|
||||
slug="night",
|
||||
schedule_type="fixed",
|
||||
weekdays="0,1,2,3,4,5,6",
|
||||
label_time="Noite",
|
||||
requires_manager_approval=True,
|
||||
is_active=True,
|
||||
)
|
||||
@@ -58,7 +57,6 @@ def seed(db):
|
||||
slug="saturday-lunch",
|
||||
schedule_type="rotation",
|
||||
weekdays="5",
|
||||
label_time="Lunch",
|
||||
requires_manager_approval=True,
|
||||
is_active=True,
|
||||
rotation_anchor_date=date(2026, 1, 3),
|
||||
@@ -68,7 +66,6 @@ def seed(db):
|
||||
slug="sunday-lunch",
|
||||
schedule_type="rotation",
|
||||
weekdays="6",
|
||||
label_time="Lunch",
|
||||
requires_manager_approval=True,
|
||||
is_active=True,
|
||||
rotation_anchor_date=date(2026, 1, 4),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import backup_database, database_file
|
||||
|
||||
|
||||
def _touch(path, rows: int = 1) -> None:
|
||||
with sqlite3.connect(path) as connection:
|
||||
for index in range(rows):
|
||||
connection.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)")
|
||||
connection.execute("INSERT INTO notes (body) VALUES (?)", (f"{index}",))
|
||||
connection.commit()
|
||||
|
||||
|
||||
def test_a_backup_is_written_beside_the_database(tmp_path, monkeypatch):
|
||||
db_file = tmp_path / "rota.db"
|
||||
_touch(db_file, 2)
|
||||
monkeypatch.setattr("app.config.settings.database_url", f"sqlite:///{db_file}")
|
||||
|
||||
backup = backup_database()
|
||||
|
||||
assert backup is not None
|
||||
assert backup.parent == tmp_path / "backups"
|
||||
assert backup.name.startswith("rota-")
|
||||
assert backup.name.endswith(".db")
|
||||
with sqlite3.connect(backup) as connection:
|
||||
assert connection.execute("SELECT count(*) FROM notes").fetchone()[0] == 2
|
||||
|
||||
|
||||
def test_backups_never_overwrite_each_other(tmp_path, monkeypatch):
|
||||
db_file = tmp_path / "rota.db"
|
||||
_touch(db_file)
|
||||
monkeypatch.setattr("app.config.settings.database_url", f"sqlite:///{db_file}")
|
||||
|
||||
first = backup_database()
|
||||
second = backup_database()
|
||||
|
||||
assert first != second
|
||||
assert first.exists() and second.exists()
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
assert first.stem.startswith(f"rota-{stamp}")
|
||||
# same second: the name is incremented rather than reused
|
||||
assert second.name in {f"rota-{stamp}-1.db", f"rota-{stamp}-2.db"}
|
||||
|
||||
|
||||
def test_the_backup_directory_can_be_chosen(tmp_path, monkeypatch):
|
||||
db_file = tmp_path / "rota.db"
|
||||
_touch(db_file)
|
||||
target = tmp_path / "copias"
|
||||
monkeypatch.setattr("app.config.settings.database_url", f"sqlite:///{db_file}")
|
||||
|
||||
backup = backup_database(target)
|
||||
|
||||
assert backup.parent == target
|
||||
|
||||
|
||||
def test_nothing_is_copied_without_a_database_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("app.config.settings.database_url", f"sqlite:///{tmp_path}/nao-existe.db")
|
||||
assert backup_database() is None
|
||||
assert database_file() == tmp_path / "nao-existe.db"
|
||||
assert not (tmp_path / "backups").exists()
|
||||
|
||||
monkeypatch.setattr("app.config.settings.database_url", "sqlite:///:memory:")
|
||||
assert database_file() is None
|
||||
assert backup_database() is None
|
||||
|
||||
monkeypatch.setattr("app.config.settings.database_url", "postgresql://localhost/rota")
|
||||
assert database_file() is None
|
||||
assert backup_database() is None
|
||||
|
||||
|
||||
def test_the_copy_keeps_the_data_of_that_moment(tmp_path, monkeypatch):
|
||||
db_file = tmp_path / "rota.db"
|
||||
_touch(db_file, 1)
|
||||
monkeypatch.setattr("app.config.settings.database_url", f"sqlite:///{db_file}")
|
||||
|
||||
backup = backup_database()
|
||||
assert backup is not None
|
||||
_touch(db_file, 3) # the app carries on being written to
|
||||
|
||||
with sqlite3.connect(backup) as connection:
|
||||
assert connection.execute("SELECT count(*) FROM notes").fetchone()[0] == 1
|
||||
+13
-6
@@ -1,3 +1,5 @@
|
||||
from datetime import time
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
@@ -30,14 +32,19 @@ def test_seeded_schedules_are_named_in_portuguese(monkeypatch):
|
||||
assert names["sunday-lunch"] == "Almoço - Domingo"
|
||||
|
||||
|
||||
def test_seeded_schedules_show_their_service_window(monkeypatch):
|
||||
def test_seeded_schedules_run_at_the_bar_hours(monkeypatch):
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
ensure_initial_data(db)
|
||||
windows = dict(db.execute(select(Schedule.slug, Schedule.label_time)).all())
|
||||
assert windows == {
|
||||
"night": "20:30–00:00",
|
||||
"saturday-lunch": "13:00–16:00",
|
||||
"sunday-lunch": "13:00–16:00",
|
||||
windows = {
|
||||
slug: (start, end)
|
||||
for slug, start, end in db.execute(
|
||||
select(Schedule.slug, Schedule.start_time, Schedule.end_time)
|
||||
).all()
|
||||
}
|
||||
assert windows == {
|
||||
"night": (time(20, 30), time(0, 0)),
|
||||
"saturday-lunch": (time(13, 0), time(16, 0)),
|
||||
"sunday-lunch": (time(13, 0), time(16, 0)),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
from datetime import date, time, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.calendar import build_feed
|
||||
from app.models import Assignment, Schedule, User
|
||||
# the fixtures live next door and are shared by importing them here
|
||||
from app.security import hash_password
|
||||
from test_admin_schedule_assignments import CSRF_RE, client, db, seed, sign_in # noqa: F401
|
||||
|
||||
|
||||
def unfold(feed: str) -> str:
|
||||
"""Undo RFC 5545 line folding so assertions can read whole lines."""
|
||||
return feed.replace("\r\n ", "")
|
||||
|
||||
|
||||
def _hours(db, schedule: Schedule, start: str, end: str) -> Schedule:
|
||||
schedule.start_time = time.fromisoformat(start)
|
||||
schedule.end_time = time.fromisoformat(end)
|
||||
db.commit()
|
||||
return schedule
|
||||
|
||||
|
||||
def _shift(db, schedule: Schedule, day: date, user: User) -> Assignment:
|
||||
assignment = Assignment(schedule_id=schedule.id, date=day, user_id=user.id, source="manual")
|
||||
db.add(assignment)
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
def test_the_personal_feed_only_carries_that_person_shifts(client, db):
|
||||
night, sat, sun, ana, bruno, admin = seed(db)
|
||||
_hours(db, night, "20:30", "00:00")
|
||||
_hours(db, sat, "13:00", "16:00")
|
||||
today = date.today()
|
||||
_shift(db, night, today, ana)
|
||||
_shift(db, sat, today, bruno)
|
||||
|
||||
feed = build_feed(db, ana, "Rota — Ana")
|
||||
flat = unfold(feed)
|
||||
|
||||
assert "BEGIN:VCALENDAR" in feed and feed.endswith("END:VCALENDAR\r\n")
|
||||
assert "SUMMARY:Noite · 20:30–00:00" in flat
|
||||
assert "SUMMARY:Almoço - Sábados · 13:00–16:00" not in flat # Bruno's shift
|
||||
assert "Pessoa: Ana" in flat
|
||||
assert "Bruno" not in flat
|
||||
assert f"DTSTART:{today:%Y%m%d}T203000" in flat
|
||||
# 20:30–00:00 ends after midnight, so the night ends the next morning
|
||||
assert f"DTEND:{(today + timedelta(days=1)):%Y%m%d}T000000" in flat
|
||||
|
||||
|
||||
def test_a_shift_past_midnight_ends_on_the_next_day(client, db):
|
||||
night, _, _, ana, _, _ = seed(db)
|
||||
night.start_time = time(20, 30)
|
||||
night.end_time = time(0, 0)
|
||||
db.commit()
|
||||
_shift(db, night, date(2026, 11, 7), ana)
|
||||
|
||||
feed = unfold(build_feed(db, ana, "Rota — Ana"))
|
||||
|
||||
assert "DTSTART:20261107T203000" in feed
|
||||
assert "DTEND:20261108T000000" in feed
|
||||
|
||||
|
||||
def test_the_feed_reaches_the_right_person_by_token(client, db):
|
||||
night, _, _, ana, bruno, admin = seed(db)
|
||||
_shift(db, night, date.today(), ana)
|
||||
ana.calendar_token = "ana-token"
|
||||
db.commit()
|
||||
sign_in(client)
|
||||
page = client.get("/calendar").text
|
||||
assert f"/calendar/{admin.calendar_token}.ics" in page
|
||||
|
||||
mine = unfold(client.get("/calendar/ana-token.ics").text)
|
||||
assert client.get("/calendar/ana-token.ics").headers["content-type"].startswith("text/calendar")
|
||||
assert "Pessoa: Ana" in mine
|
||||
assert "X-WR-CALNAME:Contínuos CRR — Ana" in mine
|
||||
|
||||
assert client.get("/calendar/nao-existe.ics").status_code == 404
|
||||
|
||||
|
||||
def test_admins_share_one_feed_with_the_whole_rota(client, db):
|
||||
from app.config import settings
|
||||
|
||||
night, sat, _, ana, bruno, admin = seed(db)
|
||||
_shift(db, night, date.today(), ana)
|
||||
_shift(db, sat, date.today(), bruno)
|
||||
sign_in(client)
|
||||
|
||||
hub = client.get("/admin/schedules").text
|
||||
assert "/calendar/" in hub
|
||||
token = settings.calendar_token
|
||||
assert token, "the team token is created on first use"
|
||||
|
||||
response = client.get(f"/calendar/{token}.ics")
|
||||
feed = unfold(response.text)
|
||||
assert response.status_code == 200
|
||||
assert "Pessoa: Ana" in feed
|
||||
assert "Pessoa: Bruno" in feed
|
||||
assert "X-WR-CALNAME:Contínuos CRR" in feed
|
||||
assert settings.calendar_token == token # stable between visits
|
||||
|
||||
|
||||
def test_inactive_people_lose_their_feed(client, db):
|
||||
_, _, _, ana, _, _ = seed(db)
|
||||
ana.calendar_token = "ana-token"
|
||||
ana.is_active = False
|
||||
db.commit()
|
||||
|
||||
assert client.get("/calendar/ana-token.ics").status_code == 404
|
||||
|
||||
|
||||
def test_the_calendar_page_holds_the_links_and_the_rota_only_links_to_it(client, db):
|
||||
night, sat, _, ana, bruno, admin = seed(db)
|
||||
sign_in(client)
|
||||
|
||||
rota = client.get("/").text
|
||||
assert 'href="/calendar">Calendário</a>' in rota
|
||||
assert "O meu calendário" not in rota # the links live on their own page
|
||||
|
||||
page = client.get("/calendar").text
|
||||
assert "Os meus turnos" in page
|
||||
assert "Rota completa" in page # admins also get the team feed
|
||||
assert "/calendar/" in page
|
||||
assert 'data-copy="http://testserver/calendar/' in page
|
||||
assert ">Copiar</button>" in page
|
||||
assert "webcal://testserver/calendar/" in page
|
||||
|
||||
# members only get their own feed
|
||||
client.post("/logout", data={"csrf_token": CSRF_RE.search(client.get("/").text).group(1)})
|
||||
ana.username = "ana"
|
||||
ana.password_hash = hash_password("ana-password")
|
||||
db.commit()
|
||||
token = CSRF_RE.search(client.get("/login").text).group(1)
|
||||
client.post("/login", data={"username": "ana", "password": "ana-password", "csrf_token": token})
|
||||
member_page = client.get("/calendar").text
|
||||
assert "Os meus turnos" in member_page
|
||||
assert "Rota completa" not in member_page
|
||||
|
||||
|
||||
def test_the_calendar_page_needs_a_signed_in_person(client, db):
|
||||
seed(db)
|
||||
assert client.get("/calendar", follow_redirects=False).status_code == 401
|
||||
+22
-12
@@ -24,7 +24,7 @@ def test_migrations_produce_the_model_schema(tmp_path, monkeypatch):
|
||||
|
||||
with engine.connect() as connection:
|
||||
head = connection.execute(text("select version_num from alembic_version")).scalar_one()
|
||||
assert head == "258f14bb3fcc"
|
||||
assert head == "77b487b8a09b"
|
||||
|
||||
|
||||
def test_seeded_schedule_names_are_renamed_without_touching_custom_names(tmp_path, monkeypatch):
|
||||
@@ -55,15 +55,21 @@ def test_seeded_schedule_names_are_renamed_without_touching_custom_names(tmp_pat
|
||||
|
||||
with engine.connect() as connection:
|
||||
rows = dict(connection.execute(text("select slug, name from schedules")).all())
|
||||
labels = dict(connection.execute(text("select slug, label_time from schedules")).all())
|
||||
hours = dict(
|
||||
connection.execute(
|
||||
text("select slug, start_time || '-' || end_time from schedules")
|
||||
).all()
|
||||
)
|
||||
assert rows == {
|
||||
"night": "Noite",
|
||||
"saturday-lunch": "Almoço - Sábados",
|
||||
"sunday-lunch": "Sundays off", # renamed by an administrator, left alone
|
||||
}
|
||||
assert labels["night"] == "20:30–00:00"
|
||||
assert labels["saturday-lunch"] == "13:00–16:00"
|
||||
assert labels["sunday-lunch"] == "13:00–16:00"
|
||||
assert hours == {
|
||||
"night": "20:30-00:00",
|
||||
"saturday-lunch": "13:00-16:00",
|
||||
"sunday-lunch": "13:00-16:00",
|
||||
}
|
||||
|
||||
|
||||
def test_a_new_column_gets_a_revision(monkeypatch, tmp_path):
|
||||
@@ -100,17 +106,21 @@ def test_seeded_service_windows_replace_the_plain_labels(tmp_path, monkeypatch):
|
||||
connection.execute(text(
|
||||
"INSERT INTO schedules (slug, name, schedule_type, weekdays, label_time, "
|
||||
"requires_manager_approval, is_active, created_at) VALUES "
|
||||
"('night', 'Noite', 'fixed', '0,1,2,3,4,5,6', 'Noite', 1, 1, '2024-01-01 00:00:00'), "
|
||||
"('saturday-lunch', 'Almoço - Sábados', 'rotation', '5', 'Almoço', 1, 1, '2024-01-01 00:00:00'), "
|
||||
"('night', 'Noite', 'fixed', '0,1,2,3,4,5,6', '20:30–00:00', 1, 1, '2024-01-01 00:00:00'), "
|
||||
"('saturday-lunch', 'Almoço - Sábados', 'rotation', '5', '13:00–16:00', 1, 1, '2024-01-01 00:00:00'), "
|
||||
"('sunday-lunch', 'Almoço - Domingo', 'rotation', '6', 'Horário próprio', 1, 1, '2024-01-01 00:00:00')"
|
||||
))
|
||||
|
||||
command.upgrade(config, "head")
|
||||
|
||||
with engine.connect() as connection:
|
||||
labels = dict(connection.execute(text("select slug, label_time from schedules")).all())
|
||||
assert labels == {
|
||||
"night": "20:30–00:00",
|
||||
"saturday-lunch": "13:00–16:00",
|
||||
"sunday-lunch": "Horário próprio", # chosen by an administrator, left alone
|
||||
hours = dict(
|
||||
connection.execute(
|
||||
text("select slug, start_time || '-' || end_time from schedules")
|
||||
).all()
|
||||
)
|
||||
assert hours == {
|
||||
"night": "20:30-00:00",
|
||||
"saturday-lunch": "13:00-16:00",
|
||||
"sunday-lunch": "13:00-16:00",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, time, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -57,7 +57,8 @@ def add_schedule(db, **fields) -> Schedule:
|
||||
defaults = {
|
||||
"schedule_type": "rotation",
|
||||
"weekdays": "5",
|
||||
"label_time": "Lunch",
|
||||
"start_time": time(13, 0),
|
||||
"end_time": time(16, 0),
|
||||
"requires_manager_approval": True,
|
||||
"is_active": True,
|
||||
"rotation_anchor_date": date(2026, 1, 3),
|
||||
@@ -87,7 +88,7 @@ def login(client: TestClient, username: str, password: str) -> str:
|
||||
|
||||
|
||||
def seed_schedules(db):
|
||||
night = add_schedule(db, name="Noite", slug="night", schedule_type="fixed", weekdays="0,1,2,3,4,5,6", label_time="Noite")
|
||||
night = add_schedule(db, name="Noite", slug="night", schedule_type="fixed", weekdays="0,1,2,3,4,5,6")
|
||||
sat = add_schedule(db, name="Almoço - Sábados", slug="sat")
|
||||
sun = add_schedule(db, name="Almoço - Domingo", slug="sun", weekdays="6")
|
||||
ana = add_person(db, "Ana")
|
||||
|
||||
@@ -43,7 +43,6 @@ def seed_schedules(db: Session):
|
||||
slug="night",
|
||||
schedule_type="fixed",
|
||||
weekdays="0,1,2,3,4,5,6",
|
||||
label_time="Night",
|
||||
requires_manager_approval=True,
|
||||
is_active=True,
|
||||
)
|
||||
@@ -52,7 +51,6 @@ def seed_schedules(db: Session):
|
||||
slug="sat",
|
||||
schedule_type="rotation",
|
||||
weekdays="5",
|
||||
label_time="Lunch",
|
||||
requires_manager_approval=True,
|
||||
is_active=True,
|
||||
rotation_anchor_date=date(2026, 1, 3),
|
||||
@@ -62,7 +60,6 @@ def seed_schedules(db: Session):
|
||||
slug="sun",
|
||||
schedule_type="rotation",
|
||||
weekdays="6",
|
||||
label_time="Lunch",
|
||||
requires_manager_approval=True,
|
||||
is_active=True,
|
||||
rotation_anchor_date=date(2026, 1, 4),
|
||||
|
||||
@@ -202,7 +202,6 @@ def test_removing_a_person_clears_every_reference(client, db, admin):
|
||||
slug="saturday-lunch",
|
||||
schedule_type="rotation",
|
||||
weekdays="5",
|
||||
label_time="Almoço",
|
||||
requires_manager_approval=True,
|
||||
is_active=True,
|
||||
rotation_anchor_date=date(2026, 1, 3),
|
||||
@@ -268,7 +267,6 @@ def test_removing_a_person_keeps_other_peoples_requests_intact(client, db, admin
|
||||
slug="saturday-lunch",
|
||||
schedule_type="rotation",
|
||||
weekdays="5",
|
||||
label_time="Almoço",
|
||||
requires_manager_approval=True,
|
||||
is_active=True,
|
||||
rotation_anchor_date=date(2026, 1, 3),
|
||||
|
||||
Reference in New Issue
Block a user