feat: new buttons to assign to schedule
This commit is contained in:
@@ -35,7 +35,9 @@ To fill your own data, skip `seed-demo`, sign in as administrator, and:
|
||||
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.
|
||||
|
||||
On either lunch schedule page, **Apply rotation** updates the displayed month's automatic shifts from the current rotation order, keeping manual assignments and completed swaps. **Use rotation** on a manual row removes that override and auto-assigns the date again. If the rotation has no active members, the shift becomes unassigned. Ownership changes cancel any open/pending change request for that shift.
|
||||
On either lunch schedule page, **Apply rotation** updates the displayed month's automatic shifts from the current rotation order, keeping manual assignments and completed swaps. Every row has **Regenerate** to explicitly auto-assign that date again, including manual assignments and completed swaps. **Generate after date** creates missing shifts and updates automatic assignments strictly after the chosen date through an inclusive end date, across as many months as you select; it preserves manual assignments and completed swaps. If the rotation has no active members, automatic shifts become unassigned. Ownership changes cancel any open/pending change request for that shift.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+26
-4
@@ -49,21 +49,41 @@ def _rotation_occurrence_index(schedule: Schedule, target: date) -> int:
|
||||
|
||||
|
||||
def rotation_user_for_date(db: Session, schedule: Schedule, target: date) -> User | None:
|
||||
"""Continue after the last actual assignee in this schedule, with anchor fallback."""
|
||||
if schedule.schedule_type != "rotation" or target.weekday() not in schedule.weekday_set:
|
||||
return None
|
||||
|
||||
members = db.scalars(
|
||||
select(RotationMember)
|
||||
.join(User, RotationMember.user_id == User.id)
|
||||
.where(RotationMember.schedule_id == schedule.id, User.is_active.is_(True))
|
||||
.where(RotationMember.schedule_id == schedule.id)
|
||||
.order_by(RotationMember.position)
|
||||
.options(selectinload(RotationMember.user))
|
||||
).all()
|
||||
if not members:
|
||||
active_members = [member for member in members if member.user.is_active]
|
||||
if not active_members:
|
||||
return None
|
||||
|
||||
idx = _rotation_occurrence_index(schedule, target) % len(members)
|
||||
return members[idx].user
|
||||
previous_user_id = db.scalar(
|
||||
select(Assignment.user_id)
|
||||
.where(
|
||||
Assignment.schedule_id == schedule.id,
|
||||
Assignment.date < target,
|
||||
Assignment.user_id.is_not(None),
|
||||
)
|
||||
.order_by(Assignment.date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
for idx, member in enumerate(members):
|
||||
if member.user_id == previous_user_id:
|
||||
for offset in range(1, len(members) + 1):
|
||||
next_member = members[(idx + offset) % len(members)]
|
||||
if next_member.user.is_active:
|
||||
return next_member.user
|
||||
|
||||
# No usable reference yet (or the last assignee left the rotation).
|
||||
idx = _rotation_occurrence_index(schedule, target) % len(active_members)
|
||||
return active_members[idx].user
|
||||
|
||||
|
||||
def pattern_user_for_date(db: Session, schedule: Schedule, target: date) -> User | None:
|
||||
@@ -120,6 +140,8 @@ def ensure_month_assignments(db: Session, year: int, month: int) -> list[Assignm
|
||||
source="generated",
|
||||
)
|
||||
)
|
||||
# SessionLocal disables autoflush: the next date must see this assignee.
|
||||
db.flush()
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
|
||||
@@ -29,23 +29,28 @@
|
||||
<input type="hidden" name="year" value="{{ year }}"><input type="hidden" name="month" value="{{ month }}">
|
||||
<button class="secondary" type="submit">Apply rotation to {{ month_name }}</button>
|
||||
</form>
|
||||
<p class="muted">Apply the current order to automatic shifts in this month. Manual assignments and completed swaps are kept. Use rotation removes a manual override for one date. An empty rotation leaves automatic shifts unassigned.</p>
|
||||
<p class="muted">Each automatic shift goes to the next active person after the previous shift's assignee in this schedule, wrapping around the rotation order. Apply rotation keeps manual assignments and completed swaps. Regenerate replaces one date's assignment, including a manual assignment or swap. An empty rotation leaves automatic shifts unassigned.</p>
|
||||
<form method="post" action="/admin/schedules/{{ schedule.id }}/generate-after" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>After date<input type="date" name="after_date" value="{{ generate_after.isoformat() }}" min="1999-12-31" max="2100-12-30" required></label>
|
||||
<label>Through date<input type="date" name="through_date" value="{{ generate_through.isoformat() }}" min="2000-01-01" max="2100-12-31" required></label>
|
||||
<button class="secondary" type="submit">Generate after date</button>
|
||||
</form>
|
||||
<p class="muted">Create missing shifts and update automatic assignments after the reference date, through the end date (inclusive). Manual assignments and completed swaps are kept and used as references.</p>
|
||||
<form method="post" action="/admin/schedules/{{ schedule.id }}/assignments">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="year" value="{{ year }}"><input type="hidden" name="month" value="{{ month }}">
|
||||
<div class="table-wrap"><table><thead><tr><th>Date</th><th>Person</th><th>Source</th><th>Action</th></tr></thead><tbody>
|
||||
{% for assignment in assignments %}
|
||||
<tr><td>{{ assignment.date.strftime('%a %d %b') }}</td><td><select name="assignment_{{ assignment.id }}"><option value="">Unassigned</option>{% for person in users %}<option value="{{ person.id }}" {% if assignment.user_id == person.id %}selected{% endif %}>{{ person.name }}</option>{% endfor %}</select></td><td><span class="badge">{{ assignment.source }}</span></td><td>{% if assignment.source == 'manual' %}<button class="secondary small" type="submit" form="use-rotation-{{ assignment.id }}" aria-label="Use rotation for {{ assignment.date.strftime('%a %d %b') }}">Use rotation</button>{% endif %}</td></tr>
|
||||
<tr><td>{{ assignment.date.strftime('%a %d %b') }}</td><td><select name="assignment_{{ assignment.id }}"><option value="">Unassigned</option>{% for person in users %}<option value="{{ person.id }}" {% if assignment.user_id == person.id %}selected{% endif %}>{{ person.name }}</option>{% endfor %}</select></td><td><span class="badge">{{ assignment.source }}</span></td><td><button class="secondary small" type="submit" form="regenerate-{{ assignment.id }}" aria-label="Regenerate assignment for {{ assignment.date.strftime('%a %d %b') }}">Regenerate</button></td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table></div>
|
||||
<div class="sticky-actions"><button class="primary" type="submit">Save {{ month_name }}</button></div>
|
||||
</form>
|
||||
{% for assignment in assignments %}
|
||||
{% if assignment.source == 'manual' %}
|
||||
<form id="use-rotation-{{ assignment.id }}" method="post" action="/admin/schedules/{{ schedule.id }}/assignments/{{ assignment.id }}/use-rotation">
|
||||
<form id="regenerate-{{ assignment.id }}" method="post" action="/admin/schedules/{{ schedule.id }}/assignments/{{ assignment.id }}/regenerate">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
<section class="panel">
|
||||
|
||||
+61
-20
@@ -21,7 +21,7 @@ from .models import (
|
||||
User,
|
||||
)
|
||||
from .notifications import send_email_notification, send_many
|
||||
from .scheduling import assignments_by_date, ensure_month_assignments, iter_month_dates, month_bounds, rotation_user_for_date
|
||||
from .scheduling import assignments_by_date, ensure_month_assignments, month_bounds, rotation_user_for_date
|
||||
from .security import hash_password, new_csrf_token, verify_password
|
||||
|
||||
router = APIRouter()
|
||||
@@ -740,6 +740,8 @@ def admin_schedule(
|
||||
month_name=calendar.month_name[month],
|
||||
prev=prev,
|
||||
nxt=nxt,
|
||||
generate_after=date(year, month, 1) - timedelta(days=1),
|
||||
generate_through=month_bounds(year, month)[1],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1017,7 +1019,8 @@ def _apply_rotation_to_rows(db: Session, schedule: Schedule, assignments: Sequen
|
||||
"""Explicit admin edit: retain assignment IDs and invalidate requests when ownership changes."""
|
||||
changed: list[tuple[Assignment, User | None, User | None]] = []
|
||||
touched = 0
|
||||
for assignment in assignments:
|
||||
db.flush()
|
||||
for assignment in sorted(assignments, key=lambda row: row.date):
|
||||
new_user = rotation_user_for_date(db, schedule, assignment.date)
|
||||
new_user_id = new_user.id if new_user else None
|
||||
if assignment.user_id == new_user_id and assignment.source == "generated":
|
||||
@@ -1035,12 +1038,39 @@ def _apply_rotation_to_rows(db: Session, schedule: Schedule, assignments: Sequen
|
||||
assignment.user_id = new_user_id
|
||||
assignment.user = new_user
|
||||
assignment.source = "generated"
|
||||
# Later dates continue from the person selected for this date.
|
||||
db.flush()
|
||||
touched += 1
|
||||
db.commit()
|
||||
_notify_assignment_changes(db, changed)
|
||||
return touched
|
||||
|
||||
|
||||
def _generate_rotation_range(db: Session, schedule: Schedule, first: date, last: date) -> tuple[int, int]:
|
||||
rows = {row.date: row for row in db.scalars(
|
||||
select(Assignment)
|
||||
.where(Assignment.schedule_id == schedule.id, Assignment.date >= first, Assignment.date <= last)
|
||||
.options(selectinload(Assignment.user), selectinload(Assignment.schedule))
|
||||
).all()}
|
||||
assignments = []
|
||||
created = 0
|
||||
day = first
|
||||
while day <= last:
|
||||
if day.weekday() not in schedule.weekday_set:
|
||||
day += timedelta(days=1)
|
||||
continue
|
||||
row = rows.get(day)
|
||||
if row is None:
|
||||
row = Assignment(schedule=schedule, date=day, source="generated")
|
||||
db.add(row)
|
||||
created += 1
|
||||
if row.source == "generated":
|
||||
assignments.append(row)
|
||||
day += timedelta(days=1)
|
||||
changed = _apply_rotation_to_rows(db, schedule, assignments)
|
||||
return created, changed
|
||||
|
||||
|
||||
@router.post("/admin/schedules/{schedule_id}/apply-rotation")
|
||||
def admin_apply_rotation(
|
||||
schedule_id: int,
|
||||
@@ -1056,27 +1086,38 @@ def admin_apply_rotation(
|
||||
if schedule.schedule_type != "rotation":
|
||||
raise HTTPException(status_code=400, detail="This schedule does not use a rotation")
|
||||
year, month = _parse_month(year, month)
|
||||
rows = {row.date: row for row in _month_assignments(db, year, month) if row.schedule_id == schedule.id}
|
||||
assignments = []
|
||||
created = 0
|
||||
for day in iter_month_dates(year, month):
|
||||
if day.weekday() not in schedule.weekday_set:
|
||||
continue
|
||||
row = rows.get(day)
|
||||
if row is None:
|
||||
row = Assignment(schedule=schedule, date=day, source="generated")
|
||||
db.add(row)
|
||||
created += 1
|
||||
if row.source == "generated":
|
||||
assignments.append(row)
|
||||
changed = _apply_rotation_to_rows(db, schedule, assignments)
|
||||
created, changed = _generate_rotation_range(db, schedule, *month_bounds(year, month))
|
||||
_flash(request, f"Applied rotation for {calendar.month_name[month]} {year}: "
|
||||
f"created {created} shift(s), updated {changed} assignment(s). Manual assignments and swaps were kept.")
|
||||
return _redirect(f"/admin/schedules/{schedule.id}?year={year}&month={month}")
|
||||
|
||||
|
||||
@router.post("/admin/schedules/{schedule_id}/generate-after")
|
||||
def admin_generate_after(
|
||||
schedule_id: int,
|
||||
request: Request,
|
||||
after_date: date = Form(...),
|
||||
through_date: date = Form(...),
|
||||
csrf_token: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_require_admin(request, db)
|
||||
_check_csrf(request, csrf_token)
|
||||
schedule = _get_schedule(db, schedule_id)
|
||||
if schedule.schedule_type != "rotation":
|
||||
raise HTTPException(status_code=400, detail="This schedule does not use a rotation")
|
||||
if after_date < date(1999, 12, 31) or through_date > date(2100, 12, 31) or through_date <= after_date:
|
||||
raise HTTPException(status_code=400, detail="Choose an end date after the reference date, within 2000–2100")
|
||||
first = after_date + timedelta(days=1)
|
||||
created, changed = _generate_rotation_range(db, schedule, first, through_date)
|
||||
_flash(request, f"Generated shifts after {after_date:%d %b %Y} through {through_date:%d %b %Y}: "
|
||||
f"created {created} shift(s), updated {changed} assignment(s). Manual assignments and swaps were kept.")
|
||||
return _redirect(f"/admin/schedules/{schedule.id}?year={first.year}&month={first.month}")
|
||||
|
||||
|
||||
@router.post("/admin/schedules/{schedule_id}/assignments/{assignment_id}/regenerate")
|
||||
@router.post("/admin/schedules/{schedule_id}/assignments/{assignment_id}/use-rotation")
|
||||
def admin_assignment_use_rotation(
|
||||
def admin_assignment_regenerate(
|
||||
schedule_id: int,
|
||||
assignment_id: int,
|
||||
request: Request,
|
||||
@@ -1091,10 +1132,10 @@ def admin_assignment_use_rotation(
|
||||
assignment = db.get(Assignment, assignment_id)
|
||||
if assignment is None or assignment.schedule_id != schedule.id:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
if assignment.source != "manual" or assignment.date.weekday() not in schedule.weekday_set:
|
||||
raise HTTPException(status_code=400, detail="Only manual rotation assignments can be reset")
|
||||
if assignment.date.weekday() not in schedule.weekday_set:
|
||||
raise HTTPException(status_code=400, detail="This date does not match the schedule")
|
||||
_apply_rotation_to_rows(db, schedule, [assignment])
|
||||
_flash(request, "Manual assignment removed; this shift now uses the rotation.")
|
||||
_flash(request, "Assignment regenerated using the next person after the previous shift's assignee.")
|
||||
return _redirect(f"/admin/schedules/{schedule.id}?year={assignment.date.year}&month={assignment.date.month}")
|
||||
|
||||
|
||||
|
||||
+9
-3
@@ -22,7 +22,7 @@ This distinction is intentional: editing a rotation must not retroactively chang
|
||||
- Saturdays only.
|
||||
- Type: `rotation`.
|
||||
- Has its own ordered member list.
|
||||
- Uses an anchor date to continue the rotation across month boundaries.
|
||||
- Continues after the previous assigned person's position in the rotation, including across month boundaries. The anchor date is the fallback when there is no usable previous assignee.
|
||||
|
||||
### Sunday Lunch
|
||||
|
||||
@@ -34,13 +34,19 @@ The data model supports a schedule matching multiple weekdays even though the se
|
||||
|
||||
## Rotation generation
|
||||
|
||||
For a rotation schedule, the member is chosen from the ordered active rotation members using the number of matching schedule days from the anchor date to the target date modulo member count.
|
||||
For a rotation schedule, use the last assigned person on an earlier date in that same schedule as the reference. Choose the next active member after that person's position in the current rotation order, wrapping to the beginning. Unassigned dates are skipped when finding the reference. Manual assignments and completed swaps also supply the reference for later dates. Saturday and Sunday never use each other's assignees.
|
||||
|
||||
Inactive members are skipped as recipients, but their position can still supply the reference if they remain in the rotation. When no previous assignee exists, or that person has been removed from the rotation, fall back to the number of matching schedule days from the anchor date modulo the active member count. An empty active rotation leaves the shift unassigned.
|
||||
|
||||
Generation and explicit month/range application process dates chronologically, so each newly assigned date supplies the reference for the next date. Regenerating one assignment only recalculates that date; later existing assignments remain unchanged until an administrator explicitly applies the rotation.
|
||||
|
||||
Only missing assignments are generated. Existing rows are never rewritten.
|
||||
|
||||
An administrator can explicitly **Apply rotation** on a lunch schedule's month page. This recalculates that month's `generated` assignments using the current active rotation members and fills missing dates for that schedule. Manual assignments and completed swaps are preserved. Simply editing the rotation order still does not rewrite any assignment.
|
||||
|
||||
An administrator can choose **Use rotation** on a `manual` assignment to remove that override and immediately recalculate that date from the current rotation. Its source becomes `generated`; if there are no active rotation members, it remains unassigned until an administrator applies a configured rotation. These actions preserve assignment IDs and notes. If ownership changes, open or pending-approval change requests for the assignment are cancelled, and assignment-change notifications are attempted after saving.
|
||||
Every assignment on a lunch schedule page has a **Regenerate** button. An administrator can explicitly regenerate any assignment, including an automatic assignment, manual override, or completed swap. It immediately recalculates that date from the current rotation and its source becomes `generated`; if there are no active rotation members, it becomes unassigned. These actions preserve assignment IDs, notes, and completed request history. If ownership changes, open or pending-approval change requests for the assignment are cancelled, and assignment-change notifications are attempted after saving.
|
||||
|
||||
**Generate after date** fills missing shifts and recalculates automatic assignments in that schedule strictly after the selected reference date, through an explicit end date (inclusive). The range can span multiple months. Assignments on or before the reference date and after the end date are not edited. Manual assignments and completed swaps within the range are preserved and supply references for later dates. The default range covers the displayed month.
|
||||
|
||||
## People and logins
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ MONTH = 10
|
||||
def db():
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine, expire_on_commit=False)()
|
||||
session = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
@@ -305,7 +305,9 @@ def test_apply_rotation_updates_only_automatic_rows_in_selected_schedule_and_mon
|
||||
db.expire_all()
|
||||
assert db.get(Assignment, first.id).user_id == ana.id
|
||||
assert db.get(Assignment, first.id).note == "Keep this note"
|
||||
assert db.get(Assignment, unassigned.id).user_id == bruno.id
|
||||
# The preserved swap on the 17th supplies Bruno as the reference;
|
||||
# in the reordered Bruno -> Ana rotation, the 24th goes to Ana.
|
||||
assert db.get(Assignment, unassigned.id).user_id == ana.id
|
||||
for assignment_id, expected in untouched.items():
|
||||
row = db.get(Assignment, assignment_id)
|
||||
assert (row.user_id, row.source) == expected
|
||||
@@ -392,18 +394,25 @@ def test_reset_unassigned_manual_row_and_apply_later_when_members_become_active(
|
||||
assert (row.user_id, row.source) == (ana.id, "generated")
|
||||
|
||||
|
||||
def test_rotation_controls_render_manual_rows_as_separate_forms(client, db):
|
||||
def test_rotation_controls_render_regenerate_for_every_row_as_separate_forms(client, db):
|
||||
night, sat, _, _, _, _ = seed(db)
|
||||
sign_in(client)
|
||||
ensure_month_assignments(db, YEAR, MONTH)
|
||||
row = assignment_on(db, sat, date(YEAR, MONTH, 3))
|
||||
row.source = "manual"
|
||||
swapped = assignment_on(db, sat, date(YEAR, MONTH, 10))
|
||||
swapped.source = "swap"
|
||||
db.commit()
|
||||
page = client.get(f"/admin/schedules/{sat.id}?year={YEAR}&month={MONTH}").text
|
||||
assert "Apply rotation to October" in page
|
||||
assert f'form="use-rotation-{row.id}"' in page
|
||||
assert f'action="/admin/schedules/{sat.id}/assignments/{row.id}/use-rotation"' in page
|
||||
assert page.index(f'id="use-rotation-{row.id}"') > page.index("Save October")
|
||||
assert page.count(">Regenerate</button>") == 5
|
||||
assert f'form="regenerate-{row.id}"' in page
|
||||
assert f'form="regenerate-{swapped.id}"' in page
|
||||
assert f'action="/admin/schedules/{sat.id}/assignments/{row.id}/regenerate"' in page
|
||||
assert page.index(f'id="regenerate-{row.id}"') > page.index("Save October")
|
||||
assert "Generate after date" in page
|
||||
assert 'name="after_date" value="2026-09-30"' in page
|
||||
assert 'name="through_date" value="2026-10-31"' in page
|
||||
assert "Apply rotation" not in client.get(f"/admin/schedules/{night.id}").text
|
||||
|
||||
|
||||
@@ -416,10 +425,12 @@ def test_rotation_actions_reject_invalid_schedule_row_and_month(client, db):
|
||||
assert client.post(f"/admin/schedules/{night.id}/apply-rotation", data=base).status_code == 400
|
||||
assert client.post(f"/admin/schedules/{sat.id}/apply-rotation", data={**base, "month": 13}).status_code == 400
|
||||
assert client.post(f"/admin/schedules/{sun.id}/assignments/{row.id}/use-rotation", data=base).status_code == 404
|
||||
assert client.post(f"/admin/schedules/{sat.id}/assignments/{row.id}/use-rotation", data=base).status_code == 400
|
||||
row.source = "swap"
|
||||
wrong_day = Assignment(schedule_id=sat.id, date=date(YEAR, MONTH, 4), user_id=None, source="manual")
|
||||
db.add(wrong_day)
|
||||
db.commit()
|
||||
assert client.post(f"/admin/schedules/{sat.id}/assignments/{row.id}/use-rotation", data=base).status_code == 400
|
||||
assert client.post(f"/admin/schedules/{sat.id}/assignments/{wrong_day.id}/regenerate", data=base).status_code == 400
|
||||
night_row = assignment_on(db, night, date(YEAR, MONTH, 3))
|
||||
assert client.post(f"/admin/schedules/{night.id}/assignments/{night_row.id}/regenerate", data=base).status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["apply-rotation", "use-rotation"])
|
||||
@@ -442,3 +453,181 @@ def test_rotation_actions_require_admin_and_csrf(client, db, action):
|
||||
token = CSRF_RE.search(client.get("/login").text).group(1)
|
||||
client.post("/login", data={"username": "member", "password": "member-password", "csrf_token": token})
|
||||
assert client.post(url, data={**data, "csrf_token": token}).status_code == 403
|
||||
|
||||
|
||||
def test_use_rotation_selects_next_person_after_previous_actual_assignee(client, db):
|
||||
_, sat, _, ana, bruno, _ = seed(db)
|
||||
token = sign_in(client)
|
||||
ensure_month_assignments(db, YEAR, MONTH)
|
||||
carla = User(name="Carla", is_active=True, notify_email=False)
|
||||
db.add(carla)
|
||||
db.flush()
|
||||
db.add(RotationMember(schedule_id=sat.id, user_id=carla.id, position=2))
|
||||
previous = assignment_on(db, sat, date(YEAR, MONTH, 3))
|
||||
previous.user_id, previous.source = bruno.id, "manual"
|
||||
target = assignment_on(db, sat, date(YEAR, MONTH, 10))
|
||||
target.user_id, target.source = ana.id, "manual"
|
||||
db.commit()
|
||||
other_rows = {row.id: (row.user_id, row.source) for row in db.scalars(select(Assignment)).all() if row.id != target.id}
|
||||
|
||||
response = client.post(f"/admin/schedules/{sat.id}/assignments/{target.id}/use-rotation", data={
|
||||
"csrf_token": token,
|
||||
}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
db.expire_all()
|
||||
assert (target.user_id, target.source) == (carla.id, "generated")
|
||||
for assignment_id, expected in other_rows.items():
|
||||
row = db.get(Assignment, assignment_id)
|
||||
assert (row.user_id, row.source) == expected
|
||||
|
||||
|
||||
def test_apply_rotation_continues_chronologically_from_previous_month_and_preserved_override(client, db):
|
||||
_, sat, _, ana, bruno, _ = seed(db)
|
||||
token = sign_in(client)
|
||||
ensure_month_assignments(db, YEAR, MONTH)
|
||||
carla = User(name="Carla", is_active=True, notify_email=False)
|
||||
db.add(carla)
|
||||
db.flush()
|
||||
db.add(RotationMember(schedule_id=sat.id, user_id=carla.id, position=2))
|
||||
db.add(Assignment(schedule_id=sat.id, date=date(YEAR, 9, 26), user_id=bruno.id, source="manual"))
|
||||
preserved = assignment_on(db, sat, date(YEAR, MONTH, 17))
|
||||
preserved.user_id, preserved.source = carla.id, "swap"
|
||||
db.commit()
|
||||
|
||||
response = client.post(f"/admin/schedules/{sat.id}/apply-rotation", data={
|
||||
"csrf_token": token, "year": YEAR, "month": MONTH,
|
||||
}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
db.expire_all()
|
||||
rows = [assignment_on(db, sat, date(YEAR, MONTH, day)) for day in (3, 10, 17, 24, 31)]
|
||||
assert [row.user_id for row in rows] == [carla.id, ana.id, carla.id, ana.id, bruno.id]
|
||||
assert preserved.source == "swap"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["generated", "manual", "swap"])
|
||||
def test_regenerate_replaces_any_assignment_source_and_retains_history(client, db, source):
|
||||
_, sat, _, ana, bruno, _ = seed(db)
|
||||
token = sign_in(client)
|
||||
ensure_month_assignments(db, YEAR, MONTH)
|
||||
row = assignment_on(db, sat, date(YEAR, MONTH, 3))
|
||||
row.user_id, row.source, row.note = ana.id, source, "Keep date history"
|
||||
completed = SwapRequest(assignment_id=row.id, requester_id=bruno.id, accepted_by_id=ana.id, status="approved")
|
||||
stale = SwapRequest(assignment_id=row.id, requester_id=ana.id, target_user_id=bruno.id, status="open")
|
||||
db.add_all([completed, stale])
|
||||
db.commit()
|
||||
other_rows = {r.id: (r.user_id, r.source) for r in db.scalars(select(Assignment)).all() if r.id != row.id}
|
||||
|
||||
response = client.post(f"/admin/schedules/{sat.id}/assignments/{row.id}/regenerate", data={
|
||||
"csrf_token": token,
|
||||
}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
db.expire_all()
|
||||
assert (row.user_id, row.source, row.note) == (bruno.id, "generated", "Keep date history")
|
||||
assert completed.status == "approved"
|
||||
assert stale.status == "cancelled"
|
||||
for assignment_id, expected in other_rows.items():
|
||||
current = db.get(Assignment, assignment_id)
|
||||
assert (current.user_id, current.source) == expected
|
||||
|
||||
|
||||
def test_generate_after_date_spans_months_with_exclusive_start_and_inclusive_end(client, db):
|
||||
_, sat, _, ana, bruno, admin = seed(db)
|
||||
token = sign_in(client)
|
||||
ensure_month_assignments(db, YEAR, MONTH)
|
||||
reference = assignment_on(db, sat, date(YEAR, MONTH, 31))
|
||||
reference.user_id, reference.source = ana.id, "manual"
|
||||
outdated = Assignment(schedule_id=sat.id, date=date(YEAR, 11, 7), user_id=ana.id, source="generated")
|
||||
swapped = Assignment(schedule_id=sat.id, date=date(YEAR, 11, 14), user_id=bruno.id, source="swap")
|
||||
later = Assignment(schedule_id=sat.id, date=date(YEAR, 11, 28), user_id=admin.id, source="generated")
|
||||
db.add_all([outdated, swapped, later])
|
||||
db.flush()
|
||||
stale = SwapRequest(assignment_id=outdated.id, requester_id=ana.id, status="pending_approval")
|
||||
db.add(stale)
|
||||
db.commit()
|
||||
untouched = {row.id: (row.user_id, row.source) for row in db.scalars(select(Assignment)).all()
|
||||
if row.id != outdated.id}
|
||||
|
||||
response = client.post(f"/admin/schedules/{sat.id}/generate-after", data={
|
||||
"csrf_token": token, "after_date": "2026-10-31", "through_date": "2026-11-21",
|
||||
}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/admin/schedules/{sat.id}?year=2026&month=11"
|
||||
db.expire_all()
|
||||
assert (outdated.user_id, outdated.source) == (bruno.id, "generated")
|
||||
generated = assignment_on(db, sat, date(YEAR, 11, 21))
|
||||
assert (generated.user_id, generated.source) == (ana.id, "generated")
|
||||
assert stale.status == "cancelled"
|
||||
for assignment_id, expected in untouched.items():
|
||||
row = db.get(Assignment, assignment_id)
|
||||
assert (row.user_id, row.source) == expected
|
||||
november = db.scalars(select(Assignment).where(
|
||||
Assignment.date >= date(YEAR, 11, 1), Assignment.date <= date(YEAR, 11, 30)
|
||||
)).all()
|
||||
assert len(november) == 4
|
||||
assert all(row.schedule_id == sat.id for row in november)
|
||||
before_repeat = [(r.id, r.user_id, r.source) for r in november]
|
||||
log_count = len(db.scalars(select(NotificationLog)).all())
|
||||
client.post(f"/admin/schedules/{sat.id}/generate-after", data={
|
||||
"csrf_token": token, "after_date": "2026-10-31", "through_date": "2026-11-21",
|
||||
}, follow_redirects=False)
|
||||
db.expire_all()
|
||||
assert [(r.id, r.user_id, r.source) for r in november] == before_repeat
|
||||
assert len(db.scalars(select(NotificationLog)).all()) == log_count
|
||||
|
||||
|
||||
def test_generate_after_date_fills_multiple_months_in_order(client, db):
|
||||
_, sat, _, ana, bruno, _ = seed(db)
|
||||
token = sign_in(client)
|
||||
ensure_month_assignments(db, YEAR, MONTH)
|
||||
reference = assignment_on(db, sat, date(YEAR, MONTH, 31))
|
||||
reference.user_id = bruno.id
|
||||
db.commit()
|
||||
response = client.post(f"/admin/schedules/{sat.id}/generate-after", data={
|
||||
"csrf_token": token, "after_date": "2026-10-31", "through_date": "2026-12-05",
|
||||
}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
rows = db.scalars(select(Assignment).where(Assignment.date > date(YEAR, MONTH, 31)).order_by(Assignment.date)).all()
|
||||
assert [r.date for r in rows] == [date(YEAR, 11, day) for day in (7, 14, 21, 28)] + [date(YEAR, 12, 5)]
|
||||
assert [r.user_id for r in rows] == [ana.id, bruno.id, ana.id, bruno.id, ana.id]
|
||||
assert all(r.schedule_id == sat.id and r.source == "generated" for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("after_date,through_date,status", [
|
||||
("2026-10-31", "2026-10-31", 400),
|
||||
("2026-10-31", "2026-10-01", 400),
|
||||
("1999-12-30", "2026-10-31", 400),
|
||||
("2026-10-31", "2101-01-01", 400),
|
||||
("not-a-date", "2026-10-31", 422),
|
||||
("2026-10-31", "2026-11-31", 422),
|
||||
])
|
||||
def test_generate_after_rejects_invalid_ranges_without_editing_data(client, db, after_date, through_date, status):
|
||||
_, sat, _, _, _, _ = seed(db)
|
||||
token = sign_in(client)
|
||||
snapshot = [(r.id, r.user_id, r.source) for r in db.scalars(select(Assignment)).all()]
|
||||
assert client.post(f"/admin/schedules/{sat.id}/generate-after", data={
|
||||
"csrf_token": token, "after_date": after_date, "through_date": through_date,
|
||||
}).status_code == status
|
||||
assert [(r.id, r.user_id, r.source) for r in db.scalars(select(Assignment)).all()] == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["regenerate", "generate-after"])
|
||||
def test_new_generation_controls_require_admin_and_csrf(client, db, action):
|
||||
night, sat, _, _, _, _ = seed(db)
|
||||
ensure_month_assignments(db, YEAR, MONTH)
|
||||
row = assignment_on(db, sat, date(YEAR, MONTH, 3))
|
||||
member = User(name="Member", username="member", password_hash=hash_password("member-password"), is_active=True)
|
||||
db.add(member)
|
||||
db.commit()
|
||||
url = f"/admin/schedules/{sat.id}/assignments/{row.id}/regenerate" if action == "regenerate" else (
|
||||
f"/admin/schedules/{sat.id}/generate-after"
|
||||
)
|
||||
data = {"csrf_token": "invalid", "after_date": "2026-10-31", "through_date": "2026-11-21"}
|
||||
assert client.post(url, data=data).status_code == 401
|
||||
token = sign_in(client)
|
||||
assert client.post(url, data=data).status_code == 400
|
||||
if action == "generate-after":
|
||||
assert client.post(f"/admin/schedules/{night.id}/generate-after", data={**data, "csrf_token": token}).status_code == 400
|
||||
client.post("/logout", data={"csrf_token": token})
|
||||
token = CSRF_RE.search(client.get("/login").text).group(1)
|
||||
client.post("/login", data={"username": "member", "password": "member-password", "csrf_token": token})
|
||||
assert client.post(url, data={**data, "csrf_token": token}).status_code == 403
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
from app.models import Assignment, RotationMember, Schedule, User
|
||||
from app.scheduling import ensure_month_assignments
|
||||
from app.scheduling import ensure_month_assignments, rotation_user_for_date
|
||||
from app.security import hash_password
|
||||
|
||||
|
||||
@@ -17,7 +18,7 @@ def make_db() -> Session:
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
return Session(engine, autoflush=False)
|
||||
|
||||
|
||||
def add_user(db: Session, name: str, pos: int) -> User:
|
||||
@@ -124,3 +125,87 @@ def test_existing_assignment_is_not_rewritten_when_rotation_changes():
|
||||
ensure_month_assignments(db, 2026, 5)
|
||||
refreshed = db.get(Assignment, first_sat.id)
|
||||
assert refreshed.user_id == original_user
|
||||
|
||||
|
||||
def test_generation_continues_from_previous_people_across_months_independently():
|
||||
with make_db() as db:
|
||||
alice, bruno, carla = [add_user(db, name, pos) for pos, name in enumerate(["Alice", "Bruno", "Carla"], 1)]
|
||||
_, sat, sun = seed_schedules(db)
|
||||
for schedule in (sat, sun):
|
||||
for pos, person in enumerate((alice, bruno, carla), 1):
|
||||
db.add(RotationMember(schedule_id=schedule.id, user_id=person.id, position=pos))
|
||||
db.add_all([
|
||||
Assignment(schedule_id=sat.id, date=date(2026, 9, 26), user_id=bruno.id, source="manual"),
|
||||
Assignment(schedule_id=sun.id, date=date(2026, 9, 27), user_id=carla.id, source="swap"),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
assignments = ensure_month_assignments(db, 2026, 10)
|
||||
assert [row.user_id for row in assignments if row.schedule_id == sat.id] == [
|
||||
carla.id, alice.id, bruno.id, carla.id, alice.id,
|
||||
]
|
||||
assert [row.user_id for row in assignments if row.schedule_id == sun.id] == [
|
||||
alice.id, bruno.id, carla.id, alice.id,
|
||||
]
|
||||
november = ensure_month_assignments(db, 2026, 11)
|
||||
assert next(row for row in november if row.schedule_id == sat.id).user_id == bruno.id
|
||||
assert next(row for row in november if row.schedule_id == sun.id).user_id == bruno.id
|
||||
|
||||
|
||||
def test_generation_uses_preserved_manual_and_swapped_shifts_as_references():
|
||||
with make_db() as db:
|
||||
alice, bruno, carla = [add_user(db, name, pos) for pos, name in enumerate(["Alice", "Bruno", "Carla"], 1)]
|
||||
_, sat, _ = seed_schedules(db)
|
||||
for pos, person in enumerate((alice, bruno, carla), 1):
|
||||
db.add(RotationMember(schedule_id=sat.id, user_id=person.id, position=pos))
|
||||
manual = Assignment(schedule_id=sat.id, date=date(2026, 10, 10), user_id=bruno.id, source="manual")
|
||||
swapped = Assignment(schedule_id=sat.id, date=date(2026, 10, 24), user_id=alice.id, source="swap")
|
||||
db.add_all([manual, swapped])
|
||||
db.commit()
|
||||
original_ids = manual.id, swapped.id
|
||||
|
||||
rows = ensure_month_assignments(db, 2026, 10)
|
||||
assert [row.user_id for row in rows if row.schedule_id == sat.id] == [
|
||||
alice.id, bruno.id, carla.id, alice.id, bruno.id,
|
||||
]
|
||||
assert (manual.id, swapped.id) == original_ids
|
||||
assert (manual.source, swapped.source) == ("manual", "swap")
|
||||
snapshot = [(row.id, row.user_id, row.source) for row in rows]
|
||||
assert [(row.id, row.user_id, row.source) for row in ensure_month_assignments(db, 2026, 10)] == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous, inactive, expected", [
|
||||
("Alice", ["Alice"], "Bruno"), # inactive reference still has its position
|
||||
("Alice", ["Bruno"], "Carla"), # skip inactive next person
|
||||
("Carla", [], "Alice"), # wrap at the end
|
||||
("Alice", ["Alice", "Bruno", "Carla"], None),
|
||||
("Outside", [], "Alice"), # removed person: fallback to the anchor
|
||||
])
|
||||
def test_reference_selection_handles_inactive_members_wraparound_and_missing_members(previous, inactive, expected):
|
||||
with make_db() as db:
|
||||
users = {name: add_user(db, name, pos) for pos, name in enumerate(["Alice", "Bruno", "Carla", "Outside"], 1)}
|
||||
_, sat, _ = seed_schedules(db)
|
||||
for pos, name in enumerate(("Alice", "Bruno", "Carla"), 1):
|
||||
users[name].is_active = name not in inactive
|
||||
db.add(RotationMember(schedule_id=sat.id, user_id=users[name].id, position=pos))
|
||||
db.add(Assignment(schedule_id=sat.id, date=date(2026, 9, 26), user_id=users[previous].id, source="manual"))
|
||||
db.commit()
|
||||
selected = rotation_user_for_date(db, sat, date(2026, 10, 3))
|
||||
assert (selected.name if selected else None) == expected
|
||||
|
||||
|
||||
def test_reference_ignores_unassigned_dates_current_date_and_future_shifts():
|
||||
with make_db() as db:
|
||||
alice, bruno, carla = [add_user(db, name, pos) for pos, name in enumerate(["Alice", "Bruno", "Carla"], 1)]
|
||||
_, sat, sun = seed_schedules(db)
|
||||
for pos, person in enumerate((alice, bruno, carla), 1):
|
||||
db.add(RotationMember(schedule_id=sat.id, user_id=person.id, position=pos))
|
||||
db.add_all([
|
||||
Assignment(schedule_id=sat.id, date=date(2026, 9, 19), user_id=bruno.id, source="manual"),
|
||||
Assignment(schedule_id=sat.id, date=date(2026, 9, 26), user_id=None, source="manual"),
|
||||
Assignment(schedule_id=sun.id, date=date(2026, 9, 27), user_id=carla.id, source="manual"),
|
||||
Assignment(schedule_id=sat.id, date=date(2026, 10, 3), user_id=alice.id, source="manual"),
|
||||
Assignment(schedule_id=sat.id, date=date(2026, 10, 10), user_id=carla.id, source="manual"),
|
||||
])
|
||||
db.commit()
|
||||
assert rotation_user_for_date(db, sat, date(2026, 10, 3)).id == carla.id
|
||||
|
||||
Reference in New Issue
Block a user