Phase 6: Admin appointments view and cancellation with UI and backend tests

This commit is contained in:
counterweight 2025-12-21 00:30:09 +01:00
parent 5108a620e7
commit b3e00b0745
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
12 changed files with 814 additions and 548 deletions

View file

@ -49,4 +49,5 @@ app.include_router(invites_routes.admin_router)
app.include_router(availability_routes.router)
app.include_router(booking_routes.router)
app.include_router(booking_routes.appointments_router)
app.include_router(booking_routes.admin_appointments_router)
app.include_router(meta_routes.router)

View file

@ -203,6 +203,13 @@ async def create_booking(
appointments_router = APIRouter(prefix="/api/appointments", tags=["appointments"])
async def _get_user_email(db: AsyncSession, user_id: int) -> str:
"""Get user email by ID."""
result = await db.execute(select(User.email).where(User.id == user_id))
email = result.scalar_one_or_none()
return email or "unknown"
@appointments_router.get("", response_model=list[AppointmentResponse])
async def get_my_appointments(
db: AsyncSession = Depends(get_db),
@ -278,3 +285,84 @@ async def cancel_my_appointment(
cancelled_at=appointment.cancelled_at,
)
# =============================================================================
# Admin Appointments Endpoints
# =============================================================================
admin_appointments_router = APIRouter(prefix="/api/admin/appointments", tags=["admin-appointments"])
@admin_appointments_router.get("", response_model=list[AppointmentResponse])
async def get_all_appointments(
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_APPOINTMENTS)),
) -> list[AppointmentResponse]:
"""Get all appointments (admin only), sorted by date descending."""
result = await db.execute(
select(Appointment)
.order_by(Appointment.slot_start.desc())
)
appointments = result.scalars().all()
responses = []
for apt in appointments:
user_email = await _get_user_email(db, apt.user_id)
responses.append(AppointmentResponse(
id=apt.id,
user_id=apt.user_id,
user_email=user_email,
slot_start=apt.slot_start,
slot_end=apt.slot_end,
note=apt.note,
status=apt.status.value,
created_at=apt.created_at,
cancelled_at=apt.cancelled_at,
))
return responses
@admin_appointments_router.post("/{appointment_id}/cancel", response_model=AppointmentResponse)
async def admin_cancel_appointment(
appointment_id: int,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.CANCEL_ANY_APPOINTMENT)),
) -> AppointmentResponse:
"""Cancel any appointment (admin only)."""
# Get the appointment
result = await db.execute(
select(Appointment).where(Appointment.id == appointment_id)
)
appointment = result.scalar_one_or_none()
if not appointment:
raise HTTPException(status_code=404, detail="Appointment not found")
# Check if already cancelled
if appointment.status != AppointmentStatus.BOOKED:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel appointment with status '{appointment.status.value}'"
)
# Cancel the appointment
appointment.status = AppointmentStatus.CANCELLED_BY_ADMIN
appointment.cancelled_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(appointment)
user_email = await _get_user_email(db, appointment.user_id)
return AppointmentResponse(
id=appointment.id,
user_id=appointment.user_id,
user_email=user_email,
slot_start=appointment.slot_start,
slot_end=appointment.slot_end,
note=appointment.note,
status=appointment.status.value,
created_at=appointment.created_at,
cancelled_at=appointment.cancelled_at,
)

View file

@ -656,3 +656,153 @@ class TestCancelAppointment:
)
assert len(slots_response.json()["slots"]) == 2
# =============================================================================
# Admin Appointments Tests
# =============================================================================
class TestAdminViewAppointments:
"""Test admin viewing all appointments."""
@pytest.mark.asyncio
async def test_admin_can_view_all_appointments(self, client_factory, regular_user, admin_user):
"""Admin can view all appointments."""
# Admin sets availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(tomorrow()),
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
},
)
# User books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z", "note": "Test"},
)
# Admin views all appointments
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
response = await admin_client.get("/api/admin/appointments")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
assert any(apt["note"] == "Test" for apt in data)
@pytest.mark.asyncio
async def test_regular_user_cannot_view_all_appointments(self, client_factory, regular_user):
"""Regular user cannot access admin appointments endpoint."""
async with client_factory.create(cookies=regular_user["cookies"]) as client:
response = await client.get("/api/admin/appointments")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_unauthenticated_cannot_view_all_appointments(self, client):
"""Unauthenticated user cannot view appointments."""
response = await client.get("/api/admin/appointments")
assert response.status_code == 401
class TestAdminCancelAppointment:
"""Test admin cancelling appointments."""
@pytest.mark.asyncio
async def test_admin_can_cancel_any_appointment(self, client_factory, regular_user, admin_user):
"""Admin can cancel any user's appointment."""
# Admin sets availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(tomorrow()),
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
},
)
# User books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
# Admin cancels
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
response = await admin_client.post(f"/api/admin/appointments/{apt_id}/cancel")
assert response.status_code == 200
data = response.json()
assert data["status"] == "cancelled_by_admin"
assert data["cancelled_at"] is not None
@pytest.mark.asyncio
async def test_regular_user_cannot_use_admin_cancel(self, client_factory, regular_user, admin_user):
"""Regular user cannot use admin cancel endpoint."""
# Admin sets availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(tomorrow()),
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
},
)
# User books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
# User tries to use admin cancel endpoint
response = await client.post(f"/api/admin/appointments/{apt_id}/cancel")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_admin_cancel_nonexistent_appointment(self, client_factory, admin_user):
"""Returns 404 for non-existent appointment."""
async with client_factory.create(cookies=admin_user["cookies"]) as client:
response = await client.post("/api/admin/appointments/99999/cancel")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_admin_cannot_cancel_already_cancelled(self, client_factory, regular_user, admin_user):
"""Admin cannot cancel an already cancelled appointment."""
# Admin sets availability
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
await admin_client.put(
"/api/admin/availability",
json={
"date": str(tomorrow()),
"slots": [{"start_time": "09:00:00", "end_time": "12:00:00"}],
},
)
# User books
async with client_factory.create(cookies=regular_user["cookies"]) as client:
book_response = await client.post(
"/api/booking",
json={"slot_start": f"{tomorrow()}T09:00:00Z"},
)
apt_id = book_response.json()["id"]
# User cancels their own appointment
await client.post(f"/api/appointments/{apt_id}/cancel")
# Admin tries to cancel again
async with client_factory.create(cookies=admin_user["cookies"]) as admin_client:
response = await admin_client.post(f"/api/admin/appointments/{apt_id}/cancel")
assert response.status_code == 400
assert "cancelled_by_user" in response.json()["detail"]