Skip to content

Commit d5d2ccd

Browse files
Page the admin panel routes, and add a hard-delete route in admin panel. (Fixes #925) (#926)
* Page the admin panel routes, and add a hard-delete route in admin panel. (Fixes #925) * lint fixes * 🌐 Update German language --------- Co-authored-by: Andreas Müller <[email protected]>
1 parent 0e3bf03 commit d5d2ccd

File tree

17 files changed

+545
-123
lines changed

17 files changed

+545
-123
lines changed

backend/src/appointment/database/schemas.py

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ class Availability(AvailabilityBase):
159159

160160

161161
class ScheduleBase(BaseModel):
162-
model_config = ConfigDict(json_encoders = { time: lambda t: t.strftime('%H:%M') })
162+
model_config = ConfigDict(json_encoders={time: lambda t: t.strftime('%H:%M')})
163163

164164
active: bool | None = True
165165
name: str = Field(min_length=1, max_length=128)
@@ -195,7 +195,7 @@ class ScheduleValidationIn(ScheduleBase):
195195
"""ScheduleBase but with specific fields overridden to add validation."""
196196

197197
# Regex to exclude any character can be mess with a url
198-
slug: Annotated[Optional[str], Field(min_length=2, max_length=16, pattern=r"^[^\;\/\?\:\@\&\=\+\$\,\#]*$")] = None
198+
slug: Annotated[Optional[str], Field(min_length=2, max_length=16, pattern=r'^[^\;\/\?\:\@\&\=\+\$\,\#]*$')] = None
199199
slot_duration: Annotated[int, Field(ge=10, default=30)]
200200
# Require these fields
201201
start_date: date
@@ -211,29 +211,26 @@ def start_time_should_be_before_end_time(self) -> Self:
211211
# Fallback to utc...
212212
tz = self.timezone or 'UTC'
213213

214-
start_time = datetime.combine(
215-
self.start_date,
216-
self.start_time,
217-
tzinfo=timezone.utc).astimezone(zoneinfo.ZoneInfo(tz)
214+
start_time = datetime.combine(self.start_date, self.start_time, tzinfo=timezone.utc).astimezone(
215+
zoneinfo.ZoneInfo(tz)
218216
)
219217

220-
end_time = datetime.combine(
221-
self.start_date,
222-
self.end_time,
223-
tzinfo=timezone.utc).astimezone(zoneinfo.ZoneInfo(tz)
218+
end_time = datetime.combine(self.start_date, self.end_time, tzinfo=timezone.utc).astimezone(
219+
zoneinfo.ZoneInfo(tz)
224220
)
225221

226-
start_time = (start_time + timedelta(minutes=self.slot_duration))
222+
start_time = start_time + timedelta(minutes=self.slot_duration)
227223
end_time = end_time
228224
# Compare time objects!
229225
if start_time.time() > end_time.time():
230226
msg = l10n('error-minimum-value')
231227

232228
# These can't be field or value because that will auto-format the msg? Weird feature but okay.
233-
raise PydanticCustomError(defines.END_TIME_BEFORE_START_TIME_ERR, msg, {
234-
'err_field': 'end_time',
235-
'err_value': start_time.astimezone(timezone.utc).time()
236-
})
229+
raise PydanticCustomError(
230+
defines.END_TIME_BEFORE_START_TIME_ERR,
231+
msg,
232+
{'err_field': 'end_time', 'err_value': start_time.astimezone(timezone.utc).time()},
233+
)
237234

238235
return self
239236

@@ -286,6 +283,8 @@ class CalendarOut(CalendarBase):
286283

287284

288285
class Invite(BaseModel):
286+
model_config = ConfigDict(from_attributes=True)
287+
289288
subscriber_id: int | None = None
290289
owner_id: Optional[int] = None
291290
code: str
@@ -339,14 +338,38 @@ class SubscriberMeOut(SubscriberBase):
339338
schedule_links: list[str] = []
340339

341340

342-
class SubscriberAdminOut(Subscriber):
341+
class SubscriberAdminItem(Subscriber):
343342
model_config = ConfigDict(from_attributes=True)
344343

345344
invite: Invite | None = None
346345
time_created: datetime
347346
time_deleted: datetime | None
348347

349348

349+
class Paginator(BaseModel):
350+
page: int
351+
total_pages: int
352+
count: int
353+
per_page: int
354+
355+
356+
class ListResponse(BaseModel):
357+
items: list
358+
page_meta: Paginator
359+
360+
361+
class SubscriberAdminOut(ListResponse):
362+
items: list[SubscriberAdminItem]
363+
364+
365+
class ListResponseIn(BaseModel):
366+
page: int = 1
367+
per_page: int = 50
368+
369+
370+
class InviteAdminOut(ListResponse):
371+
items: list[Invite]
372+
350373
""" other schemas used for requests or data migration
351374
"""
352375

@@ -471,7 +494,7 @@ class WaitingListInviteAdminOut(BaseModel):
471494
errors: list[str]
472495

473496

474-
class WaitingListAdminOut(BaseModel):
497+
class WaitingListAdminOutItem(BaseModel):
475498
model_config = ConfigDict(from_attributes=True)
476499

477500
id: int
@@ -484,6 +507,10 @@ class WaitingListAdminOut(BaseModel):
484507
invite: Invite | None = None
485508

486509

510+
class WaitingListAdminOut(ListResponse):
511+
items: list[WaitingListAdminOutItem]
512+
513+
487514
class PageLoadIn(BaseModel):
488515
browser: Optional[str] = None
489516
browser_version: Optional[str] = None

backend/src/appointment/exceptions/validation.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,16 @@ def get_msg(self):
283283
return l10n('subscriber-already-deleted')
284284

285285

286+
class SubscriberNotDisabledException(APIException):
287+
"""Raise when a subscriber is not disabled before being hard deleted."""
288+
289+
id_code = 'SUBSCRIBER_NOT_DISABLED'
290+
status_code = 400
291+
292+
def get_msg(self):
293+
return l10n('subscriber-not-disabled')
294+
295+
286296
class SubscriberAlreadyEnabledException(APIException):
287297
"""Raise when a subscriber failed to be marked undeleted because they already are"""
288298

backend/src/appointment/l10n/de/main.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ subscriber-already-exists = Eine Person mit dieser E-Mail-Adresse existiert bere
6161
subscriber-already-deleted = Die Person ist bereits gelöscht.
6262
subscriber-already-enabled = Die Person ist bereits aktiv.
6363
subscriber-self-delete = Die Löschung des eigenen Benutzerkontos ist hier nicht möglich.
64+
subscriber-not-disabled = Die Person muss deaktiviert sein, bevor sie gänzlich gelöscht werden kann.
6465
6566
rate-limit-exceeded = Zu viele Anfragen in zu kurzem Zeitraum. Bitte später noch einmal versuchen.
6667

backend/src/appointment/l10n/en/main.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ subscriber-already-exists = A subscriber with this email address already exists.
6161
subscriber-already-deleted = The subscriber is already deleted.
6262
subscriber-already-enabled = The subscriber is already enabled.
6363
subscriber-self-delete = You are not allowed to delete yourself here.
64+
subscriber-not-disabled = You must disable a subscriber before you can hard-delete their account.
6465
6566
rate-limit-exceeded = Too many requests in a short period. Please try again later.
6667

backend/src/appointment/routes/invite.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import math
2+
13
from fastapi import APIRouter, Depends, BackgroundTasks
24

35
from sqlalchemy.orm import Session
@@ -20,10 +22,23 @@
2022
"""
2123

2224

23-
@router.get('/', response_model=list[schemas.Invite])
24-
def get_all_invites(db: Session = Depends(get_db), _admin: Subscriber = Depends(get_admin_subscriber)):
25+
@router.post('/', response_model=schemas.InviteAdminOut)
26+
def get_all_invites(
27+
data: schemas.ListResponseIn, db: Session = Depends(get_db), _admin: Subscriber = Depends(get_admin_subscriber)
28+
):
2529
"""List all existing invites, needs admin permissions"""
26-
return db.query(models.Invite).all()
30+
page = data.page - 1
31+
per_page = data.per_page
32+
33+
total_count = db.query(models.Subscriber).count()
34+
invites = db.query(models.Invite).order_by('time_created').offset(page * per_page).limit(per_page).all()
35+
36+
return schemas.InviteAdminOut(
37+
items=invites,
38+
page_meta=schemas.Paginator(
39+
page=data.page, per_page=per_page, count=len(invites), total_pages=math.ceil(total_count / per_page)
40+
),
41+
)
2742

2843

2944
@router.post('/generate/{n}', response_model=list[schemas.Invite])

backend/src/appointment/routes/subscriber.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
from fastapi import APIRouter, Depends, Request
1+
import math
22

3-
from sqlalchemy.orm import Session
3+
from fastapi import APIRouter, Depends
4+
5+
from sqlalchemy.orm import Session, joinedload
46

57
from ..database import repo, schemas, models
68
from ..database.models import Subscriber
@@ -15,11 +17,34 @@
1517
These require get_admin_subscriber!
1618
"""
1719

18-
@router.get('/', response_model=list[schemas.SubscriberAdminOut])
19-
def get_all_subscriber(request: Request, db: Session = Depends(get_db), _: Subscriber = Depends(get_admin_subscriber)):
20+
21+
@router.post('/', response_model=schemas.SubscriberAdminOut)
22+
def get_all_subscriber(
23+
data: schemas.ListResponseIn, db: Session = Depends(get_db), _: Subscriber = Depends(get_admin_subscriber)
24+
):
2025
"""List all existing invites, needs admin permissions"""
21-
response = db.query(models.Subscriber).all()
22-
return response
26+
page = data.page - 1
27+
per_page = data.per_page
28+
29+
total_count = db.query(models.Subscriber).count()
30+
subscribers = (
31+
db.query(models.Subscriber)
32+
.options(joinedload(models.Subscriber.invite))
33+
.order_by('time_created')
34+
.offset(page * per_page)
35+
.limit(per_page)
36+
.all()
37+
)
38+
39+
return schemas.SubscriberAdminOut(
40+
items=subscribers,
41+
page_meta=schemas.Paginator(
42+
page=data.page,
43+
per_page=per_page,
44+
count=len(subscribers),
45+
total_pages=math.ceil(total_count / per_page)
46+
),
47+
)
2348

2449

2550
@router.put('/disable/{email}')
@@ -52,6 +77,23 @@ def enable_subscriber(email: str, db: Session = Depends(get_db), _: Subscriber =
5277
return repo.subscriber.enable(db, subscriber_to_enable)
5378

5479

80+
@router.put('/hard-delete/{id}')
81+
def hard_delete_subscriber(
82+
id: str, db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_admin_subscriber)
83+
):
84+
"""endpoint to hard-delete a subscriber by email, needs admin permissions"""
85+
subscriber_to_delete = repo.subscriber.get(db, int(id))
86+
if not subscriber_to_delete:
87+
raise validation.SubscriberNotFoundException()
88+
if not subscriber_to_delete.is_deleted:
89+
raise validation.SubscriberNotDisabledException()
90+
if subscriber.email == subscriber_to_delete.email:
91+
raise validation.SubscriberSelfDeleteException()
92+
93+
# Nuke their account
94+
return repo.subscriber.hard_delete(db, subscriber_to_delete)
95+
96+
5597
""" NON-ADMIN ROUTES """
5698

5799

backend/src/appointment/routes/waiting_list.py

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import math
12
import os
23

34
import sentry_sdk
@@ -27,12 +28,9 @@ class WaitingListAction(Enum):
2728

2829

2930
@router.post('/join')
30-
@limiter.limit("2/minute")
31+
@limiter.limit('2/minute')
3132
def join_the_waiting_list(
32-
request: Request,
33-
data: schemas.JoinTheWaitingList,
34-
background_tasks: BackgroundTasks,
35-
db: Session = Depends(get_db)
33+
request: Request, data: schemas.JoinTheWaitingList, background_tasks: BackgroundTasks, db: Session = Depends(get_db)
3634
):
3735
"""Join the waiting list!"""
3836
email = data.email.lower()
@@ -78,11 +76,7 @@ def act_on_waiting_list(data: schemas.TokenForWaitingList, db: Session = Depends
7876
waiting_list_entry = repo.invite.get_waiting_list_entry_by_email(db, email)
7977

8078
if waiting_list_entry and waiting_list_entry.invite_id and waiting_list_entry.invite.subscriber_id:
81-
return {
82-
'action': action,
83-
'success': False,
84-
'redirectToSettings': True
85-
}
79+
return {'action': action, 'success': False, 'redirectToSettings': True}
8680

8781
success = repo.invite.remove_waiting_list_email(db, email)
8882
else:
@@ -108,11 +102,28 @@ def act_on_waiting_list(data: schemas.TokenForWaitingList, db: Session = Depends
108102
"""
109103

110104

111-
@router.get('/', response_model=list[schemas.WaitingListAdminOut])
112-
def get_all_waiting_list_users(db: Session = Depends(get_db), _: models.Subscriber = Depends(get_admin_subscriber)):
105+
@router.post('/', response_model=schemas.WaitingListAdminOut)
106+
def get_all_waiting_list_users(
107+
data: schemas.ListResponseIn, db: Session = Depends(get_db), _: models.Subscriber = Depends(get_admin_subscriber)
108+
):
113109
"""List all existing waiting list users, needs admin permissions"""
114-
response = db.query(models.WaitingList).all()
115-
return response
110+
page = data.page - 1
111+
per_page = data.per_page
112+
113+
total_count = db.query(models.WaitingList).count()
114+
waiting_list_items = (
115+
db.query(models.WaitingList).order_by('time_created').offset(page * per_page).limit(per_page).all()
116+
)
117+
118+
return schemas.WaitingListAdminOut(
119+
items=waiting_list_items,
120+
page_meta=schemas.Paginator(
121+
page=data.page,
122+
per_page=per_page,
123+
count=len(waiting_list_items),
124+
total_pages=math.ceil(total_count / per_page),
125+
),
126+
)
116127

117128

118129
@router.post('/invite', response_model=schemas.WaitingListInviteAdminOut)
@@ -139,7 +150,7 @@ def invite_waiting_list_users(
139150

140151
for id in data.id_list:
141152
# Look the user up!
142-
waiting_list_user: models.WaitingList|None = (
153+
waiting_list_user: models.WaitingList | None = (
143154
db.query(models.WaitingList).filter(models.WaitingList.id == id).first()
144155
)
145156
# If the user doesn't exist, or if they're already invited ignore them
@@ -179,17 +190,21 @@ def invite_waiting_list_users(
179190
send_invite_account_email,
180191
date=waiting_list_user.time_created,
181192
to=subscriber.email,
182-
lang=subscriber.language
193+
lang=subscriber.language,
183194
)
184195
accepted.append(waiting_list_user.id)
185196

186197
if posthog:
187-
posthog.capture(distinct_id=admin.unique_hash, event='apmt.admin.invited', properties={
188-
'from': 'waitingList',
189-
'waiting-list-ids': accepted,
190-
'errors-encountered': len(errors),
191-
'service': 'apmt'
192-
})
198+
posthog.capture(
199+
distinct_id=admin.unique_hash,
200+
event='apmt.admin.invited',
201+
properties={
202+
'from': 'waitingList',
203+
'waiting-list-ids': accepted,
204+
'errors-encountered': len(errors),
205+
'service': 'apmt',
206+
},
207+
)
193208

194209
return schemas.WaitingListInviteAdminOut(
195210
accepted=accepted,

backend/test/integration/test_general.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ def test_health_for_locale(self, with_client):
5757
('get', '/schedule'),
5858
('get', '/schedule/0'),
5959
('put', '/schedule/0'),
60-
('get', '/invite'),
60+
('post', '/invite'),
6161
('post', '/invite/generate/1'),
6262
('put', '/invite/revoke/1'),
63-
('get', '/subscriber'),
63+
('post', '/subscriber'),
6464
('put', '/subscriber/enable/[email protected]'),
6565
('put', '/subscriber/disable/[email protected]'),
6666
('post', '/subscriber/setup'),

0 commit comments

Comments
 (0)