feat: change schedule view

This commit is contained in:
2026-10-06 21:20:59 +01:00
parent f4dade9eec
commit 1b801b2468
10 changed files with 93 additions and 148 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ Maintain a deliberately small, self-hosted rota application for a non-profit bar
- `night`: fixed/manual, every day of the month. - `night`: fixed/manual, every day of the month.
- `saturday-lunch`: rotating, Saturdays only. - `saturday-lunch`: rotating, Saturdays only.
- `sunday-lunch`: rotating, Sundays only. - `sunday-lunch`: rotating, Sundays only.
3. Saturday and Sunday rotations are independent, each with its own configuration page (`/admin/schedules/<id>`); the night schedule is fixed and repeats every month with a single configuration. The `/admin/schedules` and `/schedules` pages are hubs that link to one page per schedule. 3. Saturday and Sunday rotations are independent, each with its own configuration page (`/admin/schedules/<id>`); the night schedule is fixed and repeats every month with a single configuration. `/admin/schedules` is the hub: it shows every schedule (kind, hours, next dates, rotation order or night pattern) and links to its configuration page. There is no public schedule page: `/`, the rota, is the only page open to signed-out visitors.
4. Never silently rewrite an existing assignment when rotation membership/order changes. 4. Never silently rewrite an existing assignment when rotation membership/order changes.
5. A swap/change request only applies if the requester still owns the assignment when it is accepted/approved. 5. A swap/change request only applies if the requester still owns the assignment when it is accepted/approved.
6. `email` and `phone` are optional. Signing in belongs to a `login_accounts` row (username, password, admin flag) that several people can share; a person with no login account is rota-only. A username is unique across accounts, not across people. The session stores the login account, and everything a member sees and controls is scoped to the people on it. 6. `email` and `phone` are optional. Signing in belongs to a `login_accounts` row (username, password, admin flag) that several people can share; a person with no login account is rota-only. A username is unique across accounts, not across people. The session stores the login account, and everything a member sees and controls is scoped to the people on it.
+2 -2
View File
@@ -31,7 +31,7 @@ Open `http://localhost:8000`. With the example configuration, the administrator
To fill your own data, skip `seed-demo`, sign in as administrator, and: To fill your own data, skip `seed-demo`, sign in as administrator, and:
1. Open **People** (`/admin/users`) and add members. Contact details are optional; credentials are optional as a pair. 1. Open **People** (`/admin/users`) and add members. Contact details are optional; credentials are optional as a pair.
2. Open **Schedules** (`/admin/schedules`) and configure each schedule: Night's repeating day-of-month pattern, Saturday's rotation, and Sunday's independent rotation. 2. Open **Gerir escalas** (`/admin/schedules`) to see every schedule, then configure each one: Night's repeating day-of-month pattern, Saturday's rotation, and Sunday's independent rotation.
3. View the rota (`/`) and use each schedule's configuration page to edit assignments. 3. View the rota (`/`) and use each schedule's configuration page to edit assignments.
4. Sign in as a member to request a change, as another member to accept it, then as admin to approve it. 4. Sign in as a member to request a change, as another member to accept it, then as admin to approve it.
@@ -39,7 +39,7 @@ On either lunch schedule page, **Apply rotation** updates the displayed month's
Rotation uses the previous assigned person in that same schedule as its reference, then selects the next active person in the order, wrapping to the start. This continues across months and respects manual assignments and completed swaps as references. For example, with Alice → Bruno → Carla, a previous shift assigned to Bruno means the next automatic shift goes to Carla. If no usable previous assignee exists, the schedule's anchor date supplies the starting position. Rotation uses the previous assigned person in that same schedule as its reference, then selects the next active person in the order, wrapping to the start. This continues across months and respects manual assignments and completed swaps as references. For example, with Alice → Bruno → Carla, a previous shift assigned to Bruno means the next automatic shift goes to Carla. If no usable previous assignee exists, the schedule's anchor date supplies the starting position.
Demo seeding creates **alice, bruno, carla, diogo**, each with password **demo-password** for newly created accounts, both lunch rotations, and all 31 days of the repeating Night pattern. It generates this month and next so there are future shifts available for testing changes. Re-running it preserves existing accounts, configured schedules, and assignments, including intentionally unassigned shifts. If you already opened a month before seeding, its existing rows stay as they were; use the next month or edit them in the admin UI. Demo seeding creates the logins **alice** and **bruno**, plus a shared **sabado** login that both **carla** and **diogo** sign in through, all with password **demo-password** for newly created accounts, together with both lunch rotations and all 31 days of the repeating Night pattern. It generates this month and next so there are future shifts available for testing changes. Re-running it preserves existing accounts, configured schedules, and assignments, including intentionally unassigned shifts. If you already opened a month before seeding, its existing rows stay as they were; use the next month or edit them in the admin UI.
**Important:** the initial admin is only created when the database has no users. Changing `INITIAL_ADMIN_PASSWORD` later does not reset that account; reset it from the People screen. **Important:** the initial admin is only created when the database has no users. Changing `INITIAL_ADMIN_PASSWORD` later does not reset that account; reset it from the People screen.
+13 -1
View File
@@ -21,7 +21,19 @@
{% if card.kind == 'rotation' %} {% if card.kind == 'rotation' %}
<p class="muted">Próximas datas</p> <p class="muted">Próximas datas</p>
<p class="date-list">{{ card.dates | join(' · ') }}</p> <p class="date-list">{{ card.dates | join(' · ') }}</p>
<p class="muted">Rotação: {{ card.rotation_names | join(', ') if card.rotation_names else 'sem membros ainda' }}</p> <h3>Ordem da rotação</h3>
<ol class="rotation-list">
{% for name in card.rotation_names %}
<li><span>{{ name }}</span></li>
{% else %}<li class="muted">Ainda não há membros.</li>{% endfor %}
</ol>
{% else %}
<h3>Padrão</h3>
<ul class="link-list">
{% for day, person_name in card.pattern %}
<li><span>{{ day }}</span> <strong>{{ person_name }}</strong></li>
{% else %}<li class="muted">Ainda não há ninguém definido.</li>{% endfor %}
</ul>
{% endif %} {% endif %}
</section> </section>
{% else %} {% else %}
-1
View File
@@ -16,7 +16,6 @@
<span class="brand-name">{{ settings.app_name }}</span> <span class="brand-name">{{ settings.app_name }}</span>
</a> </a>
<nav class="nav" aria-label="Navegação principal"> <nav class="nav" aria-label="Navegação principal">
<a href="{{ app_path('/schedules') }}">Escalas</a>
{% if current_login %} {% if current_login %}
<a href="{{ app_path('/swaps') }}">Trocas</a> <a href="{{ app_path('/swaps') }}">Trocas</a>
{% if current_login.is_admin %} {% if current_login.is_admin %}
-36
View File
@@ -1,36 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ card.schedule.name }} · {{ settings.app_name }}{% endblock %}
{% block content %}
{% set schedule = card.schedule %}
<div class="page-head">
<div>
<p class="eyebrow"><a href="{{ app_path('/schedules') }}">Escalas</a></p>
<h1>{{ schedule.name }}</h1>
<p class="muted">{{ card.kind_label }} · {{ schedule|horario }}</p>
</div>
</div>
<div class="stack gap-lg">
<section class="panel">
{% if card.kind == 'rotation' %}
<p class="muted">Próximas datas</p>
<p class="date-list">{{ card.dates | join(' · ') }}</p>
<h3>Ordem da rotação</h3>
<ol class="rotation-list">
{% for name in card.rotation_names %}
<li><span>{{ name }}</span></li>
{% else %}<li class="muted">Ainda não há membros.</li>{% endfor %}
</ol>
{% else %}
<h3>Padrão</h3>
<ul class="link-list">
{% for day, person_name in card.pattern %}
<li><span>{{ day }}</span> <strong>{{ person_name }}</strong></li>
{% else %}<li class="muted">Ainda não há ninguém definido.</li>{% endfor %}
</ul>
{% endif %}
</section>
</div>
{% if not current_login %}
<div class="top-space"><a class="button primary" href="{{ app_path('/login') }}">Entrar</a></div>
{% endif %}
{% endblock %}
-28
View File
@@ -1,28 +0,0 @@
{% extends "base.html" %}
{% block title %}Escalas · {{ settings.app_name }}{% endblock %}
{% block content %}
<div class="page-head"><div><h1>Escalas</h1></div></div>
<div class="stack gap-lg">
{% for card in cards %}
<section class="panel">
<div class="row between wrap gap">
<div>
<h2>{{ card.schedule.name }}</h2>
<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>
{% if card.kind == 'rotation' %}
<p class="muted">Próximas datas</p>
<p class="date-list">{{ card.dates | join(' · ') }}</p>
<p class="muted">{{ card.rotation_names | length }} pessoa(s) na rotação.</p>
{% endif %}
</section>
{% else %}
<div class="empty">Ainda não há escalas publicadas.</div>
{% endfor %}
</div>
{% if not current_login %}
<div class="top-space"><a class="button primary" href="{{ app_path('/login') }}">Entrar</a></div>
{% endif %}
{% endblock %}
-22
View File
@@ -368,28 +368,6 @@ def logout(request: Request, csrf_token: str = Form(...)):
return _redirect("/login") return _redirect("/login")
@router.get("/schedules", response_class=HTMLResponse)
def public_schedules(request: Request, db: Session = Depends(get_db)):
cards = _schedule_groups(_active_schedules(db), db)
return request.app.state.templates.TemplateResponse(
request=request,
name="schedules.html",
context=_context(request, db, cards=cards),
)
@router.get("/schedules/{schedule_id}", response_class=HTMLResponse)
def public_schedule(schedule_id: int, request: Request, db: Session = Depends(get_db)):
schedule = _get_schedule(db, schedule_id)
if not schedule.is_active:
raise HTTPException(status_code=404, detail="Escala não encontrada")
return request.app.state.templates.TemplateResponse(
request=request,
name="schedule.html",
context=_context(request, db, card=_schedule_card(schedule, db), cards=_schedule_groups(_active_schedules(db), db)),
)
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
def dashboard( def dashboard(
request: Request, request: Request,
+1
View File
@@ -87,6 +87,7 @@ Every assignment on a lunch schedule page has a **Regenerate** button. An admini
## Access ## 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.
- Change requests, `/swaps`, and every `/admin` page still require a signed-in, active account. - Change requests, `/swaps`, and every `/admin` page still require a signed-in, active account.
- Internal links are built with `settings.url()` (exposed to templates as `app_path`) and redirects go through the same helper, so `ROOT_PATH` keeps every link working under a proxy sub-path. - Internal links are built with `settings.url()` (exposed to templates as `app_path`) and redirects go through the same helper, so `ROOT_PATH` keeps every link working under a proxy sub-path.
+75 -56
View File
@@ -97,14 +97,12 @@ def seed_schedules(db):
return night, sat, sun return night, sat, sun
def test_public_schedules_page_is_open_to_anonymous_visitors(client, db): def test_the_schedule_hub_needs_an_administrator(client, db):
seed_schedules(db) seed_schedules(db)
response = client.get("/schedules", follow_redirects=False) response = client.get("/admin/schedules", follow_redirects=False)
assert response.status_code == 200 assert response.status_code == 401
assert "Almoço - Sábados" in response.text
assert "Almoço - Domingo" in response.text
def test_rota_is_the_landing_page_for_signed_out_visitors(client, db): def test_rota_is_the_landing_page_for_signed_out_visitors(client, db):
@@ -135,23 +133,28 @@ def test_signed_in_rota_shows_change_actions_for_own_shifts(client, db):
assert f"{sat.id}" in page assert f"{sat.id}" in page
def test_public_schedules_show_details_and_rotation_order(client, db): def test_the_admin_hub_shows_the_rotation_order_and_the_night_pattern(client, db):
night, sat, _ = seed_schedules(db) seed_schedules(db)
make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
db.commit()
login(client, "admin", "admin-password")
hub = client.get("/schedules").text hub = client.get("/admin/schedules").text
detail = client.get(f"/schedules/{sat.id}").text
assert "Noite" in hub assert "Noite" in hub
assert "Almoço - Sábados" in hub assert "Almoço - Sábados" in hub
assert "Ana" in detail assert "Ana" in hub
assert "Bruno" in detail assert "Bruno" in hub
assert "Todos os dias" in client.get(f"/schedules/{night.id}").text assert "Todos os dias" in hub
def test_fixed_schedule_is_described_as_repeating_every_month(client, db): def test_fixed_schedule_is_described_as_repeating_every_month(client, db):
night, _, _ = seed_schedules(db) seed_schedules(db)
make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
db.commit()
login(client, "admin", "admin-password")
page = client.get(f"/schedules/{night.id}").text page = client.get("/admin/schedules").text
assert "repete todos os meses" in page assert "repete todos os meses" in page
assert "Todos os dias" in page assert "Todos os dias" in page
@@ -159,35 +162,50 @@ def test_fixed_schedule_is_described_as_repeating_every_month(client, db):
def test_raw_weekday_numbers_are_never_shown(client, db): def test_raw_weekday_numbers_are_never_shown(client, db):
night, sat, _ = seed_schedules(db) night, sat, _ = seed_schedules(db)
make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
db.commit()
login(client, "admin", "admin-password")
for path in ("/schedules", f"/schedules/{night.id}", f"/schedules/{sat.id}"): for path in ("/admin/schedules", f"/admin/schedules/{night.id}", f"/admin/schedules/{sat.id}"):
page = client.get(path).text page = client.get(path).text
assert "0,1,2,3,4,5,6" not in page assert "0,1,2,3,4,5,6" not in page
assert "weekdays" not in page assert "weekdays" not in page
def test_each_schedule_has_its_own_page(client, db): def test_each_schedule_has_its_own_configuration_page(client, db):
night, sat, sun = seed_schedules(db) night, sat, sun = seed_schedules(db)
make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
hub = client.get("/schedules").text
for schedule in (night, sat, sun):
assert f'href="/schedules/{schedule.id}"' in hub
assert "Rotação · Sábado" in client.get(f"/schedules/{sat.id}").text
assert "Rotação · Domingo" in client.get(f"/schedules/{sun.id}").text
def test_inactive_schedule_page_is_not_public(client, db):
retired = add_schedule(db, name="Retired Shift", slug="retired", is_active=False)
db.commit() db.commit()
login(client, "admin", "admin-password")
assert client.get(f"/schedules/{retired.id}", follow_redirects=False).status_code == 404 hub = client.get("/admin/schedules").text
for schedule in (night, sat, sun):
assert f'href="/admin/schedules/{schedule.id}"' in hub
assert "Rotação · Sábado" in hub
assert "Rotação · Domingo" in hub
def test_an_inactive_schedule_is_listed_as_inactive(client, db):
add_schedule(db, name="Retired Shift", slug="retired", is_active=False)
db.commit()
make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
db.commit()
login(client, "admin", "admin-password")
hub = client.get("/admin/schedules").text
assert "Retired Shift" in hub
assert "inativa" in hub
def test_rotations_show_upcoming_dates_instead_of_weekdays(client, db): def test_rotations_show_upcoming_dates_instead_of_weekdays(client, db):
_, sat, _ = seed_schedules(db) seed_schedules(db)
make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
db.commit()
login(client, "admin", "admin-password")
page = client.get(f"/schedules/{sat.id}").text page = client.get("/admin/schedules").text
first_saturday = date.today() first_saturday = date.today()
while first_saturday.weekday() != 5: while first_saturday.weekday() != 5:
@@ -196,40 +214,32 @@ def test_rotations_show_upcoming_dates_instead_of_weekdays(client, db):
assert expected in page assert expected in page
def test_public_schedules_omit_inactive_schedules(client, db): def test_the_admin_hub_shows_a_placeholder_when_no_schedules_exist(client, db):
add_schedule(db, name="Retired Shift", slug="retired", is_active=False) make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
db.commit() db.commit()
login(client, "admin", "admin-password")
page = client.get("/schedules").text page = client.get("/admin/schedules").text
assert "Retired Shift" not in page assert "Ainda não há escalas." in page
def test_public_schedules_show_placeholder_when_none_exist(client, db): def test_signed_out_visitors_are_not_offered_the_admin_pages(client, db):
page = client.get("/schedules").text
assert "Ainda não há escalas publicadas." in page
def test_public_schedules_offer_sign_in_to_anonymous_visitors(client, db):
seed_schedules(db) seed_schedules(db)
assert "Entrar" in client.get("/schedules").text page = client.get("/login").text
assert 'href="/schedules"' not in page
assert 'href="/admin/schedules"' not in page
def test_public_schedules_are_linked_for_anonymous_visitors(client, db): def test_a_member_cannot_read_the_schedule_hub(client, db):
seed_schedules(db)
assert 'href="/schedules"' in client.get("/login").text
def test_signed_in_member_can_also_read_public_schedules(client, db):
seed_schedules(db) seed_schedules(db)
make_member(db, "Member", "member", password="member-password") make_member(db, "Member", "member", password="member-password")
db.commit() db.commit()
assert "Entrar" not in login(client, "member", "member-password") assert "Entrar" not in login(client, "member", "member-password")
assert "Almoço - Sábados" in client.get("/schedules").text assert client.get("/admin/schedules", follow_redirects=False).status_code == 403
def test_rota_is_read_only_for_signed_out_visitors(client, db): def test_rota_is_read_only_for_signed_out_visitors(client, db):
@@ -253,7 +263,6 @@ def test_pages_are_served_in_european_portuguese(client, db):
assert day_short(today) not in page # the rota shows the weekday, not the full date assert day_short(today) not in page # the rota shows the weekday, not the full date
assert f'<strong>{weekday_name(today.weekday(), short=True)}</strong>' in page assert f'<strong>{weekday_name(today.weekday(), short=True)}</strong>' in page
assert "Sem atribuição" in page assert "Sem atribuição" in page
assert "Escalas" in page
def test_admin_schedules_page_separates_the_three_schedule_types(client, db): def test_admin_schedules_page_separates_the_three_schedule_types(client, db):
@@ -313,10 +322,21 @@ def test_adding_a_rotation_member_returns_to_the_rotation_order_anchor(client, d
def test_pages_and_static_files_survive_a_sub_path_deployment(client, db): def test_pages_and_static_files_survive_a_sub_path_deployment(client, db):
"""Behind a proxy serving /crr/, every link, form and asset keeps the prefix.""" """Behind a proxy serving /crr/, every link, form and asset keeps the prefix."""
_, sat, _ = seed_schedules(db) _, sat, _ = seed_schedules(db)
make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
db.commit()
monkey = pytest.MonkeyPatch() monkey = pytest.MonkeyPatch()
monkey.setattr("app.config.settings.root_path", "/crr") monkey.setattr("app.config.settings.root_path", "/crr")
proxied = TestClient(app, root_path="/crr")
try: try:
page = TestClient(app, root_path="/crr").get("/schedules").text proxied.post(
"/login",
data={
"username": "admin",
"password": "admin-password",
"csrf_token": CSRF_RE.search(proxied.get("/login").text).group(1),
},
)
page = proxied.get("/admin/schedules").text
finally: finally:
monkey.undo() monkey.undo()
@@ -325,10 +345,9 @@ def test_pages_and_static_files_survive_a_sub_path_deployment(client, db):
assert 'src="http://testserver/crr/static/logo-crr.png"' in page assert 'src="http://testserver/crr/static/logo-crr.png"' in page
# every hand written link goes through app_path # every hand written link goes through app_path
assert 'href="/crr/"' in page assert 'href="/crr/"' in page
assert 'href="/crr/schedules"' in page assert f'href="/crr/admin/schedules/{sat.id}"' in page
assert f'href="/crr/schedules/{sat.id}"' in page assert 'href="/crr/admin/users"' in page
assert 'href="/crr/login"' in page assert "http://testserver/crr/admin/schedules/1" not in page
assert "http://testserver/crr/schedules/1" not in page
def test_redirects_keep_the_prefix_behind_a_sub_path_deployment(client, db): def test_redirects_keep_the_prefix_behind_a_sub_path_deployment(client, db):
+1 -1
View File
@@ -204,8 +204,8 @@ def test_signed_in_home_is_the_rota_and_nav_has_no_rota_link(client, db, admin):
nav = client.get("/admin/users").text nav = client.get("/admin/users").text
assert '<a href="/">Rota</a>' not in nav assert '<a href="/">Rota</a>' not in nav
assert '<a href="/schedules">Escalas</a>' in nav
assert '<a href="/swaps">Trocas</a>' in nav assert '<a href="/swaps">Trocas</a>' in nav
assert '<a href="/admin/schedules">Gerir escalas</a>' in nav
assert 'href="/"' in nav # the brand stays the home link for signed-in members assert 'href="/"' in nav # the brand stays the home link for signed-in members