feat: change schedule view
This commit is contained in:
@@ -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.
|
||||
- `saturday-lunch`: rotating, Saturdays 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.
|
||||
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -21,7 +21,19 @@
|
||||
{% if card.kind == 'rotation' %}
|
||||
<p class="muted">Próximas datas</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 %}
|
||||
</section>
|
||||
{% else %}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
<span class="brand-name">{{ settings.app_name }}</span>
|
||||
</a>
|
||||
<nav class="nav" aria-label="Navegação principal">
|
||||
<a href="{{ app_path('/schedules') }}">Escalas</a>
|
||||
{% if current_login %}
|
||||
<a href="{{ app_path('/swaps') }}">Trocas</a>
|
||||
{% if current_login.is_admin %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -368,28 +368,6 @@ def logout(request: Request, csrf_token: str = Form(...)):
|
||||
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)
|
||||
def dashboard(
|
||||
request: Request,
|
||||
|
||||
@@ -87,6 +87,7 @@ Every assignment on a lunch schedule page has a **Regenerate** button. An admini
|
||||
## 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 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.
|
||||
|
||||
|
||||
@@ -97,14 +97,12 @@ def seed_schedules(db):
|
||||
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)
|
||||
|
||||
response = client.get("/schedules", follow_redirects=False)
|
||||
response = client.get("/admin/schedules", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Almoço - Sábados" in response.text
|
||||
assert "Almoço - Domingo" in response.text
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_public_schedules_show_details_and_rotation_order(client, db):
|
||||
night, sat, _ = seed_schedules(db)
|
||||
def test_the_admin_hub_shows_the_rotation_order_and_the_night_pattern(client, 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
|
||||
detail = client.get(f"/schedules/{sat.id}").text
|
||||
hub = client.get("/admin/schedules").text
|
||||
|
||||
assert "Noite" in hub
|
||||
assert "Almoço - Sábados" in hub
|
||||
assert "Ana" in detail
|
||||
assert "Bruno" in detail
|
||||
assert "Todos os dias" in client.get(f"/schedules/{night.id}").text
|
||||
assert "Ana" in hub
|
||||
assert "Bruno" in hub
|
||||
assert "Todos os dias" in hub
|
||||
|
||||
|
||||
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 "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):
|
||||
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
|
||||
assert "0,1,2,3,4,5,6" 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)
|
||||
|
||||
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)
|
||||
make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
|
||||
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):
|
||||
_, 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()
|
||||
while first_saturday.weekday() != 5:
|
||||
@@ -196,40 +214,32 @@ def test_rotations_show_upcoming_dates_instead_of_weekdays(client, db):
|
||||
assert expected in page
|
||||
|
||||
|
||||
def test_public_schedules_omit_inactive_schedules(client, db):
|
||||
add_schedule(db, name="Retired Shift", slug="retired", is_active=False)
|
||||
def test_the_admin_hub_shows_a_placeholder_when_no_schedules_exist(client, db):
|
||||
make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
|
||||
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):
|
||||
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):
|
||||
def test_signed_out_visitors_are_not_offered_the_admin_pages(client, 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):
|
||||
seed_schedules(db)
|
||||
|
||||
assert 'href="/schedules"' in client.get("/login").text
|
||||
|
||||
|
||||
def test_signed_in_member_can_also_read_public_schedules(client, db):
|
||||
def test_a_member_cannot_read_the_schedule_hub(client, db):
|
||||
seed_schedules(db)
|
||||
make_member(db, "Member", "member", password="member-password")
|
||||
db.commit()
|
||||
|
||||
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):
|
||||
@@ -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 f'<strong>{weekday_name(today.weekday(), short=True)}</strong>' in page
|
||||
assert "Sem atribuição" in page
|
||||
assert "Escalas" in page
|
||||
|
||||
|
||||
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):
|
||||
"""Behind a proxy serving /crr/, every link, form and asset keeps the prefix."""
|
||||
_, sat, _ = seed_schedules(db)
|
||||
make_member(db, "Admin", "admin", password="admin-password", is_admin=True)
|
||||
db.commit()
|
||||
monkey = pytest.MonkeyPatch()
|
||||
monkey.setattr("app.config.settings.root_path", "/crr")
|
||||
proxied = TestClient(app, root_path="/crr")
|
||||
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:
|
||||
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
|
||||
# every hand written link goes through app_path
|
||||
assert 'href="/crr/"' in page
|
||||
assert 'href="/crr/schedules"' in page
|
||||
assert f'href="/crr/schedules/{sat.id}"' in page
|
||||
assert 'href="/crr/login"' in page
|
||||
assert "http://testserver/crr/schedules/1" not in page
|
||||
assert f'href="/crr/admin/schedules/{sat.id}"' in page
|
||||
assert 'href="/crr/admin/users"' in page
|
||||
assert "http://testserver/crr/admin/schedules/1" not in page
|
||||
|
||||
|
||||
def test_redirects_keep_the_prefix_behind_a_sub_path_deployment(client, db):
|
||||
|
||||
+1
-1
@@ -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
|
||||
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="/admin/schedules">Gerir escalas</a>' in nav
|
||||
assert 'href="/"' in nav # the brand stays the home link for signed-in members
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user