From 426d5593875eb28e11e9d30cbf2107ec335fa858 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 23 Mar 2026 23:02:18 -0500 Subject: [PATCH 1/3] feat: add limit/pagination to notifications endpoint (#140) Closes #140 Adds optional `limit` query param to `GET /api/v2/notifs` with default 100 and max 500. Limit is applied after all filters. Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/notifications.py | 135 ++++++++++++++++++++++---------- 1 file changed, 92 insertions(+), 43 deletions(-) diff --git a/app/routers_v2/notifications.py b/app/routers_v2/notifications.py index 7fec666..9518dad 100644 --- a/app/routers_v2/notifications.py +++ b/app/routers_v2/notifications.py @@ -9,10 +9,7 @@ from ..db_engine import Notification, model_to_dict, fn, DoesNotExist from ..dependencies import oauth2_scheme, valid_token -router = APIRouter( - prefix='/api/v2/notifs', - tags=['notifs'] -) +router = APIRouter(prefix="/api/v2/notifs", tags=["notifs"]) class NotifModel(pydantic.BaseModel): @@ -21,19 +18,30 @@ class NotifModel(pydantic.BaseModel): desc: Optional[str] = None field_name: str message: str - about: Optional[str] = 'blank' + about: Optional[str] = "blank" ack: Optional[bool] = False -@router.get('') +@router.get("") async def get_notifs( - created_after: Optional[int] = None, title: Optional[str] = None, desc: Optional[str] = None, - field_name: Optional[str] = None, in_desc: Optional[str] = None, about: Optional[str] = None, - ack: Optional[bool] = None, csv: Optional[bool] = None): + created_after: Optional[int] = None, + title: Optional[str] = None, + desc: Optional[str] = None, + field_name: Optional[str] = None, + in_desc: Optional[str] = None, + about: Optional[str] = None, + ack: Optional[bool] = None, + csv: Optional[bool] = None, + limit: Optional[int] = 100, +): + if limit is not None: + limit = min(limit, 500) all_notif = Notification.select().order_by(Notification.id) if all_notif.count() == 0: - raise HTTPException(status_code=404, detail=f'There are no notifications to filter') + raise HTTPException( + status_code=404, detail="There are no notifications to filter" + ) if created_after is not None: # Convert milliseconds timestamp to datetime for PostgreSQL comparison @@ -46,62 +54,88 @@ async def get_notifs( if field_name is not None: all_notif = all_notif.where(Notification.field_name == field_name) if in_desc is not None: - all_notif = all_notif.where(fn.Lower(Notification.desc).contains(in_desc.lower())) + all_notif = all_notif.where( + fn.Lower(Notification.desc).contains(in_desc.lower()) + ) if about is not None: all_notif = all_notif.where(Notification.about == about) if ack is not None: all_notif = all_notif.where(Notification.ack == ack) + if limit is not None: + all_notif = all_notif.limit(limit) + if csv: - data_list = [['id', 'created', 'title', 'desc', 'field_name', 'message', 'about', 'ack']] + data_list = [ + ["id", "created", "title", "desc", "field_name", "message", "about", "ack"] + ] for line in all_notif: - data_list.append([ - line.id, line.created, line.title, line.desc, line.field_name, line.message, line.about, line.ack - ]) + data_list.append( + [ + line.id, + line.created, + line.title, + line.desc, + line.field_name, + line.message, + line.about, + line.ack, + ] + ) return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: - return_val = {'count': all_notif.count(), 'notifs': []} + return_val = {"count": all_notif.count(), "notifs": []} for x in all_notif: - return_val['notifs'].append(model_to_dict(x)) + return_val["notifs"].append(model_to_dict(x)) return return_val -@router.get('/{notif_id}') +@router.get("/{notif_id}") async def get_one_notif(notif_id, csv: Optional[bool] = None): try: this_notif = Notification.get_by_id(notif_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No notification found with id {notif_id}') + raise HTTPException( + status_code=404, detail=f"No notification found with id {notif_id}" + ) if csv: data_list = [ - ['id', 'created', 'title', 'desc', 'field_name', 'message', 'about', 'ack'], - [this_notif.id, this_notif.created, this_notif.title, this_notif.desc, this_notif.field_name, - this_notif.message, this_notif.about, this_notif.ack] + ["id", "created", "title", "desc", "field_name", "message", "about", "ack"], + [ + this_notif.id, + this_notif.created, + this_notif.title, + this_notif.desc, + this_notif.field_name, + this_notif.message, + this_notif.about, + this_notif.ack, + ], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = model_to_dict(this_notif) return return_val -@router.post('') +@router.post("") async def post_notif(notif: NotifModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post notifications. This event has been logged.' + detail="You are not authorized to post notifications. This event has been logged.", ) - logging.info(f'new notif: {notif}') + logging.info(f"new notif: {notif}") this_notif = Notification( created=datetime.fromtimestamp(notif.created / 1000), title=notif.title, @@ -118,25 +152,34 @@ async def post_notif(notif: NotifModel, token: str = Depends(oauth2_scheme)): else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that notification' + detail="Well slap my ass and call me a teapot; I could not save that notification", ) -@router.patch('/{notif_id}') +@router.patch("/{notif_id}") async def patch_notif( - notif_id, created: Optional[int] = None, title: Optional[str] = None, desc: Optional[str] = None, - field_name: Optional[str] = None, message: Optional[str] = None, about: Optional[str] = None, - ack: Optional[bool] = None, token: str = Depends(oauth2_scheme)): + notif_id, + created: Optional[int] = None, + title: Optional[str] = None, + desc: Optional[str] = None, + field_name: Optional[str] = None, + message: Optional[str] = None, + about: Optional[str] = None, + ack: Optional[bool] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to patch notifications. This event has been logged.' + detail="You are not authorized to patch notifications. This event has been logged.", ) try: this_notif = Notification.get_by_id(notif_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No notification found with id {notif_id}') + raise HTTPException( + status_code=404, detail=f"No notification found with id {notif_id}" + ) if title is not None: this_notif.title = title @@ -159,26 +202,32 @@ async def patch_notif( else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that rarity' + detail="Well slap my ass and call me a teapot; I could not save that rarity", ) -@router.delete('/{notif_id}') +@router.delete("/{notif_id}") async def delete_notif(notif_id, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to delete notifications. This event has been logged.' + detail="You are not authorized to delete notifications. This event has been logged.", ) try: this_notif = Notification.get_by_id(notif_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No notification found with id {notif_id}') + raise HTTPException( + status_code=404, detail=f"No notification found with id {notif_id}" + ) count = this_notif.delete_instance() if count == 1: - raise HTTPException(status_code=200, detail=f'Notification {notif_id} has been deleted') + raise HTTPException( + status_code=200, detail=f"Notification {notif_id} has been deleted" + ) else: - raise HTTPException(status_code=500, detail=f'Notification {notif_id} was not deleted') + raise HTTPException( + status_code=500, detail=f"Notification {notif_id} was not deleted" + ) From ac8ec4b283980d6bd779fda23d6c2264901a664d Mon Sep 17 00:00:00 2001 From: cal Date: Tue, 24 Mar 2026 12:08:15 +0000 Subject: [PATCH 2/3] fix: clamp limit to 0 minimum to prevent negative limit values --- app/routers_v2/notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routers_v2/notifications.py b/app/routers_v2/notifications.py index 9518dad..7a6053c 100644 --- a/app/routers_v2/notifications.py +++ b/app/routers_v2/notifications.py @@ -35,7 +35,7 @@ async def get_notifs( limit: Optional[int] = 100, ): if limit is not None: - limit = min(limit, 500) + limit = max(0, min(limit, 500)) all_notif = Notification.select().order_by(Notification.id) if all_notif.count() == 0: From 66505915a7c8ef8143b1410c33fe99baadf9e5d5 Mon Sep 17 00:00:00 2001 From: cal Date: Tue, 24 Mar 2026 12:11:18 +0000 Subject: [PATCH 3/3] fix: capture total_count before applying limit so response count reflects matching records not page size --- app/routers_v2/notifications.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/routers_v2/notifications.py b/app/routers_v2/notifications.py index 7a6053c..03f67a2 100644 --- a/app/routers_v2/notifications.py +++ b/app/routers_v2/notifications.py @@ -62,6 +62,8 @@ async def get_notifs( if ack is not None: all_notif = all_notif.where(Notification.ack == ack) + total_count = all_notif.count() + if limit is not None: all_notif = all_notif.limit(limit) @@ -87,7 +89,7 @@ async def get_notifs( return Response(content=return_val, media_type="text/csv") else: - return_val = {"count": all_notif.count(), "notifs": []} + return_val = {"count": total_count, "notifs": []} for x in all_notif: return_val["notifs"].append(model_to_dict(x))