diff --git a/.gitignore b/.gitignore index 9d31390..920bc46 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ *.py[cod] __pycache__/ *.so - +secrets # Virtualenv venv/ ENV/ diff --git a/docs/notification-flow.excalidraw b/docs/notification-flow.excalidraw new file mode 100644 index 0000000..3eaf11e --- /dev/null +++ b/docs/notification-flow.excalidraw @@ -0,0 +1,1235 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "title", + "type": "text", + "x": 280, + "y": 20, + "width": 520, + "height": 35, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1001, + "version": 1, + "versionNonce": 1001, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "WebBuddhist — Server-Driven Notification Flow", + "fontSize": 28, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "top", + "containerId": null, + "originalText": "WebBuddhist — Server-Driven Notification Flow", + "lineHeight": 1.25 + }, + { + "id": "write-label", + "type": "text", + "x": 40, + "y": 80, + "width": 320, + "height": 25, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1002, + "version": 1, + "versionNonce": 1002, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "WRITE PATH (rare — enroll / edit routine)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "WRITE PATH (rare — enroll / edit routine)", + "lineHeight": 1.25 + }, + { + "id": "write-box", + "type": "rectangle", + "x": 30, + "y": 110, + "width": 1020, + "height": 160, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "#e7f5ff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1003, + "version": 1, + "versionNonce": 1003, + "isDeleted": false, + "boundElements": [ + { "id": "w-arrow1", "type": "arrow" }, + { "id": "w-arrow2", "type": "arrow" }, + { "id": "w-arrow3", "type": "arrow" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "app-box", + "type": "rectangle", + "x": 60, + "y": 150, + "width": 160, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1004, + "version": 1, + "versionNonce": 1004, + "isDeleted": false, + "boundElements": [ + { "id": "w-arrow1", "type": "arrow" }, + { "id": "app-text", "type": "text" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "app-text", + "type": "text", + "x": 70, + "y": 165, + "width": 140, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1005, + "version": 1, + "versionNonce": 1005, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "📱 Mobile App\n(routine + tz\n+ device token)", + "fontSize": 16, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "app-box", + "originalText": "📱 Mobile App\n(routine + tz\n+ device token)", + "lineHeight": 1.25 + }, + { + "id": "enroll-box", + "type": "rectangle", + "x": 340, + "y": 140, + "width": 260, + "height": 100, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#fff3bf", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1006, + "version": 1, + "versionNonce": 1006, + "isDeleted": false, + "boundElements": [ + { "id": "w-arrow1", "type": "arrow" }, + { "id": "w-arrow2", "type": "arrow" }, + { "id": "enroll-text", "type": "text" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "enroll-text", + "type": "text", + "x": 350, + "y": 152, + "width": 240, + "height": 76, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1007, + "version": 1, + "versionNonce": 1007, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Worker API\nPOST /notifications/reminders\ncompute next trigger_at", + "fontSize": 16, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "enroll-box", + "originalText": "Worker API\nPOST /notifications/reminders\ncompute next trigger_at", + "lineHeight": 1.25 + }, + { + "id": "pg-write-box", + "type": "rectangle", + "x": 720, + "y": 140, + "width": 280, + "height": 100, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#d3f9d8", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1008, + "version": 1, + "versionNonce": 1008, + "isDeleted": false, + "boundElements": [ + { "id": "w-arrow2", "type": "arrow" }, + { "id": "w-arrow3", "type": "arrow" }, + { "id": "pg-write-text", "type": "text" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "pg-write-text", + "type": "text", + "x": 730, + "y": 152, + "width": 260, + "height": 76, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1009, + "version": 1, + "versionNonce": 1009, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "🗄️ PostgreSQL\nupcoming_reminders\nstatus = pending", + "fontSize": 16, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "pg-write-box", + "originalText": "🗄️ PostgreSQL\nupcoming_reminders\nstatus = pending", + "lineHeight": 1.25 + }, + { + "id": "w-arrow1", + "type": "arrow", + "x": 220, + "y": 190, + "width": 115, + "height": 0, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 2 }, + "seed": 1010, + "version": 1, + "versionNonce": 1010, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [[0, 0], [115, 0]], + "lastCommittedPoint": null, + "startBinding": { "elementId": "app-box", "focus": 0, "gap": 5 }, + "endBinding": { "elementId": "enroll-box", "focus": 0, "gap": 5 }, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "w-arrow2", + "type": "arrow", + "x": 600, + "y": 190, + "width": 115, + "height": 0, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 2 }, + "seed": 1011, + "version": 1, + "versionNonce": 1011, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [[0, 0], [115, 0]], + "lastCommittedPoint": null, + "startBinding": { "elementId": "enroll-box", "focus": 0, "gap": 5 }, + "endBinding": { "elementId": "pg-write-box", "focus": 0, "gap": 5 }, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "w-label1", + "type": "text", + "x": 230, + "y": 165, + "width": 90, + "height": 20, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1012, + "version": 1, + "versionNonce": 1012, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "enroll", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "top", + "containerId": null, + "originalText": "enroll", + "lineHeight": 1.25 + }, + { + "id": "w-label2", + "type": "text", + "x": 610, + "y": 165, + "width": 90, + "height": 20, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1013, + "version": 1, + "versionNonce": 1013, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "INSERT row", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "top", + "containerId": null, + "originalText": "INSERT row", + "lineHeight": 1.25 + }, + { + "id": "read-label", + "type": "text", + "x": 40, + "y": 310, + "width": 380, + "height": 25, + "angle": 0, + "strokeColor": "#e67700", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1014, + "version": 1, + "versionNonce": 1014, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "READ PATH (every minute — Cloud Scheduler)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "READ PATH (every minute — Cloud Scheduler)", + "lineHeight": 1.25 + }, + { + "id": "read-box", + "type": "rectangle", + "x": 30, + "y": 340, + "width": 1020, + "height": 320, + "angle": 0, + "strokeColor": "#e67700", + "backgroundColor": "#fff4e6", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1015, + "version": 1, + "versionNonce": 1015, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "scheduler-box", + "type": "rectangle", + "x": 50, + "y": 380, + "width": 150, + "height": 90, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1016, + "version": 1, + "versionNonce": 1016, + "isDeleted": false, + "boundElements": [ + { "id": "r-arrow1", "type": "arrow" }, + { "id": "scheduler-text", "type": "text" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "scheduler-text", + "type": "text", + "x": 58, + "y": 392, + "width": 134, + "height": 66, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1017, + "version": 1, + "versionNonce": 1017, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "⏰ Cloud\nScheduler\n(every 1 min)", + "fontSize": 15, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "scheduler-box", + "originalText": "⏰ Cloud\nScheduler\n(every 1 min)", + "lineHeight": 1.25 + }, + { + "id": "dispatch-box", + "type": "rectangle", + "x": 260, + "y": 370, + "width": 220, + "height": 110, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#fff3bf", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1018, + "version": 1, + "versionNonce": 1018, + "isDeleted": false, + "boundElements": [ + { "id": "r-arrow1", "type": "arrow" }, + { "id": "r-arrow2", "type": "arrow" }, + { "id": "r-arrow6", "type": "arrow" }, + { "id": "dispatch-text", "type": "text" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "dispatch-text", + "type": "text", + "x": 270, + "y": 382, + "width": 200, + "height": 86, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1019, + "version": 1, + "versionNonce": 1019, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "🧠 Dispatch Endpoint\nPOST /internal/\ndispatch-due-notifications\nX-Dispatch-Token", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "dispatch-box", + "originalText": "🧠 Dispatch Endpoint\nPOST /internal/\ndispatch-due-notifications\nX-Dispatch-Token", + "lineHeight": 1.25 + }, + { + "id": "pg-read-box", + "type": "rectangle", + "x": 530, + "y": 380, + "width": 200, + "height": 90, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#d3f9d8", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1020, + "version": 1, + "versionNonce": 1020, + "isDeleted": false, + "boundElements": [ + { "id": "r-arrow2", "type": "arrow" }, + { "id": "r-arrow3", "type": "arrow" }, + { "id": "pg-read-text", "type": "text" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "pg-read-text", + "type": "text", + "x": 540, + "y": 392, + "width": 180, + "height": 66, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1021, + "version": 1, + "versionNonce": 1021, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "🗄️ PostgreSQL\nSELECT due rows\nFOR UPDATE SKIP LOCKED", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "pg-read-box", + "originalText": "🗄️ PostgreSQL\nSELECT due rows\nFOR UPDATE SKIP LOCKED", + "lineHeight": 1.25 + }, + { + "id": "push-box", + "type": "rectangle", + "x": 780, + "y": 380, + "width": 170, + "height": 90, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1022, + "version": 1, + "versionNonce": 1022, + "isDeleted": false, + "boundElements": [ + { "id": "r-arrow3", "type": "arrow" }, + { "id": "r-arrow4", "type": "arrow" }, + { "id": "push-text", "type": "text" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "push-text", + "type": "text", + "x": 790, + "y": 392, + "width": 150, + "height": 66, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1023, + "version": 1, + "versionNonce": 1023, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "📤 FCM / APNs\n(fresh title\n+ body)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "push-box", + "originalText": "📤 FCM / APNs\n(fresh title\n+ body)", + "lineHeight": 1.25 + }, + { + "id": "phone-box", + "type": "rectangle", + "x": 990, + "y": 380, + "width": 140, + "height": 90, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1024, + "version": 1, + "versionNonce": 1024, + "isDeleted": false, + "boundElements": [ + { "id": "r-arrow4", "type": "arrow" }, + { "id": "r-arrow5", "type": "arrow" }, + { "id": "phone-text", "type": "text" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "phone-text", + "type": "text", + "x": 998, + "y": 392, + "width": 124, + "height": 66, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1025, + "version": 1, + "versionNonce": 1025, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "📱 Phone\nshows notif\n(app closed OK)", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "phone-box", + "originalText": "📱 Phone\nshows notif\n(app closed OK)", + "lineHeight": 1.25 + }, + { + "id": "r-arrow1", + "type": "arrow", + "x": 200, + "y": 425, + "width": 55, + "height": 0, + "angle": 0, + "strokeColor": "#e67700", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 2 }, + "seed": 1026, + "version": 1, + "versionNonce": 1026, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [[0, 0], [55, 0]], + "lastCommittedPoint": null, + "startBinding": { "elementId": "scheduler-box", "focus": 0, "gap": 5 }, + "endBinding": { "elementId": "dispatch-box", "focus": 0, "gap": 5 }, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "r-arrow2", + "type": "arrow", + "x": 480, + "y": 425, + "width": 45, + "height": 0, + "angle": 0, + "strokeColor": "#e67700", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 2 }, + "seed": 1027, + "version": 1, + "versionNonce": 1027, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [[0, 0], [45, 0]], + "lastCommittedPoint": null, + "startBinding": { "elementId": "dispatch-box", "focus": 0, "gap": 5 }, + "endBinding": { "elementId": "pg-read-box", "focus": 0, "gap": 5 }, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "r-arrow3", + "type": "arrow", + "x": 730, + "y": 425, + "width": 45, + "height": 0, + "angle": 0, + "strokeColor": "#e67700", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 2 }, + "seed": 1028, + "version": 1, + "versionNonce": 1028, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [[0, 0], [45, 0]], + "lastCommittedPoint": null, + "startBinding": { "elementId": "pg-read-box", "focus": 0, "gap": 5 }, + "endBinding": { "elementId": "push-box", "focus": 0, "gap": 5 }, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "r-arrow4", + "type": "arrow", + "x": 950, + "y": 425, + "width": 35, + "height": 0, + "angle": 0, + "strokeColor": "#e67700", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 2 }, + "seed": 1029, + "version": 1, + "versionNonce": 1029, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [[0, 0], [35, 0]], + "lastCommittedPoint": null, + "startBinding": { "elementId": "push-box", "focus": 0, "gap": 5 }, + "endBinding": { "elementId": "phone-box", "focus": 0, "gap": 5 }, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "loop-box", + "type": "rectangle", + "x": 260, + "y": 510, + "width": 470, + "height": 120, + "angle": 0, + "strokeColor": "#862e9c", + "backgroundColor": "#f3d9fa", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1030, + "version": 1, + "versionNonce": 1030, + "isDeleted": false, + "boundElements": [ + { "id": "loop-text", "type": "text" }, + { "id": "r-arrow6", "type": "arrow" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "loop-text", + "type": "text", + "x": 280, + "y": 525, + "width": 430, + "height": 90, + "angle": 0, + "strokeColor": "#862e9c", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1031, + "version": 1, + "versionNonce": 1031, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "For each due reminder:\n 1. Build notification text (fresh from server)\n 2. Send push via FCM (Android) or APNs (iOS)\n 3. Mark row status = sent\n 4. INSERT next occurrence into upcoming_reminders", + "fontSize": 15, + "fontFamily": 3, + "textAlign": "left", + "verticalAlign": "top", + "containerId": "loop-box", + "originalText": "For each due reminder:\n 1. Build notification text (fresh from server)\n 2. Send push via FCM (Android) or APNs (iOS)\n 3. Mark row status = sent\n 4. INSERT next occurrence into upcoming_reminders", + "lineHeight": 1.25 + }, + { + "id": "r-arrow6", + "type": "arrow", + "x": 370, + "y": 480, + "width": 0, + "height": 25, + "angle": 0, + "strokeColor": "#862e9c", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "dashed", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 2 }, + "seed": 1032, + "version": 1, + "versionNonce": 1032, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [[0, 0], [0, 25]], + "lastCommittedPoint": null, + "startBinding": { "elementId": "dispatch-box", "focus": 0, "gap": 5 }, + "endBinding": { "elementId": "loop-box", "focus": 0, "gap": 5 }, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "r-arrow5", + "type": "arrow", + "x": 1060, + "y": 470, + "width": 0, + "height": 55, + "angle": 0, + "strokeColor": "#2b8a3e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 2 }, + "seed": 1033, + "version": 1, + "versionNonce": 1033, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "points": [[0, 0], [0, 55]], + "lastCommittedPoint": null, + "startBinding": { "elementId": "phone-box", "focus": 0, "gap": 5 }, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "tap-text", + "type": "text", + "x": 980, + "y": 530, + "width": 160, + "height": 40, + "angle": 0, + "strokeColor": "#2b8a3e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1034, + "version": 1, + "versionNonce": 1034, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "User taps →\nopens app", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "top", + "containerId": null, + "originalText": "User taps →\nopens app", + "lineHeight": 1.25 + }, + { + "id": "empty-note", + "type": "text", + "x": 760, + "y": 520, + "width": 260, + "height": 60, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1035, + "version": 1, + "versionNonce": 1035, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "If nothing is due:\nquery returns empty →\nendpoint finishes (normal case)", + "fontSize": 13, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "If nothing is due:\nquery returns empty →\nendpoint finishes (normal case)", + "lineHeight": 1.25 + }, + { + "id": "legend-box", + "type": "rectangle", + "x": 30, + "y": 690, + "width": 1020, + "height": 80, + "angle": 0, + "strokeColor": "#868e96", + "backgroundColor": "#f8f9fa", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { "type": 3 }, + "seed": 1036, + "version": 1, + "versionNonce": 1036, + "isDeleted": false, + "boundElements": [ + { "id": "legend-text", "type": "text" } + ], + "updated": 1, + "link": null, + "locked": false + }, + { + "id": "legend-text", + "type": "text", + "x": 50, + "y": 705, + "width": 980, + "height": 50, + "angle": 0, + "strokeColor": "#495057", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1037, + "version": 1, + "versionNonce": 1037, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "Key idea: Cloud Scheduler is a dumb alarm clock — it only pings the backend. The backend owns all logic.\nPostgres is the source of truth (not Redis TTL). Content is generated fresh at send time, so the app never needs to run when closed.", + "fontSize": 14, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "middle", + "containerId": "legend-box", + "originalText": "Key idea: Cloud Scheduler is a dumb alarm clock — it only pings the backend. The backend owns all logic.\nPostgres is the source of truth (not Redis TTL). Content is generated fresh at send time, so the app never needs to run when closed.", + "lineHeight": 1.25 + } + ], + "appState": { + "gridSize": null, + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} diff --git a/migrations/env.py b/migrations/env.py index 51e3bba..4a07e25 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -6,9 +6,7 @@ from alembic import context from worker_api.db.database import Base -# Import models here as they are added when transferring endpoints -# Example: -# from worker_api.example.example_models import ExampleModel +from worker_api.notifications.models.reminder_models import UpcomingReminder # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/migrations/versions/001_create_upcoming_reminders.py b/migrations/versions/001_create_upcoming_reminders.py new file mode 100644 index 0000000..c34ba5b --- /dev/null +++ b/migrations/versions/001_create_upcoming_reminders.py @@ -0,0 +1,53 @@ +"""Create upcoming_reminders table + +Revision ID: 001_upcoming_reminders +Revises: +Create Date: 2026-06-22 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "001_upcoming_reminders" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "upcoming_reminders", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("plan_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("trigger_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("timezone", sa.String(length=64), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("device_token", sa.Text(), nullable=False), + sa.Column("platform", sa.String(length=16), nullable=False), + sa.Column("routine_config", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "idx_upcoming_reminders_due", + "upcoming_reminders", + ["trigger_at"], + unique=False, + postgresql_where=sa.text("status = 'pending'"), + ) + op.create_index( + "idx_upcoming_reminders_user_plan", + "upcoming_reminders", + ["user_id", "plan_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("idx_upcoming_reminders_user_plan", table_name="upcoming_reminders") + op.drop_index("idx_upcoming_reminders_due", table_name="upcoming_reminders") + op.drop_table("upcoming_reminders") diff --git a/poetry.lock b/poetry.lock index d8276ef..3732de5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "alembic" @@ -6,7 +6,6 @@ version = "1.18.4" description = "A database migration tool for SQLAlchemy." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"}, {file = "alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc"}, @@ -26,7 +25,6 @@ version = "0.0.4" description = "Document parameters, class attributes, return types, and variables inline, with Annotated." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, @@ -38,7 +36,6 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -50,7 +47,6 @@ version = "4.14.0" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] files = [ {file = "anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9"}, {file = "anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89"}, @@ -69,7 +65,6 @@ version = "26.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, @@ -81,7 +76,6 @@ version = "4.3.0" description = "Modern password hashing for your software and your servers" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281"}, {file = "bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb"}, @@ -146,7 +140,6 @@ version = "1.30.0" description = "Asynchronous Python ODM for MongoDB" optional = false python-versions = "<4.0,>=3.9" -groups = ["main"] files = [ {file = "beanie-1.30.0-py3-none-any.whl", hash = "sha256:385f1b850b36a19dd221aeb83e838c83ec6b47bbf6aeac4e5bf8b8d40bfcfe51"}, {file = "beanie-1.30.0.tar.gz", hash = "sha256:33ead17ff2742144c510b4b24e188f6b316dd1b614d86b57a3cfe20bc7b768c9"}, @@ -161,7 +154,7 @@ typing-extensions = ">=4.7" [package.extras] aws = ["motor[aws] (>=2.5.0,<4.0.0)"] -ci = ["requests", "tomli (>=2.2.1,<3.0.0) ; python_version < \"3.11\"", "tomli-w (>=1.0.0,<2.0.0)", "types-requests"] +ci = ["requests", "tomli (>=2.2.1,<3.0.0)", "tomli-w (>=1.0.0,<2.0.0)", "types-requests"] doc = ["Markdown (>=3.3)", "Pygments (>=2.8.0)", "jinja2 (>=3.0.3)", "mkdocs (>=1.4)", "mkdocs-material (>=9.0)", "pydoc-markdown (>=4.8)"] encryption = ["motor[encryption] (>=2.5.0,<4.0.0)"] gssapi = ["motor[gssapi] (>=2.5.0,<4.0.0)"] @@ -177,7 +170,6 @@ version = "4.15.0" description = "Screen-scraping library" optional = false python-versions = ">=3.7.0" -groups = ["main"] files = [ {file = "beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9"}, {file = "beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7"}, @@ -200,7 +192,6 @@ version = "1.43.34" description = "The AWS SDK for Python" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "boto3-1.43.34-py3-none-any.whl", hash = "sha256:42595057324606928c6e2432b3093978e4d722e0d432bce942f2a385702c0a43"}, {file = "boto3-1.43.34.tar.gz", hash = "sha256:444207c6c883d4df3ea3b2c36df43ad492b86e0b889eebd2fc1d5ea8db0a8a1a"}, @@ -220,7 +211,6 @@ version = "1.43.34" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "botocore-1.43.34-py3-none-any.whl", hash = "sha256:238a0269f33c5914b9343900b44767e783b3e8b6dcb6e065eac8b4495601c5df"}, {file = "botocore-1.43.34.tar.gz", hash = "sha256:ccc973cf30c6445b30afe5760f6dc949a80f1f862cb23d9c45747f2c814ece77"}, @@ -240,7 +230,6 @@ version = "0.0.2" description = "Dummy package for Beautiful Soup (beautifulsoup4)" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc"}, {file = "bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925"}, @@ -249,13 +238,32 @@ files = [ [package.dependencies] beautifulsoup4 = "*" +[[package]] +name = "cachecontrol" +version = "0.14.4" +description = "httplib2 caching for requests" +optional = false +python-versions = ">=3.10" +files = [ + {file = "cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b"}, + {file = "cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1"}, +] + +[package.dependencies] +msgpack = ">=0.5.2,<2.0.0" +requests = ">=2.16.0" + +[package.extras] +dev = ["cachecontrol[filecache,redis]", "cheroot (>=11.1.2)", "cherrypy", "codespell", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "types-redis", "types-requests"] +filecache = ["filelock (>=3.8.0)"] +redis = ["redis (>=2.10.5)"] + [[package]] name = "certifi" version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] files = [ {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, @@ -267,8 +275,6 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -365,7 +371,6 @@ version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, @@ -504,7 +509,6 @@ version = "8.4.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["main", "dev"] files = [ {file = "click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2"}, {file = "click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"}, @@ -519,8 +523,6 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -532,7 +534,6 @@ version = "7.14.2" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "coverage-7.14.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:59b75818e3046e9319143157f3dc4b43679a550c2060a17cbf3e39cc0b552925"}, {file = "coverage-7.14.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66b08ba4c5cbf0eaa2e9692b203073f198d5d469d8b15d1c7a4854ce7032b2e2"}, @@ -628,7 +629,7 @@ files = [ ] [package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +toml = ["tomli"] [[package]] name = "cryptography" @@ -636,7 +637,6 @@ version = "49.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.9" -groups = ["main"] files = [ {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, @@ -698,7 +698,6 @@ version = "1.9.0" description = "Distro - an OS platform information API" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, @@ -710,7 +709,6 @@ version = "2.8.0" description = "DNS toolkit" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, @@ -723,7 +721,7 @@ doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] doq = ["aioquic (>=1.2.0)"] idna = ["idna (>=3.10)"] trio = ["trio (>=0.30)"] -wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] +wmi = ["wmi (>=1.5.1)"] [[package]] name = "ecdsa" @@ -731,7 +729,6 @@ version = "0.19.2" description = "ECDSA cryptographic signature library (pure python)" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.6" -groups = ["main"] files = [ {file = "ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399"}, {file = "ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930"}, @@ -750,7 +747,6 @@ version = "9.4.2" description = "Transport classes and utilities shared among Python Elastic client libraries" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "elastic_transport-9.4.2-py3-none-any.whl", hash = "sha256:33dc89bb1855faa8b98ae8b036405a39c562778dbcdbe4a00a2eaf753148556c"}, {file = "elastic_transport-9.4.2.tar.gz", hash = "sha256:366f4614f4544c5fb5d780c82f57af8f30492b44a68ed20750390aa81e20c2ea"}, @@ -770,7 +766,6 @@ version = "9.4.1" description = "Python client for Elasticsearch" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "elasticsearch-9.4.1-py3-none-any.whl", hash = "sha256:71ab71c3d1b20fd88c2922fb82c3277cce7ea03c160686e7b9368b265c2b4cac"}, {file = "elasticsearch-9.4.1.tar.gz", hash = "sha256:1d78fdfba97a903ec35a5eb5808a74e33392b7c620bd5f742d465a3a26c27d75"}, @@ -785,7 +780,7 @@ typing-extensions = "*" [package.extras] async = ["aiohttp (>=3,<4)"] -dev = ["aiohttp", "anyio[trio]", "black", "build", "coverage", "httpx", "isort", "jinja2", "mapbox-vector-tile", "mypy", "nox", "numpy", "orjson", "pandas", "pyarrow ; python_version < \"3.14\"", "pydantic", "pyright", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "python-dateutil", "pyyaml (>=5.4)", "requests (>=2,<3)", "simsimd", "tqdm", "twine", "types-python-dateutil", "types-tqdm", "unasync"] +dev = ["aiohttp", "anyio[trio]", "black", "build", "coverage", "httpx", "isort", "jinja2", "mapbox-vector-tile", "mypy", "nox", "numpy", "orjson", "pandas", "pyarrow", "pydantic", "pyright", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "python-dateutil", "pyyaml (>=5.4)", "requests (>=2,<3)", "simsimd", "tqdm", "twine", "types-python-dateutil", "types-tqdm", "unasync"] docs = ["sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme (>=2.0)"] orjson = ["orjson (>=3)"] pyarrow = ["pyarrow (>=1)"] @@ -798,7 +793,6 @@ version = "0.115.14" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca"}, {file = "fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739"}, @@ -813,13 +807,66 @@ typing-extensions = ">=4.8.0" all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "firebase-admin" +version = "7.4.0" +description = "Firebase Admin Python SDK" +optional = false +python-versions = ">=3.9" +files = [ + {file = "firebase_admin-7.4.0-py3-none-any.whl", hash = "sha256:416967d1aacd30cfece2a5b0c606a52ba7614eb3093f054399be3868489cd7a8"}, + {file = "firebase_admin-7.4.0.tar.gz", hash = "sha256:08d7550efdba32fbd306141ce82150f9ffb91c1550e07aa4ed5757af4895d1ff"}, +] + +[package.dependencies] +cachecontrol = ">=0.14.3" +google-api-core = {version = ">=2.25.1,<3.0.0dev", extras = ["grpc"], markers = "platform_python_implementation != \"PyPy\""} +google-cloud-firestore = {version = ">=2.21.0", markers = "platform_python_implementation != \"PyPy\""} +google-cloud-storage = ">=3.1.1" +httpx = {version = "0.28.1", extras = ["http2"]} +pyjwt = {version = ">=2.10.1", extras = ["crypto"]} + +[[package]] +name = "google-api-core" +version = "2.31.0" +description = "Google API client core library" +optional = false +python-versions = ">=3.10" +files = [ + {file = "google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab"}, + {file = "google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.0" +googleapis-common-protos = ">=1.63.2,<2.0.0" +grpcio = [ + {version = ">=1.41.0,<2.0.0", optional = true, markers = "extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, + {version = ">=1.75.1,<2.0.0", optional = true, markers = "python_version >= \"3.14\" and extra == \"grpc\""}, +] +grpcio-status = [ + {version = ">=1.41.0,<2.0.0", optional = true, markers = "extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, + {version = ">=1.75.1,<2.0.0", optional = true, markers = "python_version >= \"3.14\" and extra == \"grpc\""}, +] +proto-plus = [ + {version = ">=1.24.0,<2.0.0", markers = "python_version < \"3.13\""}, + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, +] +protobuf = ">=5.29.6,<8.0.0" +requests = ">=2.33.0,<3.0.0" + +[package.extras] +async-rest = ["aiohttp (>=3.13.4)", "google-auth[aiohttp] (>=2.14.1,<3.0.0)"] +grpc = ["grpcio (>=1.41.0,<2.0.0)", "grpcio (>=1.49.1,<2.0.0)", "grpcio (>=1.75.1,<2.0.0)", "grpcio-status (>=1.41.0,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0)", "grpcio-status (>=1.75.1,<2.0.0)"] + [[package]] name = "google-auth" version = "2.55.0" description = "Google Authentication Library" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a"}, {file = "google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb"}, @@ -842,13 +889,122 @@ rsa = ["rsa (>=3.1.4,<5)"] testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.8.0,<4.0.0)", "aioresponses", "flask", "freezegun", "grpcio", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] urllib3 = ["packaging", "urllib3"] +[[package]] +name = "google-cloud-core" +version = "2.6.0" +description = "Google Cloud API client core library" +optional = false +python-versions = ">=3.10" +files = [ + {file = "google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e"}, + {file = "google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83"}, +] + +[package.dependencies] +google-api-core = ">=2.11.0,<3.0.0" +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" + +[package.extras] +grpc = ["grpcio (>=1.47.0,<2.0.0)", "grpcio (>=1.75.1,<2.0.0)", "grpcio-status (>=1.47.0,<2.0.0)"] + +[[package]] +name = "google-cloud-firestore" +version = "2.27.0" +description = "Google Cloud Firestore API client library" +optional = false +python-versions = ">=3.9" +files = [ + {file = "google_cloud_firestore-2.27.0-py3-none-any.whl", hash = "sha256:cc2ea78bc2d4dcc928016d56802deacfda3c9bbda0a7d691ee73b41a2f1a80d7"}, + {file = "google_cloud_firestore-2.27.0.tar.gz", hash = "sha256:5633cb164ef56ca6c73a807822191a56a98f6f10e76978c4f2eb197ae03383d2"}, +] + +[package.dependencies] +google-api-core = {version = ">=2.11.0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +google-cloud-core = ">=2.0.0,<3.0.0" +grpcio = [ + {version = ">=1.33.2,<2.0.0", markers = "python_version < \"3.14\""}, + {version = ">=1.75.1,<2.0.0", markers = "python_version >= \"3.14\""}, +] +proto-plus = [ + {version = ">=1.22.3,<2.0.0", markers = "python_version < \"3.13\""}, + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, +] +protobuf = ">=4.25.8,<8.0.0" + +[[package]] +name = "google-cloud-storage" +version = "3.12.0" +description = "Google Cloud Storage API client library" +optional = false +python-versions = ">=3.10" +files = [ + {file = "google_cloud_storage-3.12.0-py3-none-any.whl", hash = "sha256:3880773754ddf7c27567b04e2a4d193950b6b99429f37b9097d873686e95b09c"}, + {file = "google_cloud_storage-3.12.0.tar.gz", hash = "sha256:03ae9847c6babb368f35f054126b8a08cbc0e3266efb990eb17b9926a45cf3be"}, +] + +[package.dependencies] +google-api-core = ">=2.27.0,<3.0.0" +google-auth = ">=2.26.1,<3.0.0" +google-cloud-core = ">=2.4.2,<3.0.0" +google-crc32c = ">=1.6.0,<2.0.0" +google-resumable-media = ">=2.7.2,<3.0.0" +requests = ">=2.22.0,<3.0.0" + +[package.extras] +grpc = ["google-api-core[grpc] (>=2.27.0,<3.0.0)", "grpc-google-iam-v1 (>=0.14.0,<1.0.0)", "grpcio (>=1.59.0,<2.0.0)", "grpcio (>=1.75.1,<2.0.0)", "grpcio-status (>=1.59.0,<2.0.0)", "grpcio-status (>=1.75.1,<2.0.0)", "proto-plus (>=1.22.3,<2.0.0)", "proto-plus (>=1.25.0,<2.0.0)", "protobuf (>=4.25.8,<8.0.0)"] +protobuf = ["protobuf (>=3.20.2,<7.0.0)"] +testing = ["PyYAML", "black", "brotli", "coverage", "flake8", "google-cloud-iam", "google-cloud-kms", "google-cloud-pubsub", "google-cloud-testutils", "google-cloud-testutils", "mock", "numpy", "opentelemetry-sdk", "psutil", "py-cpuinfo", "pyopenssl", "pytest", "pytest-asyncio", "pytest-benchmark", "pytest-cov", "pytest-rerunfailures", "pytest-xdist"] +tracing = ["opentelemetry-api (>=1.1.0,<2.0.0)"] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +description = "A python wrapper of the C library 'Google CRC32C'" +optional = false +python-versions = ">=3.9" +files = [ + {file = "google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff"}, + {file = "google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288"}, + {file = "google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d"}, + {file = "google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092"}, + {file = "google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733"}, + {file = "google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8"}, + {file = "google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7"}, + {file = "google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15"}, + {file = "google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a"}, + {file = "google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2"}, + {file = "google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113"}, + {file = "google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb"}, + {file = "google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411"}, + {file = "google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454"}, + {file = "google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962"}, + {file = "google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b"}, + {file = "google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27"}, + {file = "google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa"}, + {file = "google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8"}, + {file = "google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f"}, + {file = "google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697"}, + {file = "google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651"}, + {file = "google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2"}, + {file = "google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21"}, + {file = "google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2"}, + {file = "google_crc32c-1.8.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:ba6aba18daf4d36ad4412feede6221414692f44d17e5428bdd81ad3fc1eee5dc"}, + {file = "google_crc32c-1.8.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:87b0072c4ecc9505cfa16ee734b00cd7721d20a0f595be4d40d3d21b41f65ae2"}, + {file = "google_crc32c-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d488e98b18809f5e322978d4506373599c0c13e6c5ad13e53bb44758e18d215"}, + {file = "google_crc32c-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01f126a5cfddc378290de52095e2c7052be2ba7656a9f0caf4bcd1bfb1833f8a"}, + {file = "google_crc32c-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:61f58b28e0b21fcb249a8247ad0db2e64114e201e2e9b4200af020f3b6242c9f"}, + {file = "google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93"}, + {file = "google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c"}, + {file = "google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79"}, +] + [[package]] name = "google-genai" version = "2.9.0" description = "GenAI Python SDK" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "google_genai-2.9.0-py3-none-any.whl", hash = "sha256:2a79e2b08e8439f5f25c2b42f98e3f3e8ea4be9c9265f5d7321580dbaf2764f4"}, {file = "google_genai-2.9.0.tar.gz", hash = "sha256:a8a10e9113f460cc668c1d9deeb62ba393ad1ba704bf3166d5a0f32a434f9415"}, @@ -871,14 +1027,47 @@ aiohttp = ["aiohttp (>=3.10.11,<4.0.0)"] local-tokenizer = ["pillow", "protobuf", "sentencepiece (>=0.2.0)", "torch", "torchvision", "transformers"] pyopenssl = ["pyopenssl"] +[[package]] +name = "google-resumable-media" +version = "2.10.0" +description = "Utilities for Google Media Downloads and Resumable Uploads" +optional = false +python-versions = ">=3.10" +files = [ + {file = "google_resumable_media-2.10.0-py3-none-any.whl", hash = "sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c"}, + {file = "google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee"}, +] + +[package.dependencies] +google-crc32c = ">=1.0.0,<2.0.0" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "google-auth (>=1.22.0,<2.0.0)"] +requests = ["requests (>=2.18.0,<3.0.0)"] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.9" +files = [ + {file = "googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed"}, + {file = "googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd"}, +] + +[package.dependencies] +protobuf = ">=4.25.8,<8.0.0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0)"] + [[package]] name = "greenlet" version = "3.5.2" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" files = [ {file = "greenlet-3.5.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9df9daae96848508450011d0d86ed7c95f8829a354ce438284a77b24896fd1f8"}, {file = "greenlet-3.5.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01e32e9d2b1714a2b06184cb3071ff2a2fd9bc7d065e39198ab21f7253dad421"}, @@ -965,25 +1154,131 @@ files = [ docs = ["Sphinx", "furo"] test = ["objgraph", "psutil", "setuptools"] +[[package]] +name = "grpcio" +version = "1.81.1" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.10" +files = [ + {file = "grpcio-1.81.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:6f9a0c9c1cc15c112d1c053064fd032b64917062292c3d70aea280e02ae10b77"}, + {file = "grpcio-1.81.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:69ef28e54fc85397f91b8c19592b8ef3d81952080366914823bd8572a2958120"}, + {file = "grpcio-1.81.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15641444eca4a29358107b3dceb74c1c6305c55c822fd199b458aaea4068a7fb"}, + {file = "grpcio-1.81.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d4b2dddfc219f54f956ccd53cf76a1d338ffe68fc7f2849ec9c7feb9927ff692"}, + {file = "grpcio-1.81.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca1cc11d82677b9662082e5478b7528e2b7db7beaa6bdff42bd62789d81be399"}, + {file = "grpcio-1.81.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa2ba7d2ad6df4d80127cea65e5b8d5e2c3adbf153ff4804452836328aca7c54"}, + {file = "grpcio-1.81.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:592b5fee597faa91cce2dd294dd7d9a1c83d76c4dbf877e33ec1adb866b2fbed"}, + {file = "grpcio-1.81.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62481553b1793a27e9b9c3cf9e5bd483ef045ca72462592074b46d42b0c4d9b9"}, + {file = "grpcio-1.81.1-cp310-cp310-win32.whl", hash = "sha256:bb693b1e3d9a2f3fd228e2110daf4b5aeedb36761ca1e4282f74725f6d89f611"}, + {file = "grpcio-1.81.1-cp310-cp310-win_amd64.whl", hash = "sha256:88268ca418cacea64cecb0d1d600d3c6b3a8038fcba02e1e205178c5b1f47661"}, + {file = "grpcio-1.81.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:d71d30f2d92f67d944631c523713934fee37292469e182ebcd2c1dd8a64ce53f"}, + {file = "grpcio-1.81.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b137f4bf3ada9dc44d411478decc6ff09a79ed30b306cd2abaa98408c3588137"}, + {file = "grpcio-1.81.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3acb384427816dd5d470f47e62137b87f74da694faa8a50147012cf40df276a"}, + {file = "grpcio-1.81.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f9a0ebbe45c29b5e5866593c12b78bd9035f0f0f0d4bc8361680cd580d99db49"}, + {file = "grpcio-1.81.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a37165cc80b1a368384b383e63a4c38116a10467ae44c904d2d7468c4470ec2"}, + {file = "grpcio-1.81.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6282caffb41ec326d4cb67ca9cf53b739d1b2f975a2acb498c7418e9f7d9a416"}, + {file = "grpcio-1.81.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a35009284d0d3d5c2c9601c164a911b8b4331608d98a9a66d47d97bb2f522b70"}, + {file = "grpcio-1.81.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1b22c80559854b789a01fd89e8929b3798a156c0829b5282a8939f33ad4115ad"}, + {file = "grpcio-1.81.1-cp311-cp311-win32.whl", hash = "sha256:428bec0161b48d8cf583c068591bc0016d0d9cfff52462b72b3884861ea768c5"}, + {file = "grpcio-1.81.1-cp311-cp311-win_amd64.whl", hash = "sha256:30e825f6848d9f18bba350ed6c75c1b02a0b5184474a31db9a32b1fa66fd8c79"}, + {file = "grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115"}, + {file = "grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3"}, + {file = "grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2"}, + {file = "grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7"}, + {file = "grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0"}, + {file = "grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3"}, + {file = "grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf"}, + {file = "grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c"}, + {file = "grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6"}, + {file = "grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94"}, + {file = "grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe"}, + {file = "grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e"}, + {file = "grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0"}, + {file = "grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14"}, + {file = "grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae"}, + {file = "grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5"}, + {file = "grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb"}, + {file = "grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7"}, + {file = "grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42"}, + {file = "grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60"}, + {file = "grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b"}, + {file = "grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e"}, + {file = "grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27"}, + {file = "grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854"}, + {file = "grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6"}, + {file = "grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5"}, + {file = "grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0"}, + {file = "grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190"}, + {file = "grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f"}, + {file = "grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821"}, + {file = "grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.81.1)"] + +[[package]] +name = "grpcio-status" +version = "1.81.1" +description = "Status proto mapping for gRPC" +optional = false +python-versions = ">=3.10" +files = [ + {file = "grpcio_status-1.81.1-py3-none-any.whl", hash = "sha256:08072fa9995f4a95c647fc6f4f85e2411573d00087bcabdf30f260114338f232"}, + {file = "grpcio_status-1.81.1.tar.gz", hash = "sha256:9389a03e746017b10f0630c064289201458f3ce01f5d7ef4b0bebc1ef6cf82ad"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.5.5" +grpcio = ">=1.81.1" +protobuf = ">=6.33.5,<8.0.0" + [[package]] name = "h11" version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "h2" +version = "4.3.0" +description = "Pure-Python HTTP/2 protocol implementation" +optional = false +python-versions = ">=3.9" +files = [ + {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, + {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, +] + +[package.dependencies] +hpack = ">=4.1,<5" +hyperframe = ">=6.1,<7" + +[[package]] +name = "hpack" +version = "4.1.0" +description = "Pure-Python HPACK header encoding" +optional = false +python-versions = ">=3.9" +files = [ + {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, + {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, +] + [[package]] name = "httpcore" version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -1005,7 +1300,6 @@ version = "0.8.0" description = "A collection of framework independent HTTP protocol utils." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "httptools-0.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826"}, {file = "httptools-0.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77"}, @@ -1065,7 +1359,6 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1074,23 +1367,34 @@ files = [ [package.dependencies] anyio = "*" certifi = "*" +h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "hyperframe" +version = "6.1.0" +description = "Pure-Python HTTP/2 framing" +optional = false +python-versions = ">=3.9" +files = [ + {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, + {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, +] + [[package]] name = "idna" version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] files = [ {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, @@ -1105,7 +1409,6 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, @@ -1117,7 +1420,6 @@ version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -1135,7 +1437,6 @@ version = "1.1.0" description = "JSON Matching Expressions" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, @@ -1147,7 +1448,6 @@ version = "0.2.0" description = "" optional = false python-versions = ">=3.7,<4.0" -groups = ["main"] files = [ {file = "lazy-model-0.2.0.tar.gz", hash = "sha256:57c0e91e171530c4fca7aebc3ac05a163a85cddd941bf7527cc46c0ddafca47c"}, {file = "lazy_model-0.2.0-py3-none-any.whl", hash = "sha256:5a3241775c253e36d9069d236be8378288a93d4fc53805211fd152e04cc9c342"}, @@ -1162,7 +1462,6 @@ version = "2.6.0" description = "Official mailtrap.io API client" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "mailtrap-2.6.0-py3-none-any.whl", hash = "sha256:764624917c5999790e42827cf03e4e3652008c5a9e3ab08e97f480a923dc0315"}, {file = "mailtrap-2.6.0.tar.gz", hash = "sha256:990b4f21c75f7a1cdfce6802b9a50ab31b9cdf6071402a15dda882fd0bb56857"}, @@ -1178,7 +1477,6 @@ version = "1.3.12" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9"}, {file = "mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a"}, @@ -1198,7 +1496,6 @@ version = "4.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, @@ -1222,7 +1519,6 @@ version = "3.0.3" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] files = [ {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -1321,7 +1617,6 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1333,7 +1628,6 @@ version = "3.7.1" description = "Non-blocking MongoDB driver for Tornado or asyncio" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298"}, {file = "motor-3.7.1.tar.gz", hash = "sha256:27b4d46625c87928f331a6ca9d7c51c2f518ba0e270939d395bc1ddc89d64526"}, @@ -1349,16 +1643,90 @@ encryption = ["pymongo[encryption] (>=4.5,<5)"] gssapi = ["pymongo[gssapi] (>=4.5,<5)"] ocsp = ["pymongo[ocsp] (>=4.5,<5)"] snappy = ["pymongo[snappy] (>=4.5,<5)"] -test = ["aiohttp (>=3.8.7)", "cffi (>=1.17.0rc1) ; python_version == \"3.13\"", "mockupdb", "pymongo[encryption] (>=4.5,<5)", "pytest (>=7)", "pytest-asyncio", "tornado (>=5)"] +test = ["aiohttp (>=3.8.7)", "cffi (>=1.17.0rc1)", "mockupdb", "pymongo[encryption] (>=4.5,<5)", "pytest (>=7)", "pytest-asyncio", "tornado (>=5)"] zstd = ["pymongo[zstd] (>=4.5,<5)"] +[[package]] +name = "msgpack" +version = "1.2.1" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.10" +files = [ + {file = "msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c"}, + {file = "msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895"}, + {file = "msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203"}, + {file = "msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73"}, + {file = "msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833"}, + {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8"}, + {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7"}, + {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce"}, + {file = "msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74"}, + {file = "msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb"}, + {file = "msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22"}, + {file = "msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5"}, + {file = "msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06"}, + {file = "msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4"}, + {file = "msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8"}, + {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b"}, + {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e"}, + {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f"}, + {file = "msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d"}, + {file = "msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8"}, + {file = "msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66"}, + {file = "msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35"}, + {file = "msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c"}, + {file = "msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0"}, + {file = "msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a"}, + {file = "msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6"}, + {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a"}, + {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1"}, + {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64"}, + {file = "msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac"}, + {file = "msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24"}, + {file = "msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07"}, + {file = "msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064"}, + {file = "msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056"}, + {file = "msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc"}, + {file = "msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d"}, + {file = "msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155"}, + {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402"}, + {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c"}, + {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6"}, + {file = "msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707"}, + {file = "msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9"}, + {file = "msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a"}, + {file = "msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d"}, + {file = "msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7"}, + {file = "msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889"}, + {file = "msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720"}, + {file = "msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190"}, + {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d"}, + {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24"}, + {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7"}, + {file = "msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb"}, + {file = "msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b"}, + {file = "msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7"}, + {file = "msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273"}, + {file = "msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1"}, + {file = "msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc"}, + {file = "msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde"}, + {file = "msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4"}, + {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d"}, + {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355"}, + {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c"}, + {file = "msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1"}, + {file = "msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2"}, + {file = "msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107"}, + {file = "msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647"}, +] + [[package]] name = "openapi-python-client" version = "0.28.4" description = "Generate modern Python clients from OpenAPI" optional = false python-versions = "<4.0,>=3.10" -groups = ["dev"] files = [ {file = "openapi_python_client-0.28.4-py3-none-any.whl", hash = "sha256:6bb87dbb05f88e5ab5ba4e5902f0c85ea53e666d3646be49ea2bb19bc8b5bb85"}, {file = "openapi_python_client-0.28.4.tar.gz", hash = "sha256:f78dfab5e21652806b17af07c16187b5911138fc526229126953c1b7b37d2b88"}, @@ -1382,7 +1750,6 @@ version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, @@ -1394,7 +1761,6 @@ version = "1.7.4" description = "comprehensive password hashing framework supporting over 30 schemes" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, @@ -1412,7 +1778,6 @@ version = "11.3.0" description = "Python Imaging Library (Fork)" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, @@ -1528,7 +1893,7 @@ fpx = ["olefile"] mic = ["olefile"] test-arrow = ["pyarrow"] tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] -typing = ["typing-extensions ; python_version < \"3.10\""] +typing = ["typing-extensions"] xmp = ["defusedxml"] [[package]] @@ -1537,7 +1902,6 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -1547,13 +1911,46 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "proto-plus" +version = "1.28.0" +description = "Beautiful, Pythonic protocol buffers" +optional = false +python-versions = ">=3.10" +files = [ + {file = "proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8"}, + {file = "proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9"}, +] + +[package.dependencies] +protobuf = ">=4.25.8,<8.0.0" + +[package.extras] +testing = ["google-api-core (>=1.31.5)"] + +[[package]] +name = "protobuf" +version = "7.35.1" +description = "" +optional = false +python-versions = ">=3.10" +files = [ + {file = "protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6"}, + {file = "protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799"}, + {file = "protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4"}, + {file = "protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4"}, + {file = "protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30"}, + {file = "protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87"}, + {file = "protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9"}, + {file = "protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a"}, +] + [[package]] name = "psycopg2-binary" version = "2.9.12" description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4"}, {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56"}, @@ -1630,7 +2027,6 @@ version = "0.6.3" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, @@ -1642,7 +2038,6 @@ version = "0.4.2" description = "A collection of ASN.1-based protocols modules" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, @@ -1657,8 +2052,6 @@ version = "3.0" description = "C parser in Python" optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, @@ -1670,7 +2063,6 @@ version = "2.13.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] files = [ {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, @@ -1684,7 +2076,7 @@ typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] +timezone = ["tzdata"] [[package]] name = "pydantic-core" @@ -1692,7 +2084,6 @@ version = "2.46.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] files = [ {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, @@ -1825,7 +2216,6 @@ version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, @@ -1840,12 +2230,14 @@ version = "2.13.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, ] +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + [package.extras] crypto = ["cryptography (>=3.4.0)"] @@ -1855,7 +2247,6 @@ version = "4.17.0" description = "PyMongo - the Official MongoDB Python driver" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "pymongo-4.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:47b021363cd923ace5edc7a1d63c0ff8a6d9d43859b8a1ba23645f5afae63221"}, {file = "pymongo-4.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:422fa50d7d7f5c22ea0953554396c9ef95684a2d775f860bd75a7b510538dfca"}, @@ -1936,12 +2327,12 @@ dnspython = ">=2.6.1,<3.0.0" [package.extras] aws = ["pymongo-auth-aws (>=1.1.0,<2.0.0)"] docs = ["furo (==2025.12.19)", "readthedocs-sphinx-search (>=0.3,<1.0)", "sphinx (>=5.3,<9)", "sphinx-autobuild (>=2020.9.1)", "sphinx-rtd-theme (>=2,<4)", "sphinxcontrib-shellcheck (>=1,<2)"] -encryption = ["certifi (>=2023.7.22) ; os_name == \"nt\" or sys_platform == \"darwin\"", "pymongo-auth-aws (>=1.1.0,<2.0.0)", "pymongocrypt (>=1.13.0,<2.0.0)"] -gssapi = ["pykerberos (>=1.2.4) ; os_name != \"nt\"", "winkerberos (>=0.5.0) ; os_name == \"nt\""] -ocsp = ["certifi (>=2023.7.22) ; os_name == \"nt\" or sys_platform == \"darwin\"", "cryptography (>=42.0.0)", "pyopenssl (>=23.2.0)", "requests (>=2.23.0,<3.0)", "service-identity (>=23.1.0)"] +encryption = ["certifi (>=2023.7.22)", "pymongo-auth-aws (>=1.1.0,<2.0.0)", "pymongocrypt (>=1.13.0,<2.0.0)"] +gssapi = ["pykerberos (>=1.2.4)", "winkerberos (>=0.5.0)"] +ocsp = ["certifi (>=2023.7.22)", "cryptography (>=42.0.0)", "pyopenssl (>=23.2.0)", "requests (>=2.23.0,<3.0)", "service-identity (>=23.1.0)"] snappy = ["python-snappy (>=0.6.0)"] -test = ["importlib-metadata (>=7.0) ; python_version < \"3.13\"", "pytest (>=8.2)", "pytest-asyncio (>=0.24.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +test = ["importlib-metadata (>=7.0)", "pytest (>=8.2)", "pytest-asyncio (>=0.24.0)"] +zstd = ["backports-zstd (>=1.0.0)"] [[package]] name = "pytest" @@ -1949,7 +2340,6 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -1971,7 +2361,6 @@ version = "0.25.3" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, @@ -1990,7 +2379,6 @@ version = "6.3.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749"}, {file = "pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2"}, @@ -2010,7 +2398,6 @@ version = "3.15.1" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, @@ -2028,7 +2415,6 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2043,7 +2429,6 @@ version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, @@ -2058,7 +2443,6 @@ version = "3.5.0" description = "JOSE implementation in Python" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771"}, {file = "python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b"}, @@ -2081,7 +2465,6 @@ version = "0.0.20" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, @@ -2093,7 +2476,6 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -2176,7 +2558,6 @@ version = "6.4.0" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f"}, {file = "redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010"}, @@ -2193,7 +2574,6 @@ version = "2.34.2" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, @@ -2215,7 +2595,6 @@ version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.9.0" -groups = ["dev"] files = [ {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, @@ -2234,7 +2613,6 @@ version = "4.9.1" description = "Pure-Python RSA implementation" optional = false python-versions = "<4,>=3.6" -groups = ["main"] files = [ {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, @@ -2249,7 +2627,6 @@ version = "0.19.1" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93"}, {file = "ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993"}, @@ -2258,8 +2635,8 @@ files = [ [package.extras] docs = ["mercurial (>5.7)", "ryd"] jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] -libyaml = ["ruamel.yaml.clibz (>=0.3.7) ; platform_python_implementation == \"CPython\""] -oldlibyaml = ["ruamel.yaml.clib ; platform_python_implementation == \"CPython\""] +libyaml = ["ruamel.yaml.clibz (>=0.3.7)"] +oldlibyaml = ["ruamel.yaml.clib"] [[package]] name = "ruff" @@ -2267,7 +2644,6 @@ version = "0.15.18" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df"}, {file = "ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2"}, @@ -2295,7 +2671,6 @@ version = "0.19.0" description = "An Amazon S3 Transfer Manager" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262"}, {file = "s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685"}, @@ -2313,7 +2688,6 @@ version = "1.5.4" description = "Tool to Detect Surrounding Shell" optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, @@ -2325,7 +2699,6 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -2337,7 +2710,6 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -2349,7 +2721,6 @@ version = "2.8.4" description = "A modern CSS selector implementation for Beautiful Soup." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65"}, {file = "soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e"}, @@ -2361,7 +2732,6 @@ version = "2.0.51" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0"}, {file = "sqlalchemy-2.0.51-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652"}, @@ -2424,7 +2794,7 @@ files = [ ] [package.dependencies] -greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +greenlet = {version = ">=1", markers = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\""} typing-extensions = ">=4.6.0" [package.extras] @@ -2458,7 +2828,6 @@ version = "0.41.3" description = "The little ASGI library that shines." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7"}, {file = "starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835"}, @@ -2476,7 +2845,6 @@ version = "9.1.4" description = "Retry code until it succeeds" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, @@ -2492,7 +2860,6 @@ version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["main"] files = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, @@ -2504,7 +2871,6 @@ version = "0.25.1" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89"}, {file = "typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc"}, @@ -2522,7 +2888,6 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -2534,7 +2899,6 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -2543,23 +2907,33 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2025.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, +] + [[package]] name = "urllib3" version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +zstd = ["backports-zstd (>=1.0.0)"] [[package]] name = "uvicorn" @@ -2567,7 +2941,6 @@ version = "0.32.1" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "uvicorn-0.32.1-py3-none-any.whl", hash = "sha256:82ad92fd58da0d12af7482ecdb5f2470a04c9c9a53ced65b9bbb4a205377602e"}, {file = "uvicorn-0.32.1.tar.gz", hash = "sha256:ee9519c246a72b1c084cea8d3b44ed6026e78a4a309cbedae9c37e4cb9fbb175"}, @@ -2580,12 +2953,12 @@ h11 = ">=0.8" httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} -uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} [package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] name = "uvloop" @@ -2593,8 +2966,6 @@ version = "0.22.1" description = "Fast implementation of asyncio event loop on top of libuv" optional = false python-versions = ">=3.8.1" -groups = ["main"] -markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" files = [ {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, @@ -2658,7 +3029,6 @@ version = "1.2.0" description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9"}, {file = "watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4"}, @@ -2778,7 +3148,6 @@ version = "16.0" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, @@ -2844,6 +3213,6 @@ files = [ ] [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.12" -content-hash = "e8fc428244abcebffa8cf9cb536b4a66a1c643a1c4cbdc32bef0ece6bcf6b521" +content-hash = "7b5848a6597b08ca37f36451836d76b42124a52e9ae44c58da1bad3dde6c63c3" diff --git a/pyproject.toml b/pyproject.toml index 2e14b0e..a4bd952 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,9 @@ python-dotenv = "^1.0.1" python-jose = "^3.3.0" sqlalchemy = "^2.0.36" starlette = "^0.41.3" -httpx = "^0.28.1" +httpx = {version = "^0.28.1", extras = ["http2"]} +google-auth = "^2.36.0" +tzdata = "^2025.1" jinja2 = "^3.1.4" boto3 = "^1.35.97" python-multipart = "^0.0.20" @@ -38,6 +40,7 @@ toml = "^0.10.2" mailtrap = "^2.3.0" google-genai = "^2.0" +firebase-admin = "^7.4.0" [tool.poetry.scripts] start = "uvicorn:main" diff --git a/scripts/check_notification_data.py b/scripts/check_notification_data.py new file mode 100644 index 0000000..d7024c3 --- /dev/null +++ b/scripts/check_notification_data.py @@ -0,0 +1,40 @@ +from worker_api.config import get +from worker_api.db.database import SessionLocal +from sqlalchemy import text + +print("DATABASE_URL:", get("DATABASE_URL")[:50]) + +with SessionLocal() as db: + queries = { + "pending_reminders": "SELECT COUNT(*) FROM upcoming_reminders WHERE status='pending'", + "timeblocks": "SELECT COUNT(*) FROM routine_time_blocks WHERE deleted_at IS NULL AND notification_enabled", + "push_devices": "SELECT COUNT(*) FROM push_device_tokens WHERE is_active", + "sessions": "SELECT COUNT(*) FROM routine_sessions", + } + for label, query in queries.items(): + try: + with SessionLocal() as session: + count = session.execute(text(query)).scalar() + print(f"{label}: {count}") + except Exception as exc: + print(f"{label}: ERROR {exc}") + + try: + with SessionLocal() as session: + rows = session.execute( + text( + """ + SELECT rtb.time, rtb.created_at, rs.session_type, rs.source_id, r.user_id + FROM routine_time_blocks rtb + JOIN routines r ON r.id = rtb.routine_id + JOIN routine_sessions rs ON rs.time_block_id = rtb.id + WHERE rtb.deleted_at IS NULL AND rtb.notification_enabled AND r.deleted_at IS NULL + LIMIT 5 + """ + ) + ).all() + print("sample_timeblocks:", len(rows)) + for row in rows: + print(" ", row) + except Exception as exc: + print("sample_timeblocks: ERROR", exc) diff --git a/scripts/trigger_test_notification.py b/scripts/trigger_test_notification.py new file mode 100644 index 0000000..4cb7c33 --- /dev/null +++ b/scripts/trigger_test_notification.py @@ -0,0 +1,223 @@ +"""Trigger a test push notification via the routine-notification-targets flow. + +Usage: + poetry run python scripts/trigger_test_notification.py + poetry run python scripts/trigger_test_notification.py --send + +Set TEST_FCM_DEVICE_TOKEN in .env to a real FCM token to deliver a push. +Without --send, only prints matched targets. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from uuid import UUID + +from dotenv import load_dotenv +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +load_dotenv(ROOT / ".env") + +from worker_api.db.database import SessionLocal +from worker_api.notifications.services.push.fcm_client import send_fcm_notification +from worker_api.notifications.services.routine_notification_service import ( + get_routine_notification_targets, +) + + +def _align_test_timeblock() -> UUID | None: + """Set one enabled timeblock to the current local minute for its stored offset.""" + utc_now = datetime.now(timezone.utc) + with SessionLocal() as db: + row = db.execute( + text( + """ + SELECT rtb.id, rtb.created_at, r.user_id + FROM routine_time_blocks rtb + JOIN routines r ON r.id = rtb.routine_id + WHERE rtb.deleted_at IS NULL + AND rtb.notification_enabled + AND r.deleted_at IS NULL + ORDER BY rtb.created_at DESC + LIMIT 1 + """ + ) + ).first() + if row is None: + print("No routine timeblocks found to align.") + return None + + created_at = row.created_at + offset = created_at.utcoffset() or timezone.utc.utcoffset(created_at) + local_now = utc_now + offset + hhmm = local_now.strftime("%H:%M") + db.execute( + text("UPDATE routine_time_blocks SET time = :time WHERE id = :id"), + {"time": hhmm, "id": row.id}, + ) + db.commit() + print(f"Aligned timeblock {row.id} to {hhmm} for user {row.user_id}") + return row.user_id + + +def _ensure_test_push_device(token: str, user_id: UUID | None = None) -> UUID | None: + with SessionLocal() as db: + if user_id is None: + user_row = db.execute( + text( + """ + SELECT r.user_id + FROM routines r + WHERE r.deleted_at IS NULL + LIMIT 1 + """ + ) + ).first() + if user_row is None: + print("No routine users found.") + return None + user_id = user_row.user_id + existing = db.execute( + text( + """ + SELECT id FROM push_device_tokens + WHERE user_id = :user_id AND is_active = true + LIMIT 1 + """ + ), + {"user_id": user_id}, + ).first() + + if existing: + db.execute( + text( + """ + UPDATE push_device_tokens + SET token = :token, platform = 'ANDROID', updated_at = NOW() + WHERE id = :id + """ + ), + {"token": token, "id": existing.id}, + ) + db.commit() + print(f"Updated push device for user {user_id}") + return user_id + + token_owner = db.execute( + text("SELECT id, user_id FROM push_device_tokens WHERE token = :token LIMIT 1"), + {"token": token}, + ).first() + if token_owner: + db.execute( + text( + """ + UPDATE push_device_tokens + SET user_id = :user_id, platform = 'ANDROID', is_active = true, updated_at = NOW() + WHERE id = :id + """ + ), + {"user_id": user_id, "id": token_owner.id}, + ) + db.commit() + print(f"Reassigned existing token device to user {user_id}") + return user_id + + device_id = db.execute( + text( + """ + INSERT INTO push_device_tokens ( + id, user_id, token, platform, device_id, is_active, created_at, updated_at + ) + VALUES ( + gen_random_uuid(), :user_id, :token, 'ANDROID', 'test-device', true, NOW(), NOW() + ) + RETURNING id + """ + ), + {"user_id": user_id, "token": token}, + ).scalar_one() + db.commit() + print(f"Inserted test push device {device_id} for user {user_id}") + return user_id + + +async def _send_targets(send: bool) -> None: + targets = get_routine_notification_targets() + print(f"generated_at={targets.generated_at.isoformat()}") + print(f"matched_time_utc={targets.matched_time_utc}") + print(f"groups={len(targets.groups)}") + + if not targets.groups: + print("No notification targets matched right now.") + return + + for group in targets.groups: + print( + f"- {group.session_type} source_id={group.source_id} " + f"users={len(group.users)} image={group.source_image_url}" + ) + for user in group.users: + notification = user.notification + print( + f" user={user.user_id} title={notification.title!r} " + f"devices={len(user.push_devices)}" + ) + if not send: + continue + + for device in user.push_devices: + await send_fcm_notification( + device_token=device.token, + title=notification.title, + body=notification.body, + ) + print(f" sent to {device.platform} token={device.token[:12]}...") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Trigger a test routine notification") + parser.add_argument( + "--send", + action="store_true", + help="Send FCM push for matched targets (requires TEST_FCM_DEVICE_TOKEN)", + ) + parser.add_argument( + "--align-timeblock", + action="store_true", + default=True, + help="Align one routine timeblock to the current minute (default: true)", + ) + parser.add_argument( + "--no-align-timeblock", + action="store_false", + dest="align_timeblock", + help="Skip aligning a routine timeblock", + ) + args = parser.parse_args() + + token = os.getenv("TEST_FCM_DEVICE_TOKEN", "").strip() + if args.send and not token: + print("Set TEST_FCM_DEVICE_TOKEN in .env before using --send.") + sys.exit(1) + + if args.align_timeblock: + aligned_user_id = _align_test_timeblock() + else: + aligned_user_id = None + + device_token = token or "test-token-placeholder" + if token or not args.send: + _ensure_test_push_device(device_token, user_id=aligned_user_id) + + asyncio.run(_send_targets(send=args.send)) + + +if __name__ == "__main__": + main() diff --git a/tests/notifications/__init__.py b/tests/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/notifications/test_notification_content_service.py b/tests/notifications/test_notification_content_service.py new file mode 100644 index 0000000..ba2dc76 --- /dev/null +++ b/tests/notifications/test_notification_content_service.py @@ -0,0 +1,54 @@ +from unittest.mock import patch +from uuid import uuid4 + +from worker_api.config import DEFAULTS +from worker_api.notifications.models.reminder_models import UpcomingReminder +from worker_api.notifications.services.notification_content_service import build_notification_content + + +class TestBuildNotificationContent: + def test_default_body(self): + reminder = UpcomingReminder( + id=uuid4(), + user_id=uuid4(), + plan_id=uuid4(), + trigger_at=None, + timezone="UTC", + status="pending", + device_token="token", + platform="android", + routine_config={}, + ) + title, body = build_notification_content(reminder) + assert title == DEFAULTS["NOTIFICATION_DEFAULT_TITLE"] + assert body == DEFAULTS["NOTIFICATION_DEFAULT_BODY"] + + def test_plan_name_template(self): + reminder = UpcomingReminder( + id=uuid4(), + user_id=uuid4(), + plan_id=uuid4(), + trigger_at=None, + timezone="UTC", + status="pending", + device_token="token", + platform="android", + routine_config={"plan_name": "Morning Meditation"}, + ) + _, body = build_notification_content(reminder) + assert body == "Time for Morning Meditation" + + def test_custom_message_template(self): + reminder = UpcomingReminder( + id=uuid4(), + user_id=uuid4(), + plan_id=uuid4(), + trigger_at=None, + timezone="UTC", + status="pending", + device_token="token", + platform="android", + routine_config={"message_template": "Custom reminder text"}, + ) + _, body = build_notification_content(reminder) + assert body == "Custom reminder text" diff --git a/tests/notifications/test_notification_views.py b/tests/notifications/test_notification_views.py new file mode 100644 index 0000000..59170be --- /dev/null +++ b/tests/notifications/test_notification_views.py @@ -0,0 +1,237 @@ +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest + + +class TestDispatchEndpoint: + def test_dispatch_requires_token(self, client): + response = client.post("/api/v1/internal/dispatch-due-notifications") + assert response.status_code == 422 + + def test_dispatch_rejects_invalid_token(self, client, monkeypatch): + monkeypatch.setenv("NOTIFICATION_DISPATCH_SECRET_TOKEN", "secret-token") + response = client.post( + "/api/v1/internal/dispatch-due-notifications", + headers={"X-Dispatch-Token": "wrong-token"}, + ) + assert response.status_code == 401 + + @patch( + "worker_api.notifications.internal_views.dispatch_due_notifications_service", + new_callable=AsyncMock, + ) + def test_dispatch_success(self, mock_dispatch, client, monkeypatch): + monkeypatch.setenv("NOTIFICATION_DISPATCH_SECRET_TOKEN", "secret-token") + from worker_api.notifications.schemas import DispatchDueNotificationsResponse + + mock_dispatch.return_value = DispatchDueNotificationsResponse( + processed=2, + sent=2, + failed=0, + skipped=0, + ) + + response = client.post( + "/api/v1/internal/dispatch-due-notifications", + headers={"X-Dispatch-Token": "secret-token"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "processed": 2, + "sent": 2, + "failed": 0, + "skipped": 0, + } + + +class TestRoutineNotificationTargetsEndpoint: + def test_routine_notification_targets_requires_token(self, client): + response = client.get("/api/v1/internal/routine-notification-targets") + assert response.status_code == 422 + + def test_routine_notification_targets_rejects_invalid_token(self, client, monkeypatch): + monkeypatch.setenv("NOTIFICATION_DISPATCH_SECRET_TOKEN", "secret-token") + response = client.get( + "/api/v1/internal/routine-notification-targets", + headers={"X-Dispatch-Token": "wrong-token"}, + ) + assert response.status_code == 401 + + @patch( + "worker_api.notifications.internal_views.get_routine_notification_targets", + ) + def test_routine_notification_targets_success(self, mock_get_targets, client, monkeypatch): + monkeypatch.setenv("NOTIFICATION_DISPATCH_SECRET_TOKEN", "secret-token") + from datetime import datetime, timezone + from uuid import uuid4 + + from worker_api.notifications.schemas import ( + NotificationContent, + PushDeviceTarget, + RoutineNotificationGroup, + RoutineNotificationTargetsResponse, + RoutineNotificationUserTarget, + ) + + user_id = uuid4() + plan_id = uuid4() + mock_get_targets.return_value = RoutineNotificationTargetsResponse( + generated_at=datetime(2026, 6, 23, 10, 30, tzinfo=timezone.utc), + matched_time_utc="10:30", + groups=[ + RoutineNotificationGroup( + session_type="PLAN", + source_id=plan_id, + source_image_url="https://example.com/plan.png", + users=[ + RoutineNotificationUserTarget( + user_id=user_id, + notification=NotificationContent( + title="Day 1", + body="Begin practice.", + custom_image_url=None, + ), + push_devices=[ + PushDeviceTarget(token="fcm-token", platform="android"), + ], + ) + ], + ) + ], + ) + + response = client.get( + "/api/v1/internal/routine-notification-targets", + headers={"X-Dispatch-Token": "secret-token"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["matched_time_utc"] == "10:30" + assert payload["groups"][0]["session_type"] == "PLAN" + assert payload["groups"][0]["users"][0]["push_devices"][0]["token"] == "fcm-token" + mock_get_targets.assert_called_once() + + +class TestDispatchRoutineNotificationsEndpoint: + def test_dispatch_routine_notifications_requires_token(self, client): + response = client.post("/api/v1/internal/dispatch-routine-notifications") + assert response.status_code == 422 + + def test_dispatch_routine_notifications_rejects_invalid_token(self, client, monkeypatch): + monkeypatch.setenv("NOTIFICATION_DISPATCH_SECRET_TOKEN", "secret-token") + response = client.post( + "/api/v1/internal/dispatch-routine-notifications", + headers={"X-Dispatch-Token": "wrong-token"}, + ) + assert response.status_code == 401 + + @patch( + "worker_api.notifications.internal_views.dispatch_routine_notifications_service", + new_callable=AsyncMock, + ) + def test_dispatch_routine_notifications_success(self, mock_dispatch, client, monkeypatch): + monkeypatch.setenv("NOTIFICATION_DISPATCH_SECRET_TOKEN", "secret-token") + from datetime import datetime, timezone + from uuid import uuid4 + + from worker_api.notifications.schemas import ( + DispatchRoutineNotificationsResponse, + NotificationContent, + PushDeviceTarget, + RoutineNotificationGroup, + RoutineNotificationUserTarget, + ) + + user_id = uuid4() + plan_id = uuid4() + mock_dispatch.return_value = DispatchRoutineNotificationsResponse( + generated_at=datetime(2026, 6, 23, 10, 30, tzinfo=timezone.utc), + matched_time_utc="10:30", + groups=[ + RoutineNotificationGroup( + session_type="PLAN", + source_id=plan_id, + source_image_url="https://example.com/plan.png", + users=[ + RoutineNotificationUserTarget( + user_id=user_id, + notification=NotificationContent( + title="Day 1", + body="Begin practice.", + custom_image_url=None, + ), + push_devices=[ + PushDeviceTarget(token="fcm-token", platform="android"), + ], + ) + ], + ) + ], + processed=1, + sent=1, + failed=0, + skipped=0, + ) + + response = client.post( + "/api/v1/internal/dispatch-routine-notifications", + headers={"X-Dispatch-Token": "secret-token"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["sent"] == 1 + assert payload["processed"] == 1 + assert payload["groups"][0]["session_type"] == "PLAN" + mock_dispatch.assert_called_once() + + +class TestReminderEndpoints: + @patch("worker_api.notifications.reminder_views.enroll_reminder_service") + def test_enroll_reminder(self, mock_enroll, client): + reminder_id = uuid4() + user_id = uuid4() + plan_id = uuid4() + + mock_enroll.return_value = { + "id": reminder_id, + "user_id": user_id, + "plan_id": plan_id, + "trigger_at": "2026-06-22T09:00:00Z", + "timezone": "UTC", + "status": "pending", + "platform": "android", + "routine_config": {"times": ["09:00"]}, + } + + response = client.post( + "/api/v1/notifications/reminders", + json={ + "user_id": str(user_id), + "plan_id": str(plan_id), + "timezone": "UTC", + "device_token": "fcm-token", + "platform": "android", + "routine": {"times": ["09:00"]}, + }, + ) + + assert response.status_code == 201 + mock_enroll.assert_called_once() + + def test_enroll_invalid_time(self, client): + response = client.post( + "/api/v1/notifications/reminders", + json={ + "user_id": str(uuid4()), + "plan_id": str(uuid4()), + "timezone": "UTC", + "device_token": "fcm-token", + "platform": "android", + "routine": {"times": ["25:99"]}, + }, + ) + assert response.status_code == 422 diff --git a/tests/notifications/test_reminder_schedule_service.py b/tests/notifications/test_reminder_schedule_service.py new file mode 100644 index 0000000..a578864 --- /dev/null +++ b/tests/notifications/test_reminder_schedule_service.py @@ -0,0 +1,32 @@ +from datetime import datetime, timezone + +import pytest + +from worker_api.notifications.services.reminder_schedule_service import compute_next_trigger_at + + +class TestComputeNextTriggerAt: + def test_returns_next_time_today(self): + routine = {"times": ["09:00", "18:00"]} + after = datetime(2026, 6, 22, 8, 0, tzinfo=timezone.utc) + result = compute_next_trigger_at(routine, "UTC", after=after) + assert result == datetime(2026, 6, 22, 9, 0, tzinfo=timezone.utc) + + def test_returns_next_day_when_all_times_passed(self): + routine = {"times": ["09:00"]} + after = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc) + result = compute_next_trigger_at(routine, "UTC", after=after) + assert result == datetime(2026, 6, 23, 9, 0, tzinfo=timezone.utc) + + def test_respects_days_of_week(self): + routine = {"times": ["09:00"], "days_of_week": [0]} + # 2026-06-22 is Monday + after = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc) + result = compute_next_trigger_at(routine, "UTC", after=after) + assert result == datetime(2026, 6, 29, 9, 0, tzinfo=timezone.utc) + + def test_invalid_timezone_raises(self): + routine = {"times": ["09:00"]} + with pytest.raises(Exception) as exc_info: + compute_next_trigger_at(routine, "Not/A_Timezone") + assert exc_info.value.status_code == 400 diff --git a/tests/notifications/test_routine_notification_service.py b/tests/notifications/test_routine_notification_service.py new file mode 100644 index 0000000..eda2df9 --- /dev/null +++ b/tests/notifications/test_routine_notification_service.py @@ -0,0 +1,63 @@ +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +from worker_api.notifications.repositories.routine_notification_repository import ( + RoutineNotificationRow, +) +from worker_api.notifications.services.routine_notification_service import ( + _compute_current_day_number, + _filter_by_timezone, + _get_matching_hhmm_window, + _local_time_matches, +) + + +def test_get_matching_hhmm_window_includes_adjacent_minutes(): + utc_now = datetime(2026, 6, 23, 10, 30, tzinfo=timezone.utc) + values = _get_matching_hhmm_window(utc_now) + assert values == ["10:29", "10:30", "10:31"] + + +def test_local_time_matches_using_created_at_offset(): + created_at = datetime(2026, 1, 1, 8, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) + utc_now = datetime(2026, 6, 23, 4, 0, tzinfo=timezone.utc) + + assert _local_time_matches(created_at, "09:30", utc_now) is True + assert _local_time_matches(created_at, "10:30", utc_now) is False + + +def test_filter_by_timezone_deduplicates_rows(): + user_id = uuid4() + time_block_id = uuid4() + created_at = datetime(2026, 1, 1, 8, 0, tzinfo=timezone(timedelta(hours=5, minutes=30))) + utc_now = datetime(2026, 6, 23, 4, 0, tzinfo=timezone.utc) + source_id = uuid4() + + row = RoutineNotificationRow( + user_id=user_id, + time_block_id=time_block_id, + time_block_time="09:30", + time_block_created_at=created_at, + session_type="PLAN", + source_id=source_id, + device_token="token-1", + platform="android", + ) + duplicate = RoutineNotificationRow( + user_id=user_id, + time_block_id=time_block_id, + time_block_time="09:30", + time_block_created_at=created_at, + session_type="PLAN", + source_id=source_id, + device_token="token-1", + platform="android", + ) + + filtered = _filter_by_timezone([row, duplicate], utc_now) + assert len(filtered) == 1 + + +def test_compute_current_day_number_defaults_to_one_without_progress(): + utc_now = datetime(2026, 6, 23, 10, 0, tzinfo=timezone.utc) + assert _compute_current_day_number(None, utc_now) == 1 diff --git a/worker_api/app.py b/worker_api/app.py index 3787d99..2d4a8a7 100644 --- a/worker_api/app.py +++ b/worker_api/app.py @@ -6,6 +6,8 @@ from worker_api.db.mongo_database import lifespan from worker_api.audio.audio_views import audio_router from worker_api.llm.llm_views import llm_router +from worker_api.notifications.internal_views import internal_router +from worker_api.notifications.reminder_views import reminder_router import uvicorn @@ -19,6 +21,8 @@ api.include_router(audio_router) api.include_router(llm_router) +api.include_router(internal_router) +api.include_router(reminder_router) api.add_middleware( CORSMiddleware, diff --git a/worker_api/config.py b/worker_api/config.py index 08eb3db..c574a4a 100644 --- a/worker_api/config.py +++ b/worker_api/config.py @@ -90,7 +90,7 @@ # Request observability (per-endpoint memory and latency logging) REQUEST_OBSERVABILITY_ENABLED="true", REQUEST_OBSERVABILITY_MEMORY_WARN_MB=50, - REQUEST_OBSERVABILITY_SKIP_PATHS="/health", + REQUEST_OBSERVABILITY_SKIP_PATHS="/health,/internal/dispatch-due-notifications,/internal/dispatch-routine-notifications", # TTS Configuration GEMINI_API_KEY="", @@ -100,6 +100,22 @@ MONLAM_TTS_MODEL_NAME="", MONLAM_TTS_VOICE_NAME="", + # Notification dispatch (Cloud Scheduler -> worker) + NOTIFICATION_DISPATCH_SECRET_TOKEN="Dispatch", + NOTIFICATION_DISPATCH_BATCH_SIZE=100, + NOTIFICATION_DISPATCH_ENABLED="true", + + GOOGLE_APPLICATION_CREDENTIALS="/etc/secrets/firebase-service-account.json", + + # Notification content defaults + NOTIFICATION_DEFAULT_TITLE="WebBuddhist", + NOTIFICATION_DEFAULT_BODY="Time for your daily practice.", + + # Optional Redis idempotency during dispatch + NOTIFICATION_IDEMPOTENCY_ENABLED="false", + NOTIFICATION_IDEMPOTENCY_TTL_SECONDS=3600, + NOTIFICATION_IDEMPOTENCY_KEY_PREFIX="worker:notifications:sent:", + PLAN_BACKEND="https://api.webuddhist.com", ) TIME_FORMAT_PATTERN = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") @@ -124,3 +140,7 @@ def get_int(key: str) -> int: return int(get(key)) except (TypeError, ValueError) as e: raise ValueError(f"Could not convert the value for key '{key}' to int: {e}") + + +def get_bool(key: str) -> bool: + return get(key).lower() in {"1", "true", "yes", "on"} diff --git a/worker_api/notifications/__init__.py b/worker_api/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/worker_api/notifications/dependencies.py b/worker_api/notifications/dependencies.py new file mode 100644 index 0000000..2585ecc --- /dev/null +++ b/worker_api/notifications/dependencies.py @@ -0,0 +1,22 @@ +import secrets + +from fastapi import Header, HTTPException +from starlette import status + +from worker_api.config import get + + +async def verify_dispatch_token( + x_dispatch_token: str = Header(..., alias="X-Dispatch-Token"), +) -> None: + expected = get("NOTIFICATION_DISPATCH_SECRET_TOKEN") + if not expected: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Dispatch endpoint is not configured", + ) + if not secrets.compare_digest(x_dispatch_token, expected): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid dispatch token", + ) diff --git a/worker_api/notifications/enums.py b/worker_api/notifications/enums.py new file mode 100644 index 0000000..7d5300f --- /dev/null +++ b/worker_api/notifications/enums.py @@ -0,0 +1,12 @@ +from enum import StrEnum + + +class ReminderStatus(StrEnum): + PENDING = "pending" + SENT = "sent" + CANCELLED = "cancelled" + + +class PushPlatform(StrEnum): + ANDROID = "android" + IOS = "ios" diff --git a/worker_api/notifications/internal_views.py b/worker_api/notifications/internal_views.py new file mode 100644 index 0000000..f2d6c36 --- /dev/null +++ b/worker_api/notifications/internal_views.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Depends +from starlette import status + +from worker_api.notifications.dependencies import verify_dispatch_token +from worker_api.notifications.schemas import ( + DispatchDueNotificationsResponse, + DispatchRoutineNotificationsResponse, + RoutineNotificationTargetsResponse, +) +from worker_api.notifications.services.dispatch_service import dispatch_due_notifications_service +from worker_api.notifications.services.routine_dispatch_service import ( + dispatch_routine_notifications_service, +) +from worker_api.notifications.services.routine_notification_service import ( + get_routine_notification_targets, +) + +internal_router = APIRouter(prefix="/internal", tags=["Internal"]) + + +@internal_router.post( + "/dispatch-due-notifications", + status_code=status.HTTP_200_OK, +) +async def dispatch_due_notifications( + _: None = Depends(verify_dispatch_token), +) -> DispatchDueNotificationsResponse: + return await dispatch_due_notifications_service() + + +@internal_router.get( + "/routine-notification-targets", + status_code=status.HTTP_200_OK, +) +async def routine_notification_targets( + _: None = Depends(verify_dispatch_token), +) -> RoutineNotificationTargetsResponse: + return get_routine_notification_targets() + + +@internal_router.post( + "/dispatch-routine-notifications", + status_code=status.HTTP_200_OK, +) +async def dispatch_routine_notifications( + _: None = Depends(verify_dispatch_token), +) -> DispatchRoutineNotificationsResponse: + return await dispatch_routine_notifications_service() diff --git a/worker_api/notifications/models/__init__.py b/worker_api/notifications/models/__init__.py new file mode 100644 index 0000000..ce9ab91 --- /dev/null +++ b/worker_api/notifications/models/__init__.py @@ -0,0 +1,3 @@ +from worker_api.notifications.models.reminder_models import UpcomingReminder + +__all__ = ["UpcomingReminder"] diff --git a/worker_api/notifications/models/reminder_models.py b/worker_api/notifications/models/reminder_models.py new file mode 100644 index 0000000..257e050 --- /dev/null +++ b/worker_api/notifications/models/reminder_models.py @@ -0,0 +1,42 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +import _datetime +from sqlalchemy import Column, DateTime, Index, String, Text, text +from sqlalchemy.dialects.postgresql import JSONB, UUID + +from worker_api.db.database import Base +from worker_api.notifications.enums import PushPlatform, ReminderStatus + + +class UpcomingReminder(Base): + __tablename__ = "upcoming_reminders" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + user_id = Column(UUID(as_uuid=True), nullable=False) + plan_id = Column(UUID(as_uuid=True), nullable=False) + trigger_at = Column(DateTime(timezone=True), nullable=False) + timezone = Column(String(64), nullable=False) + status = Column(String(32), nullable=False, default=ReminderStatus.PENDING) + device_token = Column(Text, nullable=False) + platform = Column(String(16), nullable=False) + routine_config = Column(JSONB, nullable=False) + created_at = Column( + DateTime(timezone=True), + default=datetime.now(_datetime.timezone.utc), + nullable=False, + ) + updated_at = Column( + DateTime(timezone=True), + default=datetime.now(_datetime.timezone.utc), + onupdate=datetime.now(_datetime.timezone.utc), + ) + + __table_args__ = ( + Index( + "idx_upcoming_reminders_due", + "trigger_at", + postgresql_where=text("status = 'pending'"), + ), + Index("idx_upcoming_reminders_user_plan", "user_id", "plan_id"), + ) diff --git a/worker_api/notifications/models/routine_models.py b/worker_api/notifications/models/routine_models.py new file mode 100644 index 0000000..24129ca --- /dev/null +++ b/worker_api/notifications/models/routine_models.py @@ -0,0 +1,122 @@ +"""Read-only SQLAlchemy models for backend routine and notification tables.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID + +from worker_api.db.database import Base + + +class Routine(Base): + __tablename__ = "routines" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False) + updated_at = Column(DateTime(timezone=True)) + deleted_at = Column(DateTime(timezone=True)) + + +class RoutineTimeBlock(Base): + __tablename__ = "routine_time_blocks" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + routine_id = Column( + UUID(as_uuid=True), + ForeignKey("routines.id", ondelete="CASCADE"), + nullable=False, + ) + time = Column(String(5), nullable=False) + time_int = Column(Integer, nullable=False) + notification_enabled = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), nullable=False) + updated_at = Column(DateTime(timezone=True)) + deleted_at = Column(DateTime(timezone=True)) + + +class RoutineSession(Base): + __tablename__ = "routine_sessions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + time_block_id = Column( + UUID(as_uuid=True), + ForeignKey("routine_time_blocks.id", ondelete="CASCADE"), + nullable=False, + ) + session_type = Column(String(32), nullable=False) + source_id = Column(UUID(as_uuid=True), nullable=True) + duration_ms = Column(Integer, nullable=True) + display_order = Column(Integer, nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False) + updated_at = Column(DateTime(timezone=True)) + + +class PushDeviceToken(Base): + __tablename__ = "push_device_tokens" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + token = Column(String(512), nullable=False) + platform = Column(String(16), nullable=False) + device_id = Column(String(255), nullable=True) + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + updated_at = Column(DateTime(timezone=True), nullable=False) + + +class Plan(Base): + __tablename__ = "plans" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + title = Column(String(255), nullable=False) + image_url = Column(String(1000), nullable=True) + + +class Series(Base): + __tablename__ = "series" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + image = Column(String(1000), nullable=True) + + +class SeriesMetadata(Base): + __tablename__ = "series_metadata" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + title = Column(String(255), nullable=False) + series_id = Column(UUID(as_uuid=True), ForeignKey("series.id", ondelete="CASCADE"), nullable=False) + + +class UserPlanProgress(Base): + __tablename__ = "user_plan_progress" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + plan_id = Column(UUID(as_uuid=True), ForeignKey("plans.id", ondelete="CASCADE"), nullable=False) + started_at = Column(DateTime(timezone=True), nullable=False) + + +class DayNotification(Base): + __tablename__ = "day_notifications" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + day_id = Column(UUID(as_uuid=True), ForeignKey("items.id", ondelete="CASCADE"), nullable=False) + title = Column(String(255), nullable=False) + body = Column(Text, nullable=False) + image_type = Column(String(16), nullable=True) + image_url = Column(String(1000), nullable=True) + + +class RecitationCollection(Base): + __tablename__ = "recitation_collections" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + user_id = Column(UUID(as_uuid=True), nullable=False) + name = Column(String(255), nullable=False) + img_url = Column(String(1000), nullable=False) diff --git a/worker_api/notifications/reminder_views.py b/worker_api/notifications/reminder_views.py new file mode 100644 index 0000000..5127997 --- /dev/null +++ b/worker_api/notifications/reminder_views.py @@ -0,0 +1,42 @@ +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from starlette import status + +from worker_api.notifications.schemas import ( + EnrollReminderRequest, + ReminderResponse, + UpdateReminderRequest, +) +from worker_api.notifications.services.reminder_enroll_service import ( + cancel_reminder_service, + enroll_reminder_service, + update_reminder_service, +) + +reminder_router = APIRouter(prefix="/notifications", tags=["Notifications"]) + + +@reminder_router.post("/reminders", status_code=status.HTTP_201_CREATED) +async def enroll_reminder(request: EnrollReminderRequest) -> ReminderResponse: + return enroll_reminder_service(request) + + +@reminder_router.put("/reminders/{user_id}/{plan_id}", status_code=status.HTTP_200_OK) +async def update_reminder( + user_id: UUID, + plan_id: UUID, + request: UpdateReminderRequest, +) -> ReminderResponse: + try: + return update_reminder_service(user_id, plan_id, request) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"error": "NOT_FOUND", "message": str(exc)}, + ) from exc + + +@reminder_router.delete("/reminders/{user_id}/{plan_id}", status_code=status.HTTP_200_OK) +async def cancel_reminder(user_id: UUID, plan_id: UUID) -> dict: + return cancel_reminder_service(user_id, plan_id) diff --git a/worker_api/notifications/repositories/__init__.py b/worker_api/notifications/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/worker_api/notifications/repositories/reminder_repository.py b/worker_api/notifications/repositories/reminder_repository.py new file mode 100644 index 0000000..4410f1c --- /dev/null +++ b/worker_api/notifications/repositories/reminder_repository.py @@ -0,0 +1,97 @@ +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from worker_api.notifications.enums import ReminderStatus +from worker_api.notifications.models.reminder_models import UpcomingReminder + + +def create_reminder( + db: Session, + *, + user_id: UUID, + plan_id: UUID, + trigger_at: datetime, + timezone_name: str, + device_token: str, + platform: str, + routine_config: dict, +) -> UpcomingReminder: + reminder = UpcomingReminder( + user_id=user_id, + plan_id=plan_id, + trigger_at=trigger_at, + timezone=timezone_name, + status=ReminderStatus.PENDING, + device_token=device_token, + platform=platform, + routine_config=routine_config, + ) + db.add(reminder) + db.flush() + return reminder + + +def get_due_reminders( + db: Session, + *, + now: datetime, + limit: int, +) -> list[UpcomingReminder]: + stmt = ( + select(UpcomingReminder) + .where( + UpcomingReminder.status == ReminderStatus.PENDING, + UpcomingReminder.trigger_at <= now, + ) + .order_by(UpcomingReminder.trigger_at) + .limit(limit) + .with_for_update(skip_locked=True) + ) + return list(db.scalars(stmt).all()) + + +def mark_sent(db: Session, reminder: UpcomingReminder) -> None: + reminder.status = ReminderStatus.SENT + reminder.updated_at = datetime.now(timezone.utc) + + +def cancel_pending_for_user_plan( + db: Session, + *, + user_id: UUID, + plan_id: UUID, +) -> int: + pending = ( + db.query(UpcomingReminder) + .filter( + UpcomingReminder.user_id == user_id, + UpcomingReminder.plan_id == plan_id, + UpcomingReminder.status == ReminderStatus.PENDING, + ) + .all() + ) + for reminder in pending: + reminder.status = ReminderStatus.CANCELLED + reminder.updated_at = datetime.now(timezone.utc) + return len(pending) + + +def get_pending_for_user_plan( + db: Session, + *, + user_id: UUID, + plan_id: UUID, +) -> UpcomingReminder | None: + return ( + db.query(UpcomingReminder) + .filter( + UpcomingReminder.user_id == user_id, + UpcomingReminder.plan_id == plan_id, + UpcomingReminder.status == ReminderStatus.PENDING, + ) + .order_by(UpcomingReminder.trigger_at) + .first() + ) diff --git a/worker_api/notifications/repositories/routine_notification_repository.py b/worker_api/notifications/repositories/routine_notification_repository.py new file mode 100644 index 0000000..a5d333b --- /dev/null +++ b/worker_api/notifications/repositories/routine_notification_repository.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from worker_api.notifications.models.routine_models import ( + PushDeviceToken, + Routine, + RoutineSession, + RoutineTimeBlock, +) + + +@dataclass(frozen=True) +class RoutineNotificationRow: + user_id: UUID + time_block_id: UUID + time_block_time: str + time_block_created_at: datetime + session_type: str + source_id: UUID | None + device_token: str + platform: str + + +def get_users_with_matching_timeblocks( + db: Session, + *, + hhmm_values: list[str] | None = None, +) -> list[RoutineNotificationRow]: + stmt = ( + select( + Routine.user_id, + RoutineTimeBlock.id, + RoutineTimeBlock.time, + RoutineTimeBlock.created_at, + RoutineSession.session_type, + RoutineSession.source_id, + PushDeviceToken.token, + PushDeviceToken.platform, + ) + .join(Routine, RoutineTimeBlock.routine_id == Routine.id) + .join(RoutineSession, RoutineSession.time_block_id == RoutineTimeBlock.id) + .join(PushDeviceToken, PushDeviceToken.user_id == Routine.user_id) + .where( + RoutineTimeBlock.notification_enabled.is_(True), + RoutineTimeBlock.deleted_at.is_(None), + Routine.deleted_at.is_(None), + PushDeviceToken.is_active.is_(True), + ) + ) + + if hhmm_values: + stmt = stmt.where(RoutineTimeBlock.time.in_(hhmm_values)) + + rows = db.execute(stmt).all() + return [ + RoutineNotificationRow( + user_id=row.user_id, + time_block_id=row.id, + time_block_time=row.time, + time_block_created_at=row.created_at, + session_type=str(row.session_type), + source_id=row.source_id, + device_token=row.token, + platform=str(row.platform).lower(), + ) + for row in rows + ] + + +def get_plan_by_id(db: Session, plan_id: UUID): + from worker_api.notifications.models.routine_models import Plan + + return db.get(Plan, plan_id) + + +def get_series_by_id(db: Session, series_id: UUID): + from worker_api.notifications.models.routine_models import Series + + return db.get(Series, series_id) + + +def get_series_metadata(db: Session, series_id: UUID): + from worker_api.notifications.models.routine_models import SeriesMetadata + + stmt = ( + select(SeriesMetadata) + .where(SeriesMetadata.series_id == series_id) + .limit(1) + ) + return db.scalars(stmt).first() + + +def get_user_plan_progress(db: Session, *, user_id: UUID, plan_id: UUID): + from worker_api.notifications.models.routine_models import UserPlanProgress + + stmt = select(UserPlanProgress).where( + UserPlanProgress.user_id == user_id, + UserPlanProgress.plan_id == plan_id, + ) + return db.scalars(stmt).first() + + +def get_plan_item_by_day_number(db: Session, *, plan_id: UUID, day_number: int): + from worker_api.audio.models.plan_items_models import PlanItem + + stmt = select(PlanItem).where( + PlanItem.plan_id == plan_id, + PlanItem.day_number == day_number, + ) + return db.scalars(stmt).first() + + +def get_day_notification(db: Session, *, day_id: UUID): + from worker_api.notifications.models.routine_models import DayNotification + + stmt = select(DayNotification).where(DayNotification.day_id == day_id) + return db.scalars(stmt).first() + + +def get_recitation_collection(db: Session, collection_id: UUID): + from worker_api.notifications.models.routine_models import RecitationCollection + + return db.get(RecitationCollection, collection_id) diff --git a/worker_api/notifications/schemas.py b/worker_api/notifications/schemas.py new file mode 100644 index 0000000..8d5287e --- /dev/null +++ b/worker_api/notifications/schemas.py @@ -0,0 +1,113 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + +from worker_api.config import TIME_FORMAT_PATTERN +from worker_api.notifications.enums import PushPlatform + + +class RoutineConfig(BaseModel): + times: list[str] = Field(..., min_length=1) + days_of_week: list[int] | None = Field( + default=None, + description="ISO weekday numbers (0=Monday). Omit for every day.", + ) + plan_name: str | None = None + message_template: str | None = None + current_day_number: int | None = None + + @field_validator("times") + @classmethod + def validate_times(cls, value: list[str]) -> list[str]: + for time_value in value: + if not TIME_FORMAT_PATTERN.match(time_value): + raise ValueError(f"Invalid time format: {time_value}. Expected HH:MM") + return value + + @field_validator("days_of_week") + @classmethod + def validate_days_of_week(cls, value: list[int] | None) -> list[int] | None: + if value is None: + return value + for day in value: + if day < 0 or day > 6: + raise ValueError("days_of_week values must be between 0 and 6") + return value + + +class EnrollReminderRequest(BaseModel): + user_id: UUID + plan_id: UUID + timezone: str + device_token: str + platform: PushPlatform + routine: RoutineConfig + + +class UpdateReminderRequest(BaseModel): + timezone: str | None = None + device_token: str | None = None + platform: PushPlatform | None = None + routine: RoutineConfig | None = None + + +class ReminderResponse(BaseModel): + id: UUID + user_id: UUID + plan_id: UUID + trigger_at: datetime + timezone: str + status: str + platform: str + routine_config: dict[str, Any] + + model_config = {"from_attributes": True} + + +class DispatchDueNotificationsResponse(BaseModel): + processed: int + sent: int + failed: int + skipped: int + + +class PushDeviceTarget(BaseModel): + token: str + platform: str + + +class NotificationContent(BaseModel): + title: str + body: str + custom_image_url: str | None = None + + +class RoutineNotificationUserTarget(BaseModel): + user_id: UUID + notification: NotificationContent + push_devices: list[PushDeviceTarget] + + +class RoutineNotificationGroup(BaseModel): + session_type: str + source_id: UUID | None + source_image_url: str | None = None + users: list[RoutineNotificationUserTarget] + + +class RoutineNotificationTargetsResponse(BaseModel): + generated_at: datetime + matched_time_utc: str + groups: list[RoutineNotificationGroup] + + +class DispatchRoutineNotificationsResponse(BaseModel): + generated_at: datetime + matched_time_utc: str + groups: list[RoutineNotificationGroup] + processed: int + sent: int + failed: int + skipped: int diff --git a/worker_api/notifications/services/__init__.py b/worker_api/notifications/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/worker_api/notifications/services/dispatch_service.py b/worker_api/notifications/services/dispatch_service.py new file mode 100644 index 0000000..fad3139 --- /dev/null +++ b/worker_api/notifications/services/dispatch_service.py @@ -0,0 +1,70 @@ +import logging +from datetime import datetime, timezone + +from worker_api.config import get_bool, get_int +from worker_api.db.database import SessionLocal +from worker_api.notifications.repositories import reminder_repository +from worker_api.notifications.schemas import DispatchDueNotificationsResponse +from worker_api.notifications.services.notification_content_service import build_notification_content +from worker_api.notifications.services.push_service import ( + _already_dispatched, + _mark_dispatched, + send_push_notification, +) +from worker_api.notifications.services.reminder_schedule_service import compute_next_trigger_at + +logger = logging.getLogger(__name__) + + +async def dispatch_due_notifications_service() -> DispatchDueNotificationsResponse: + if not get_bool("NOTIFICATION_DISPATCH_ENABLED"): + return DispatchDueNotificationsResponse(processed=0, sent=0, failed=0, skipped=0) + + now = datetime.now(timezone.utc) + batch_size = get_int("NOTIFICATION_DISPATCH_BATCH_SIZE") + processed = 0 + sent = 0 + failed = 0 + skipped = 0 + + with SessionLocal() as db: + due_reminders = reminder_repository.get_due_reminders(db, now=now, limit=batch_size) + + for reminder in due_reminders: + processed += 1 + if _already_dispatched(reminder.id): + skipped += 1 + continue + + title, body = build_notification_content(reminder) + try: + await send_push_notification(reminder, title, body) + reminder_repository.mark_sent(db, reminder) + reminder_repository.create_reminder( + db, + user_id=reminder.user_id, + plan_id=reminder.plan_id, + trigger_at=compute_next_trigger_at( + reminder.routine_config, + reminder.timezone, + after=now, + ), + timezone_name=reminder.timezone, + device_token=reminder.device_token, + platform=reminder.platform, + routine_config=reminder.routine_config, + ) + _mark_dispatched(reminder.id) + sent += 1 + except Exception: + logger.exception("Failed to dispatch reminder %s", reminder.id) + failed += 1 + + db.commit() + + return DispatchDueNotificationsResponse( + processed=processed, + sent=sent, + failed=failed, + skipped=skipped, + ) diff --git a/worker_api/notifications/services/notification_content_service.py b/worker_api/notifications/services/notification_content_service.py new file mode 100644 index 0000000..455e884 --- /dev/null +++ b/worker_api/notifications/services/notification_content_service.py @@ -0,0 +1,17 @@ +from worker_api.config import get +from worker_api.notifications.models.reminder_models import UpcomingReminder + + +def build_notification_content(reminder: UpcomingReminder) -> tuple[str, str]: + routine = reminder.routine_config or {} + title = get("NOTIFICATION_DEFAULT_TITLE") + body = get("NOTIFICATION_DEFAULT_BODY") + + if routine.get("message_template"): + body = routine["message_template"] + elif routine.get("plan_name"): + body = f"Time for {routine['plan_name']}" + elif routine.get("current_day_number") is not None: + body = f"Day {routine['current_day_number']}: time for your practice." + + return title, body diff --git a/worker_api/notifications/services/push/__init__.py b/worker_api/notifications/services/push/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/worker_api/notifications/services/push/apns_client.py b/worker_api/notifications/services/push/apns_client.py new file mode 100644 index 0000000..36a662a --- /dev/null +++ b/worker_api/notifications/services/push/apns_client.py @@ -0,0 +1,73 @@ +import logging +import time + +import httpx +import jwt + +from worker_api.config import get, get_bool +from worker_api.notifications.services.push.config_loader import load_secret_value + +logger = logging.getLogger(__name__) + +_cached_jwt: str | None = None +_jwt_expires_at: float = 0.0 + + +def _get_apns_jwt() -> str: + global _cached_jwt, _jwt_expires_at + + if _cached_jwt and time.time() < _jwt_expires_at - 60: + return _cached_jwt + + auth_key = load_secret_value(get("APNS_AUTH_KEY")) + headers = { + "alg": "ES256", + "kid": get("APNS_KEY_ID"), + } + payload = { + "iss": get("APNS_TEAM_ID"), + "iat": int(time.time()), + } + _cached_jwt = jwt.encode(payload, auth_key, algorithm="ES256", headers=headers) + _jwt_expires_at = time.time() + 3000 + return _cached_jwt + + +def _apns_host() -> str: + if get_bool("APNS_USE_SANDBOX"): + return "https://api.sandbox.push.apple.com" + return "https://api.push.apple.com" + + +async def send_apns_notification( + *, + device_token: str, + title: str, + body: str, +) -> None: + url = f"{_apns_host()}/3/device/{device_token}" + payload = { + "aps": { + "alert": { + "title": title, + "body": body, + }, + "sound": "default", + } + } + + async with httpx.AsyncClient(http2=True, timeout=30.0) as client: + response = await client.post( + url, + json=payload, + headers={ + "authorization": f"bearer {_get_apns_jwt()}", + "apns-topic": get("APNS_BUNDLE_ID"), + "apns-push-type": "alert", + "apns-priority": "10", + }, + ) + + if response.status_code >= 400: + logger.error("APNs send failed: %s %s", response.status_code, response.text) + response.raise_for_status() diff --git a/worker_api/notifications/services/push/config_loader.py b/worker_api/notifications/services/push/config_loader.py new file mode 100644 index 0000000..f8f0f1e --- /dev/null +++ b/worker_api/notifications/services/push/config_loader.py @@ -0,0 +1,30 @@ +import json +from pathlib import Path + +from worker_api.config import get + + +def load_secret_value(raw_value: str) -> str: + """Load a secret from inline content or a filesystem path.""" + value = raw_value.strip() + if not value: + return "" + if value.startswith("{") or "BEGIN" in value: + return value + path = Path(value) + if path.is_file(): + return path.read_text(encoding="utf-8") + return value + + +def load_json_secret(raw_value: str) -> dict: + content = load_secret_value(raw_value) + if not content: + return {} + return json.loads(content) + + +def is_push_configured(platform: str) -> bool: + if platform in ("android", "ios"): + return bool(get("GOOGLE_APPLICATION_CREDENTIALS").strip()) + return False diff --git a/worker_api/notifications/services/push/fcm_client.py b/worker_api/notifications/services/push/fcm_client.py new file mode 100644 index 0000000..29eff06 --- /dev/null +++ b/worker_api/notifications/services/push/fcm_client.py @@ -0,0 +1,26 @@ +import asyncio +import logging + +from firebase_admin import messaging + +from worker_api.notifications.services.push.firebase_init import initialize_firebase + +logger = logging.getLogger(__name__) + + +async def send_fcm_notification( + *, + device_token: str, + title: str, + body: str, +) -> None: + initialize_firebase() + message = messaging.Message( + notification=messaging.Notification(title=title, body=body), + token=device_token, + ) + try: + await asyncio.to_thread(messaging.send, message) + except Exception: + logger.exception("FCM send failed for token %s", device_token[:8]) + raise diff --git a/worker_api/notifications/services/push/firebase_init.py b/worker_api/notifications/services/push/firebase_init.py new file mode 100644 index 0000000..b8d5c2d --- /dev/null +++ b/worker_api/notifications/services/push/firebase_init.py @@ -0,0 +1,15 @@ +import os + +import firebase_admin + +from worker_api.config import get + + +def initialize_firebase() -> firebase_admin.App: + cred_path = get("GOOGLE_APPLICATION_CREDENTIALS").strip() + if cred_path: + os.environ.setdefault("GOOGLE_APPLICATION_CREDENTIALS", cred_path) + try: + return firebase_admin.get_app() + except ValueError: + return firebase_admin.initialize_app() diff --git a/worker_api/notifications/services/push_service.py b/worker_api/notifications/services/push_service.py new file mode 100644 index 0000000..9ef582a --- /dev/null +++ b/worker_api/notifications/services/push_service.py @@ -0,0 +1,62 @@ +import logging +from datetime import datetime, timezone +from uuid import UUID + +import redis + +from worker_api.config import get, get_bool, get_int +from worker_api.notifications.enums import PushPlatform +from worker_api.notifications.models.reminder_models import UpcomingReminder +from worker_api.notifications.services.notification_content_service import build_notification_content +from worker_api.notifications.services.push.config_loader import is_push_configured +from worker_api.notifications.services.push.fcm_client import send_fcm_notification + +logger = logging.getLogger(__name__) + +_redis_client: redis.Redis | None = None + + +def _get_redis_client() -> redis.Redis: + global _redis_client + if _redis_client is None: + _redis_client = redis.Redis.from_url(get("CACHE_CONNECTION_STRING")) + return _redis_client + + +def _idempotency_key(reminder_id: UUID) -> str: + prefix = get("NOTIFICATION_IDEMPOTENCY_KEY_PREFIX") + return f"{prefix}{reminder_id}" + + +def _already_dispatched(reminder_id: UUID) -> bool: + if not get_bool("NOTIFICATION_IDEMPOTENCY_ENABLED"): + return False + client = _get_redis_client() + return bool(client.exists(_idempotency_key(reminder_id))) + + +def _mark_dispatched(reminder_id: UUID) -> None: + if not get_bool("NOTIFICATION_IDEMPOTENCY_ENABLED"): + return + client = _get_redis_client() + client.setex( + _idempotency_key(reminder_id), + get_int("NOTIFICATION_IDEMPOTENCY_TTL_SECONDS"), + "1", + ) + + +async def send_push_notification(reminder: UpcomingReminder, title: str, body: str) -> None: + if not is_push_configured(reminder.platform): + logger.warning( + "Push not configured for platform %s; skipping send for reminder %s", + reminder.platform, + reminder.id, + ) + return + + if reminder.platform in (PushPlatform.ANDROID, PushPlatform.IOS): + await send_fcm_notification(device_token=reminder.device_token, title=title, body=body) + return + + raise ValueError(f"Unsupported platform: {reminder.platform}") diff --git a/worker_api/notifications/services/reminder_enroll_service.py b/worker_api/notifications/services/reminder_enroll_service.py new file mode 100644 index 0000000..acc784e --- /dev/null +++ b/worker_api/notifications/services/reminder_enroll_service.py @@ -0,0 +1,80 @@ +from uuid import UUID + +from worker_api.db.database import SessionLocal +from worker_api.notifications.repositories import reminder_repository +from worker_api.notifications.schemas import EnrollReminderRequest, ReminderResponse, UpdateReminderRequest +from worker_api.notifications.services.reminder_schedule_service import compute_next_trigger_at + + +def enroll_reminder_service(request: EnrollReminderRequest) -> ReminderResponse: + routine_config = request.routine.model_dump(exclude_none=True) + + with SessionLocal() as db: + reminder_repository.cancel_pending_for_user_plan( + db, + user_id=request.user_id, + plan_id=request.plan_id, + ) + reminder = reminder_repository.create_reminder( + db, + user_id=request.user_id, + plan_id=request.plan_id, + trigger_at=compute_next_trigger_at(routine_config, request.timezone), + timezone_name=request.timezone, + device_token=request.device_token, + platform=request.platform.value, + routine_config=routine_config, + ) + db.commit() + db.refresh(reminder) + return ReminderResponse.model_validate(reminder) + + +def update_reminder_service( + user_id: UUID, + plan_id: UUID, + request: UpdateReminderRequest, +) -> ReminderResponse: + with SessionLocal() as db: + existing = reminder_repository.get_pending_for_user_plan( + db, + user_id=user_id, + plan_id=plan_id, + ) + if not existing: + raise ValueError("No pending reminder found for this user and plan") + + timezone_name = request.timezone or existing.timezone + device_token = request.device_token or existing.device_token + platform = request.platform.value if request.platform else existing.platform + routine_config = ( + request.routine.model_dump(exclude_none=True) + if request.routine + else existing.routine_config + ) + + reminder_repository.cancel_pending_for_user_plan(db, user_id=user_id, plan_id=plan_id) + reminder = reminder_repository.create_reminder( + db, + user_id=user_id, + plan_id=plan_id, + trigger_at=compute_next_trigger_at(routine_config, timezone_name), + timezone_name=timezone_name, + device_token=device_token, + platform=platform, + routine_config=routine_config, + ) + db.commit() + db.refresh(reminder) + return ReminderResponse.model_validate(reminder) + + +def cancel_reminder_service(user_id: UUID, plan_id: UUID) -> dict: + with SessionLocal() as db: + cancelled = reminder_repository.cancel_pending_for_user_plan( + db, + user_id=user_id, + plan_id=plan_id, + ) + db.commit() + return {"cancelled": cancelled} diff --git a/worker_api/notifications/services/reminder_schedule_service.py b/worker_api/notifications/services/reminder_schedule_service.py new file mode 100644 index 0000000..3a36bac --- /dev/null +++ b/worker_api/notifications/services/reminder_schedule_service.py @@ -0,0 +1,56 @@ +from datetime import datetime, time, timedelta, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from fastapi import HTTPException +from starlette import status + + +def _resolve_timezone(timezone_name: str): + if timezone_name in {"UTC", "Etc/UTC", "GMT"}: + return timezone.utc + try: + return ZoneInfo(timezone_name) + except ZoneInfoNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "INVALID_TIMEZONE", "message": f"Unknown timezone: {timezone_name}"}, + ) from exc + + +def compute_next_trigger_at( + routine_config: dict, + timezone_name: str, + after: datetime | None = None, +) -> datetime: + times = routine_config.get("times") or [] + if not times: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "INVALID_ROUTINE", "message": "routine.times is required"}, + ) + + tz = _resolve_timezone(timezone_name) + + after = after or datetime.now(timezone.utc) + local_after = after.astimezone(tz) + days_of_week = routine_config.get("days_of_week") + + for day_offset in range(8): + local_date = local_after.date() + timedelta(days=day_offset) + if days_of_week is not None and local_date.weekday() not in days_of_week: + continue + + candidates: list[datetime] = [] + for time_value in sorted(times): + hour, minute = map(int, time_value.split(":")) + local_dt = datetime.combine(local_date, time(hour, minute), tzinfo=tz) + if local_dt > local_after: + candidates.append(local_dt) + + if candidates: + return min(candidates).astimezone(timezone.utc) + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "NO_UPCOMING_TRIGGER", "message": "No upcoming reminder time found"}, + ) diff --git a/worker_api/notifications/services/routine_dispatch_service.py b/worker_api/notifications/services/routine_dispatch_service.py new file mode 100644 index 0000000..2ce81a7 --- /dev/null +++ b/worker_api/notifications/services/routine_dispatch_service.py @@ -0,0 +1,69 @@ +import logging + +from worker_api.config import get_bool +from worker_api.notifications.schemas import DispatchRoutineNotificationsResponse +from worker_api.notifications.services.push.config_loader import is_push_configured +from worker_api.notifications.services.push.fcm_client import send_fcm_notification +from worker_api.notifications.services.routine_notification_service import ( + get_routine_notification_targets, +) + +logger = logging.getLogger(__name__) + + +async def dispatch_routine_notifications_service() -> DispatchRoutineNotificationsResponse: + targets = get_routine_notification_targets() + + if not get_bool("NOTIFICATION_DISPATCH_ENABLED"): + return DispatchRoutineNotificationsResponse( + generated_at=targets.generated_at, + matched_time_utc=targets.matched_time_utc, + groups=targets.groups, + processed=0, + sent=0, + failed=0, + skipped=0, + ) + + processed = 0 + sent = 0 + failed = 0 + skipped = 0 + + for group in targets.groups: + for user in group.users: + notification = user.notification + for device in user.push_devices: + processed += 1 + if not is_push_configured(device.platform): + logger.warning( + "Push not configured for platform %s; skipping user %s", + device.platform, + user.user_id, + ) + skipped += 1 + continue + + try: + await send_fcm_notification( + device_token=device.token, + title=notification.title, + body=notification.body, + ) + sent += 1 + except Exception: + logger.exception( + "Failed to dispatch routine notification to user %s", + user.user_id, + ) + failed += 1 + + return DispatchRoutineNotificationsResponse( + generated_at=targets.generated_at, + matched_time_utc=targets.matched_time_utc, + groups=targets.groups, + processed=processed, + sent=sent, + failed=failed, + skipped=skipped, + ) diff --git a/worker_api/notifications/services/routine_notification_service.py b/worker_api/notifications/services/routine_notification_service.py new file mode 100644 index 0000000..12e7186 --- /dev/null +++ b/worker_api/notifications/services/routine_notification_service.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime, timedelta, timezone +from uuid import UUID + +from worker_api.config import get +from worker_api.db.database import SessionLocal +from worker_api.notifications.repositories import routine_notification_repository as repo +from worker_api.notifications.repositories.routine_notification_repository import ( + RoutineNotificationRow, +) +from worker_api.notifications.schemas import ( + NotificationContent, + PushDeviceTarget, + RoutineNotificationGroup, + RoutineNotificationTargetsResponse, + RoutineNotificationUserTarget, +) + +SESSION_TYPE_PLAN = "PLAN" +SESSION_TYPE_SERIES = "SERIES" +SESSION_TYPE_RECITATION = "RECITATION" +SESSION_TYPE_RECITATION_COLLECTION = "RECITATION_COLLECTION" +SESSION_TYPE_TIMER = "TIMER" +IMAGE_TYPE_CUSTOM = "CUSTOM" + + +def get_routine_notification_targets() -> RoutineNotificationTargetsResponse: + utc_now = datetime.now(timezone.utc) + hhmm_values = _get_matching_hhmm_window(utc_now) + with SessionLocal() as db: + rows = repo.get_users_with_matching_timeblocks(db, hhmm_values=hhmm_values) + filtered_rows = _filter_by_timezone(rows, utc_now) + groups = _build_groups(filtered_rows, db, utc_now) + + return RoutineNotificationTargetsResponse( + generated_at=utc_now, + matched_time_utc=utc_now.strftime("%H:%M"), + groups=groups, + ) + + +def _get_matching_hhmm_window(utc_now: datetime) -> list[str]: + """Return HH:MM values for utc_now and +/- 1 minute (timezone filter applied later).""" + values: list[str] = [] + for delta_minutes in (-1, 0, 1): + candidate = utc_now + timedelta(minutes=delta_minutes) + hhmm = candidate.strftime("%H:%M") + if hhmm not in values: + values.append(hhmm) + return values + + +def _filter_by_timezone( + rows: list[RoutineNotificationRow], + utc_now: datetime, +) -> list[RoutineNotificationRow]: + matched: list[RoutineNotificationRow] = [] + seen: set[tuple] = set() + + for row in rows: + if not _local_time_matches(row.time_block_created_at, row.time_block_time, utc_now): + continue + + dedupe_key = ( + row.user_id, + row.time_block_id, + row.session_type, + row.source_id, + row.device_token, + ) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + matched.append(row) + + return matched + + +def _local_time_matches( + time_block_created_at: datetime, + time_block_time: str, + utc_now: datetime, +) -> bool: + offset = time_block_created_at.utcoffset() + if offset is None: + return False + + local_now = utc_now + offset + local_candidates = [ + (local_now + timedelta(minutes=delta)).strftime("%H:%M") + for delta in (-1, 0, 1) + ] + return time_block_time in local_candidates + + +def _build_groups( + rows: list[RoutineNotificationRow], + db, + utc_now: datetime, +) -> list[RoutineNotificationGroup]: + grouped_rows: dict[tuple[str, UUID | None], list[RoutineNotificationRow]] = defaultdict(list) + for row in rows: + grouped_rows[(row.session_type, row.source_id)].append(row) + + groups: list[RoutineNotificationGroup] = [] + for (session_type, source_id), session_rows in sorted( + grouped_rows.items(), + key=lambda item: (item[0][0], str(item[0][1])), + ): + source_image_url = _resolve_source_image_url(db, session_type, source_id) + users = _build_user_targets(db, session_type, source_id, session_rows, utc_now) + groups.append( + RoutineNotificationGroup( + session_type=session_type, + source_id=source_id, + source_image_url=source_image_url, + users=users, + ) + ) + + return groups + + +def _build_user_targets( + db, + session_type: str, + source_id: UUID | None, + rows: list[RoutineNotificationRow], + utc_now: datetime, +) -> list[RoutineNotificationUserTarget]: + users_by_id: dict[UUID, list[RoutineNotificationRow]] = defaultdict(list) + for row in rows: + users_by_id[row.user_id].append(row) + + user_targets: list[RoutineNotificationUserTarget] = [] + for user_id, user_rows in sorted(users_by_id.items(), key=lambda item: str(item[0])): + notification = _resolve_notification_content( + db, + session_type=session_type, + source_id=source_id, + user_id=user_id, + utc_now=utc_now, + ) + devices = _collect_push_devices(user_rows) + user_targets.append( + RoutineNotificationUserTarget( + user_id=user_id, + notification=notification, + push_devices=devices, + ) + ) + + return user_targets + + +def _collect_push_devices(rows: list[RoutineNotificationRow]) -> list[PushDeviceTarget]: + devices: list[PushDeviceTarget] = [] + seen_tokens: set[str] = set() + for row in rows: + if row.device_token in seen_tokens: + continue + seen_tokens.add(row.device_token) + devices.append( + PushDeviceTarget( + token=row.device_token, + platform=row.platform, + ) + ) + return devices + + +def _resolve_source_image_url(db, session_type: str, source_id: UUID | None) -> str | None: + if source_id is None: + return None + + if session_type == SESSION_TYPE_PLAN: + plan = repo.get_plan_by_id(db, source_id) + return plan.image_url if plan else None + + if session_type == SESSION_TYPE_SERIES: + series = repo.get_series_by_id(db, source_id) + return series.image if series else None + + if session_type == SESSION_TYPE_RECITATION_COLLECTION: + collection = repo.get_recitation_collection(db, source_id) + return collection.img_url if collection else None + + return None + + +def _resolve_notification_content( + db, + *, + session_type: str, + source_id: UUID | None, + user_id: UUID, + utc_now: datetime, +) -> NotificationContent: + default_title = get("NOTIFICATION_DEFAULT_TITLE") + default_body = get("NOTIFICATION_DEFAULT_BODY") + + if session_type == SESSION_TYPE_PLAN and source_id is not None: + return _resolve_plan_notification(db, user_id=user_id, plan_id=source_id, utc_now=utc_now) + + if session_type == SESSION_TYPE_SERIES and source_id is not None: + return _resolve_series_notification(db, series_id=source_id) + + if session_type == SESSION_TYPE_RECITATION_COLLECTION and source_id is not None: + collection = repo.get_recitation_collection(db, source_id) + if collection: + return NotificationContent( + title=collection.name, + body=default_body, + custom_image_url=collection.img_url, + ) + + if session_type == SESSION_TYPE_RECITATION and source_id is not None: + return NotificationContent( + title=default_title, + body=default_body, + custom_image_url=None, + ) + + if session_type == SESSION_TYPE_TIMER: + return NotificationContent( + title=default_title, + body=default_body, + custom_image_url=None, + ) + + return NotificationContent( + title=default_title, + body=default_body, + custom_image_url=None, + ) + + +def _resolve_plan_notification( + db, + *, + user_id: UUID, + plan_id: UUID, + utc_now: datetime, +) -> NotificationContent: + default_title = get("NOTIFICATION_DEFAULT_TITLE") + default_body = get("NOTIFICATION_DEFAULT_BODY") + + plan = repo.get_plan_by_id(db, plan_id) + plan_title = plan.title if plan else default_title + + day_number = _compute_current_day_number( + repo.get_user_plan_progress(db, user_id=user_id, plan_id=plan_id), + utc_now, + ) + plan_item = repo.get_plan_item_by_day_number(db, plan_id=plan_id, day_number=day_number) + if plan_item is None: + return NotificationContent(title=plan_title, body=default_body, custom_image_url=None) + + day_notification = repo.get_day_notification(db, day_id=plan_item.id) + if day_notification is None: + return NotificationContent(title=plan_title, body=default_body, custom_image_url=None) + + custom_image_url = None + image_type = str(day_notification.image_type) if day_notification.image_type else None + if image_type == IMAGE_TYPE_CUSTOM: + custom_image_url = day_notification.image_url + + return NotificationContent( + title=day_notification.title, + body=day_notification.body, + custom_image_url=custom_image_url, + ) + + +def _resolve_series_notification(db, *, series_id: UUID) -> NotificationContent: + default_body = get("NOTIFICATION_DEFAULT_BODY") + metadata = repo.get_series_metadata(db, series_id) + title = metadata.title if metadata else get("NOTIFICATION_DEFAULT_TITLE") + return NotificationContent(title=title, body=default_body, custom_image_url=None) + + +def _compute_current_day_number(progress, utc_now: datetime) -> int: + if progress is None or progress.started_at is None: + return 1 + + started_at = progress.started_at + if started_at.tzinfo is None: + started_at = started_at.replace(tzinfo=timezone.utc) + + started_date = started_at.date() + utc_today = utc_now.date() + return max(1, (utc_today - started_date).days + 1)