Spaces:
Sleeping
Sleeping
feat: file naming
Browse files- docs/devlogs/db/logs.json +6 -1
- docs/devlogs/server/runtimeerror.txt +90 -132
- src/app/api/v1/tickets.py +8 -5
- src/app/integrations/cloudinary.py +23 -8
- src/app/integrations/supabase.py +11 -5
- src/app/services/media_service.py +31 -9
- src/app/services/ticket_completion_service.py +3 -2
- src/app/utils/file_naming.py +211 -0
docs/devlogs/db/logs.json
CHANGED
|
@@ -2,4 +2,9 @@ INSERT INTO "public"."ticket_images" ("id", "ticket_id", "document_id", "image_t
|
|
| 2 |
|
| 3 |
|
| 4 |
and here is the project data that we use to generate the completion form
|
| 5 |
-
[{"idx":0,"id":"0ade6bd1-e492-4e25-b681-59f42058d29a","client_id":"a2455244-d87e-4279-9fca-dc067f06b5c3","contractor_id":"1af9fb24-e5bb-40ac-a748-0997580b4c32","title":"Atomio Fttx","description":"Install of testing purposes","service_type":"ftth","primary_manager_id":"c5cf92be-4172-4fe2-af5c-f05d83b3a938","status":"active","planned_start_date":"2025-11-22","planned_end_date":"2026-01-23","actual_start_date":null,"actual_end_date":null,"is_closed":false,"closed_at":null,"closed_by_user_id":null,"platform_billing_plan":null,"is_billable":true,"budget":"{}","inventory_requirements":"{}","photo_requirements":"[{\"type\": \"Speedtest\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"speed test collected \"}, {\"type\": \"Airtel network\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"strength metrics\"}, {\"type\": \"ODU outdoor image\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"take a photo of the odu antenna\"}]","activation_requirements":"[{\"type\": \"text\", \"field\": \"ont_serial\", \"label\": \"ONT\", \"options\": null, \"required\": true}, {\"type\": \"text\", \"field\": \"odu_serial\", \"label\": \"ODU\", \"options\": null, \"required\": true}]","additional_metadata":"{\"setup_finalized_at\": \"2025-11-23T13:47:18.729614\", \"setup_finalized_by\": \"c5cf92be-4172-4fe2-af5c-f05d83b3a938\"}","created_at":"2025-11-20 19:04:04.11638+00","updated_at":"2025-11-24 13:16:22.370045+00","deleted_at":null,"project_type":"customer_service"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
|
| 4 |
and here is the project data that we use to generate the completion form
|
| 5 |
+
[{"idx":0,"id":"0ade6bd1-e492-4e25-b681-59f42058d29a","client_id":"a2455244-d87e-4279-9fca-dc067f06b5c3","contractor_id":"1af9fb24-e5bb-40ac-a748-0997580b4c32","title":"Atomio Fttx","description":"Install of testing purposes","service_type":"ftth","primary_manager_id":"c5cf92be-4172-4fe2-af5c-f05d83b3a938","status":"active","planned_start_date":"2025-11-22","planned_end_date":"2026-01-23","actual_start_date":null,"actual_end_date":null,"is_closed":false,"closed_at":null,"closed_by_user_id":null,"platform_billing_plan":null,"is_billable":true,"budget":"{}","inventory_requirements":"{}","photo_requirements":"[{\"type\": \"Speedtest\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"speed test collected \"}, {\"type\": \"Airtel network\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"strength metrics\"}, {\"type\": \"ODU outdoor image\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"take a photo of the odu antenna\"}]","activation_requirements":"[{\"type\": \"text\", \"field\": \"ont_serial\", \"label\": \"ONT\", \"options\": null, \"required\": true}, {\"type\": \"text\", \"field\": \"odu_serial\", \"label\": \"ODU\", \"options\": null, \"required\": true}]","additional_metadata":"{\"setup_finalized_at\": \"2025-11-23T13:47:18.729614\", \"setup_finalized_by\": \"c5cf92be-4172-4fe2-af5c-f05d83b3a938\"}","created_at":"2025-11-20 19:04:04.11638+00","updated_at":"2025-11-24 13:16:22.370045+00","deleted_at":null,"project_type":"customer_service"}]
|
| 6 |
+
|
| 7 |
+
Documents system
|
| 8 |
+
|
| 9 |
+
INSERT INTO "public"."documents" ("id", "entity_type", "entity_id", "file_name", "file_type", "file_size", "file_url", "document_type", "document_category", "version", "is_latest_version", "previous_version_id", "description", "tags", "additional_metadata", "uploaded_by_user_id", "is_public", "created_at", "updated_at", "deleted_at", "storage_provider") VALUES ('03b98bf0-00bf-489b-b82e-b5ac8ed93ff1', 'ticket', 'f59b29fc-d0b9-4618-b0d1-889e340da612', 'Screenshot_20251111-113721.png', 'image/png', '435778', 'https://res.cloudinary.com/dnhajmziu/image/upload/v1764500238/file_ejhwnj.png', 'ticket_photo_ODU outdoor image', 'evidence', '1', 'true', null, 'ODU outdoor image photo for ticket Elizabeth Muthoni', '["ticket", "photo", "ODU outdoor image"]', '{"etag": "83d1240488876d014700628e0be9930d", "type": "upload", "bytes": 435778, "width": 720, "format": "png", "height": 1600, "version": 1764500238, "public_id": "file_ejhwnj", "created_at": "2025-11-30T10:57:18Z", "secure_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764500238/file_ejhwnj.png", "resource_type": "image"}', '43b778b0-2062-4724-abbb-916a4835a9b0', 'false', '2025-11-30 10:57:18.713582+00', '2025-11-30 10:57:18.713589+00', null, 'cloudinary'), ('28e39514-4ffd-44f3-8a38-3972597264a3', 'ticket', 'f59b29fc-d0b9-4618-b0d1-889e340da612', 'passport 2025-11-27 101849.png', 'image/png', '265110', 'https://res.cloudinary.com/dnhajmziu/image/upload/v1764500237/file_a6bhg3.png', 'ticket_photo_Speedtest', 'evidence', '1', 'true', null, 'Speedtest photo for ticket Elizabeth Muthoni', '["ticket", "photo", "Speedtest"]', '{"etag": "d0c2d78da84c52beb8d77ce9e252c8d8", "type": "upload", "bytes": 265110, "width": 510, "format": "png", "height": 364, "version": 1764500237, "public_id": "file_a6bhg3", "created_at": "2025-11-30T10:57:17Z", "secure_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764500237/file_a6bhg3.png", "resource_type": "image"}', '43b778b0-2062-4724-abbb-916a4835a9b0', 'false', '2025-11-30 10:57:18.189524+00', '2025-11-30 10:57:18.189535+00', null, 'cloudinary'), ('e76d848f-640a-4998-9a2c-948d4bc94d77', 'ticket', 'f59b29fc-d0b9-4618-b0d1-889e340da612', 'Gemini_Generated_Image_anof4fanof4fanof-removebg-preview.png', 'image/png', '101003', 'https://res.cloudinary.com/dnhajmziu/image/upload/v1764500236/file_kistjf.png', 'ticket_photo_Airtel network', 'evidence', '1', 'true', null, 'Airtel network photo for ticket Elizabeth Muthoni', '["ticket", "photo", "Airtel network"]', '{"etag": "b15f466735aea70f7c419b7e4f58c29e", "type": "upload", "bytes": 101003, "width": 684, "format": "png", "height": 365, "version": 1764500236, "public_id": "file_kistjf", "created_at": "2025-11-30T10:57:16Z", "secure_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764500236/file_kistjf.png", "resource_type": "image"}', '43b778b0-2062-4724-abbb-916a4835a9b0', 'false', '2025-11-30 10:57:17.268748+00', '2025-11-30 10:57:17.268755+00', null, 'cloudinary');
|
| 10 |
+
|
docs/devlogs/server/runtimeerror.txt
CHANGED
|
@@ -1,137 +1,95 @@
|
|
| 1 |
-
===== Application Startup at 2025-11-30
|
| 2 |
|
| 3 |
INFO: Started server process [7]
|
| 4 |
INFO: Waiting for application startup.
|
| 5 |
-
INFO: 2025-11-
|
| 6 |
-
INFO: 2025-11-
|
| 7 |
-
INFO: 2025-11-
|
| 8 |
-
INFO: 2025-11-
|
| 9 |
-
INFO: 2025-11-
|
| 10 |
-
INFO: 2025-11-
|
| 11 |
-
INFO: 2025-11-
|
| 12 |
-
INFO: 2025-11-
|
| 13 |
-
INFO: 2025-11-
|
| 14 |
-
INFO: 2025-11-
|
| 15 |
-
INFO: 2025-11-
|
| 16 |
-
INFO: 2025-11-
|
| 17 |
-
INFO: 2025-11-
|
| 18 |
-
INFO: 2025-11-
|
| 19 |
-
INFO: 2025-11-30T10:44:41 - app.main: ============================================================
|
| 20 |
-
INFO: 2025-11-30T10:44:41 - app.main: ✅ Startup complete | Ready to serve requests
|
| 21 |
-
INFO: 2025-11-30T10:44:41 - app.main: ============================================================
|
| 22 |
INFO: Application startup complete.
|
|
|
|
|
|
|
|
|
|
| 23 |
INFO: Uvicorn running on http://0.0.0.0:7860 (Press CTRL+C to quit)
|
| 24 |
-
INFO:
|
| 25 |
-
INFO:
|
| 26 |
-
INFO:
|
| 27 |
-
INFO:
|
| 28 |
-
INFO:
|
| 29 |
-
INFO: 2025-11-
|
| 30 |
-
INFO:
|
| 31 |
-
INFO:
|
| 32 |
-
INFO: 2025-11-
|
| 33 |
-
INFO:
|
| 34 |
-
INFO: 10.16.25.209:
|
| 35 |
-
INFO: 10.16.
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
INFO: 10.16.34.155:29232 - "GET /api/v1/auth/me HTTP/1.1" 200 OK
|
| 97 |
-
INFO: 2025-11-30T10:55:22 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
|
| 98 |
-
INFO: 2025-11-30T10:55:22 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
|
| 99 |
-
INFO: 10.16.34.155:29232 - "GET /api/v1/auth/me/preferences HTTP/1.1" 200 OK
|
| 100 |
-
INFO: 2025-11-30T10:55:22 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
|
| 101 |
-
INFO: 2025-11-30T10:55:22 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
|
| 102 |
-
INFO: 10.16.34.155:37709 - "GET /api/v1/auth/me/preferences/available-apps HTTP/1.1" 200 OK
|
| 103 |
-
INFO: 10.16.18.114:22088 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/detail HTTP/1.1" 200 OK
|
| 104 |
-
INFO: 10.16.6.70:17441 - "GET /health HTTP/1.1" 200 OK
|
| 105 |
-
INFO: 10.16.18.114:26540 - "POST /api/v1/ticket-assignments/assignments/a82a3824-f4f1-4283-a2e3-8c348dbb28ce/start-journey HTTP/1.1" 200 OK
|
| 106 |
-
INFO: 10.16.6.70:14508 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/detail HTTP/1.1" 200 OK
|
| 107 |
-
INFO: 10.16.11.176:20636 - "POST /api/v1/ticket-assignments/assignments/a82a3824-f4f1-4283-a2e3-8c348dbb28ce/update-location HTTP/1.1" 200 OK
|
| 108 |
-
INFO: 10.16.11.176:17733 - "GET /health HTTP/1.1" 200 OK
|
| 109 |
-
INFO: 10.16.18.114:40864 - "POST /api/v1/ticket-assignments/assignments/a82a3824-f4f1-4283-a2e3-8c348dbb28ce/update-location HTTP/1.1" 200 OK
|
| 110 |
-
INFO: 10.16.34.155:18221 - "POST /api/v1/ticket-assignments/assignments/a82a3824-f4f1-4283-a2e3-8c348dbb28ce/arrived HTTP/1.1" 200 OK
|
| 111 |
-
INFO: 10.16.6.70:41146 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/detail HTTP/1.1" 200 OK
|
| 112 |
-
INFO: 10.16.11.176:54402 - "GET /health HTTP/1.1" 200 OK
|
| 113 |
-
INFO: 10.16.34.155:39104 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/completion-checklist HTTP/1.1" 200 OK
|
| 114 |
-
INFO: 10.16.25.209:18778 - "GET /health HTTP/1.1" 200 OK
|
| 115 |
-
INFO: 2025-11-30T10:57:16 - app.services.media_service: Using forced provider: cloudinary
|
| 116 |
-
INFO: 2025-11-30T10:57:16 - app.services.media_service: Uploading Gemini_Generated_Image_anof4fanof4fanof-removebg-preview.png (image/png) to cloudinary
|
| 117 |
-
INFO: 2025-11-30T10:57:17 - app.integrations.cloudinary: Uploaded file to Cloudinary: https://res.cloudinary.com/dnhajmziu/image/upload/v1764500236/file_kistjf.png
|
| 118 |
-
INFO: 2025-11-30T10:57:17 - app.services.media_service: Document created: e76d848f-640a-4998-9a2c-948d4bc94d77 (version 1)
|
| 119 |
-
INFO: 2025-11-30T10:57:17 - app.services.ticket_completion_service: Photos updated for ticket f59b29fc-d0b9-4618-b0d1-889e340da612: ['Airtel network']
|
| 120 |
-
INFO: 10.16.18.114:36731 - "POST /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/upload-photos HTTP/1.1" 200 OK
|
| 121 |
-
INFO: 2025-11-30T10:57:17 - app.services.media_service: Using forced provider: cloudinary
|
| 122 |
-
INFO: 2025-11-30T10:57:17 - app.services.media_service: Uploading passport 2025-11-27 101849.png (image/png) to cloudinary
|
| 123 |
-
INFO: 2025-11-30T10:57:18 - app.integrations.cloudinary: Uploaded file to Cloudinary: https://res.cloudinary.com/dnhajmziu/image/upload/v1764500237/file_a6bhg3.png
|
| 124 |
-
INFO: 2025-11-30T10:57:18 - app.services.media_service: Document created: 28e39514-4ffd-44f3-8a38-3972597264a3 (version 1)
|
| 125 |
-
INFO: 2025-11-30T10:57:18 - app.services.ticket_completion_service: Photos updated for ticket f59b29fc-d0b9-4618-b0d1-889e340da612: ['Speedtest']
|
| 126 |
-
INFO: 10.16.34.155:33974 - "POST /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/upload-photos HTTP/1.1" 200 OK
|
| 127 |
-
INFO: 2025-11-30T10:57:18 - app.services.media_service: Using forced provider: cloudinary
|
| 128 |
-
INFO: 2025-11-30T10:57:18 - app.services.media_service: Uploading Screenshot_20251111-113721.png (image/png) to cloudinary
|
| 129 |
-
INFO: 2025-11-30T10:57:18 - app.integrations.cloudinary: Uploaded file to Cloudinary: https://res.cloudinary.com/dnhajmziu/image/upload/v1764500238/file_ejhwnj.png
|
| 130 |
-
INFO: 2025-11-30T10:57:18 - app.services.media_service: Document created: 03b98bf0-00bf-489b-b82e-b5ac8ed93ff1 (version 1)
|
| 131 |
-
INFO: 2025-11-30T10:57:18 - app.services.ticket_completion_service: Photos updated for ticket f59b29fc-d0b9-4618-b0d1-889e340da612: ['ODU outdoor image']
|
| 132 |
-
INFO: 10.16.11.176:21259 - "POST /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/upload-photos HTTP/1.1" 200 OK
|
| 133 |
-
INFO: 10.16.11.176:21259 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/completion-checklist HTTP/1.1" 200 OK
|
| 134 |
-
INFO: 10.16.6.70:11342 - "GET /health HTTP/1.1" 200 OK
|
| 135 |
-
INFO: 10.16.6.70:42258 - "POST /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/activation-data HTTP/1.1" 404 Not Found
|
| 136 |
-
INFO: 10.16.11.176:27978 - "GET /health HTTP/1.1" 200 OK
|
| 137 |
-
|
|
|
|
| 1 |
+
===== Application Startup at 2025-11-30 11:44:53 =====
|
| 2 |
|
| 3 |
INFO: Started server process [7]
|
| 4 |
INFO: Waiting for application startup.
|
| 5 |
+
INFO: 2025-11-30T11:45:08 - app.main: ============================================================
|
| 6 |
+
INFO: 2025-11-30T11:45:08 - app.main: 🚀 SwiftOps API v1.0.0 | PRODUCTION
|
| 7 |
+
INFO: 2025-11-30T11:45:08 - app.main: 📊 Dashboard: Enabled
|
| 8 |
+
INFO: 2025-11-30T11:45:08 - app.main: ============================================================
|
| 9 |
+
INFO: 2025-11-30T11:45:08 - app.main: 📦 Database:
|
| 10 |
+
INFO: 2025-11-30T11:45:08 - app.main: ✓ Connected | 44 tables | 6 users
|
| 11 |
+
INFO: 2025-11-30T11:45:08 - app.main: 💾 Cache & Sessions:
|
| 12 |
+
INFO: 2025-11-30T11:45:09 - app.services.otp_service: ✅ OTP Service initialized with Redis storage
|
| 13 |
+
INFO: 2025-11-30T11:45:09 - app.main: ✓ Redis: Connected
|
| 14 |
+
INFO: 2025-11-30T11:45:09 - app.main: 🔌 External Services:
|
| 15 |
+
INFO: 2025-11-30T11:45:10 - app.main: ✓ Cloudinary: Connected
|
| 16 |
+
INFO: 2025-11-30T11:45:10 - app.main: ✓ Resend: Configured
|
| 17 |
+
INFO: 2025-11-30T11:45:10 - app.main: ○ WASender: Failed
|
| 18 |
+
INFO: 2025-11-30T11:45:10 - app.main: ✓ Supabase: Connected | 6 buckets
|
|
|
|
|
|
|
|
|
|
| 19 |
INFO: Application startup complete.
|
| 20 |
+
INFO: 2025-11-30T11:45:10 - app.main: ============================================================
|
| 21 |
+
INFO: 2025-11-30T11:45:10 - app.main: ✅ Startup complete | Ready to serve requests
|
| 22 |
+
INFO: 2025-11-30T11:45:10 - app.main: ============================================================
|
| 23 |
INFO: Uvicorn running on http://0.0.0.0:7860 (Press CTRL+C to quit)
|
| 24 |
+
INFO: 10.16.11.176:29788 - "GET /health HTTP/1.1" 200 OK
|
| 25 |
+
INFO: 10.16.6.70:65359 - "GET /health HTTP/1.1" 200 OK
|
| 26 |
+
INFO: 2025-11-30T11:45:23 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
|
| 27 |
+
INFO: 2025-11-30T11:45:23 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
|
| 28 |
+
INFO: 10.16.11.176:21546 - "GET /api/v1/auth/me HTTP/1.1" 200 OK
|
| 29 |
+
INFO: 2025-11-30T11:45:24 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
|
| 30 |
+
INFO: 2025-11-30T11:45:24 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
|
| 31 |
+
INFO: 10.16.11.176:21546 - "GET /api/v1/auth/me/preferences HTTP/1.1" 200 OK
|
| 32 |
+
INFO: 2025-11-30T11:45:25 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
|
| 33 |
+
INFO: 2025-11-30T11:45:25 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
|
| 34 |
+
INFO: 10.16.25.209:44475 - "GET /api/v1/auth/me/preferences/available-apps HTTP/1.1" 200 OK
|
| 35 |
+
INFO: 10.16.18.114:8849 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/detail HTTP/1.1" 500 Internal Server Error
|
| 36 |
+
ERROR: Exception in ASGI application
|
| 37 |
+
Traceback (most recent call last):
|
| 38 |
+
File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi
|
| 39 |
+
result = await app( # type: ignore[func-returns-value]
|
| 40 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 41 |
+
File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
| 42 |
+
return await self.app(scope, receive, send)
|
| 43 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 44 |
+
File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__
|
| 45 |
+
await super().__call__(scope, receive, send)
|
| 46 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__
|
| 47 |
+
await self.middleware_stack(scope, receive, send)
|
| 48 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__
|
| 49 |
+
raise exc
|
| 50 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__
|
| 51 |
+
await self.app(scope, receive, _send)
|
| 52 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
| 53 |
+
await self.simple_response(scope, receive, send, request_headers=headers)
|
| 54 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
| 55 |
+
await self.app(scope, receive, send)
|
| 56 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__
|
| 57 |
+
raise exc
|
| 58 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__
|
| 59 |
+
await self.app(scope, receive, sender)
|
| 60 |
+
File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__
|
| 61 |
+
raise e
|
| 62 |
+
File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__
|
| 63 |
+
await self.app(scope, receive, send)
|
| 64 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__
|
| 65 |
+
await route.handle(scope, receive, send)
|
| 66 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle
|
| 67 |
+
await self.app(scope, receive, send)
|
| 68 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app
|
| 69 |
+
response = await func(request)
|
| 70 |
+
^^^^^^^^^^^^^^^^^^^
|
| 71 |
+
File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app
|
| 72 |
+
raw_response = await run_endpoint_function(
|
| 73 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 74 |
+
File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 193, in run_endpoint_function
|
| 75 |
+
return await run_in_threadpool(dependant.call, **values)
|
| 76 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 77 |
+
File "/usr/local/lib/python3.11/site-packages/starlette/concurrency.py", line 41, in run_in_threadpool
|
| 78 |
+
return await anyio.to_thread.run_sync(func, *args)
|
| 79 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 80 |
+
File "/usr/local/lib/python3.11/site-packages/anyio/to_thread.py", line 33, in run_sync
|
| 81 |
+
return await get_asynclib().run_sync_in_worker_thread(
|
| 82 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 83 |
+
File "/usr/local/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 877, in run_sync_in_worker_thread
|
| 84 |
+
return await future
|
| 85 |
+
^^^^^^^^^^^^
|
| 86 |
+
File "/usr/local/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 807, in run
|
| 87 |
+
result = context.run(func, *args)
|
| 88 |
+
^^^^^^^^^^^^^^^^^^^^^^^^
|
| 89 |
+
File "/app/src/app/api/v1/tickets.py", line 660, in get_ticket_detail
|
| 90 |
+
response["images"] = [{
|
| 91 |
+
^^
|
| 92 |
+
File "/app/src/app/api/v1/tickets.py", line 662, in <listcomp>
|
| 93 |
+
"image_url": img.image_url,
|
| 94 |
+
^^^^^^^^^^^^^
|
| 95 |
+
AttributeError: 'TicketImage' object has no attribute 'image_url'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/api/v1/tickets.py
CHANGED
|
@@ -651,18 +651,21 @@ def get_ticket_detail(
|
|
| 651 |
"created_at": exp.created_at.isoformat() if exp.created_at else None
|
| 652 |
} for exp in expenses]
|
| 653 |
|
| 654 |
-
# Get images
|
| 655 |
-
images = db.query(TicketImage).
|
|
|
|
|
|
|
| 656 |
TicketImage.ticket_id == ticket_id,
|
| 657 |
TicketImage.deleted_at.is_(None)
|
| 658 |
).order_by(TicketImage.created_at.desc()).all()
|
| 659 |
|
| 660 |
response["images"] = [{
|
| 661 |
"id": str(img.id),
|
| 662 |
-
"image_url": img.
|
| 663 |
"image_type": img.image_type,
|
| 664 |
-
"
|
| 665 |
-
"
|
|
|
|
| 666 |
"created_at": img.created_at.isoformat() if img.created_at else None
|
| 667 |
} for img in images]
|
| 668 |
|
|
|
|
| 651 |
"created_at": exp.created_at.isoformat() if exp.created_at else None
|
| 652 |
} for exp in expenses]
|
| 653 |
|
| 654 |
+
# Get images with document relationship
|
| 655 |
+
images = db.query(TicketImage).options(
|
| 656 |
+
joinedload(TicketImage.document)
|
| 657 |
+
).filter(
|
| 658 |
TicketImage.ticket_id == ticket_id,
|
| 659 |
TicketImage.deleted_at.is_(None)
|
| 660 |
).order_by(TicketImage.created_at.desc()).all()
|
| 661 |
|
| 662 |
response["images"] = [{
|
| 663 |
"id": str(img.id),
|
| 664 |
+
"image_url": img.document.file_url if img.document else None,
|
| 665 |
"image_type": img.image_type,
|
| 666 |
+
"description": img.description,
|
| 667 |
+
"captured_at": img.captured_at.isoformat() if img.captured_at else None,
|
| 668 |
+
"uploaded_by_user_id": str(img.captured_by_user_id) if img.captured_by_user_id else None,
|
| 669 |
"created_at": img.created_at.isoformat() if img.created_at else None
|
| 670 |
} for img in images]
|
| 671 |
|
src/app/integrations/cloudinary.py
CHANGED
|
@@ -58,7 +58,8 @@ class CloudinaryService:
|
|
| 58 |
file: UploadFile,
|
| 59 |
entity_type: str,
|
| 60 |
entity_id: str,
|
| 61 |
-
document_type: str
|
|
|
|
| 62 |
) -> Dict[str, Any]:
|
| 63 |
"""
|
| 64 |
Upload file to Cloudinary
|
|
@@ -68,6 +69,7 @@ class CloudinaryService:
|
|
| 68 |
entity_type: Type of entity (user, ticket, etc.)
|
| 69 |
entity_id: ID of the entity
|
| 70 |
document_type: Type of document (profile_photo, ticket_image, etc.)
|
|
|
|
| 71 |
|
| 72 |
Returns:
|
| 73 |
Dict containing upload response with secure_url, public_id, etc.
|
|
@@ -91,16 +93,29 @@ class CloudinaryService:
|
|
| 91 |
# Get folder path
|
| 92 |
folder = CloudinaryService.get_folder_path(entity_type)
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
# Upload to Cloudinary
|
| 95 |
-
# Using asset_folder for organization and letting Cloudinary generate unique public_id
|
| 96 |
upload_result = cloudinary.uploader.upload(
|
| 97 |
file_content,
|
| 98 |
-
|
| 99 |
-
use_filename=True,
|
| 100 |
-
unique_filename=True, # Prevents collisions
|
| 101 |
-
overwrite=False,
|
| 102 |
-
resource_type=resource_type,
|
| 103 |
-
tags=[entity_type, document_type, str(entity_id)] # For searching
|
| 104 |
)
|
| 105 |
|
| 106 |
logger.info(f"Uploaded file to Cloudinary: {upload_result.get('secure_url')}")
|
|
|
|
| 58 |
file: UploadFile,
|
| 59 |
entity_type: str,
|
| 60 |
entity_id: str,
|
| 61 |
+
document_type: str,
|
| 62 |
+
contextual_filename: str = None
|
| 63 |
) -> Dict[str, Any]:
|
| 64 |
"""
|
| 65 |
Upload file to Cloudinary
|
|
|
|
| 69 |
entity_type: Type of entity (user, ticket, etc.)
|
| 70 |
entity_id: ID of the entity
|
| 71 |
document_type: Type of document (profile_photo, ticket_image, etc.)
|
| 72 |
+
contextual_filename: Optional contextual filename (without extension)
|
| 73 |
|
| 74 |
Returns:
|
| 75 |
Dict containing upload response with secure_url, public_id, etc.
|
|
|
|
| 93 |
# Get folder path
|
| 94 |
folder = CloudinaryService.get_folder_path(entity_type)
|
| 95 |
|
| 96 |
+
# Prepare upload options
|
| 97 |
+
upload_options = {
|
| 98 |
+
'asset_folder': folder, # Where it appears in Media Library
|
| 99 |
+
'overwrite': False,
|
| 100 |
+
'resource_type': resource_type,
|
| 101 |
+
'tags': [entity_type, document_type, str(entity_id)] # For searching
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
# Use contextual filename if provided, otherwise use original with unique suffix
|
| 105 |
+
if contextual_filename:
|
| 106 |
+
# Use contextual filename as public_id (Cloudinary will add folder prefix)
|
| 107 |
+
upload_options['public_id'] = contextual_filename
|
| 108 |
+
upload_options['use_filename'] = False
|
| 109 |
+
upload_options['unique_filename'] = False
|
| 110 |
+
else:
|
| 111 |
+
# Fallback to original filename with unique suffix
|
| 112 |
+
upload_options['use_filename'] = True
|
| 113 |
+
upload_options['unique_filename'] = True
|
| 114 |
+
|
| 115 |
# Upload to Cloudinary
|
|
|
|
| 116 |
upload_result = cloudinary.uploader.upload(
|
| 117 |
file_content,
|
| 118 |
+
**upload_options
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
)
|
| 120 |
|
| 121 |
logger.info(f"Uploaded file to Cloudinary: {upload_result.get('secure_url')}")
|
src/app/integrations/supabase.py
CHANGED
|
@@ -44,17 +44,19 @@ class SupabaseStorageService:
|
|
| 44 |
def get_file_path(entity_type: str, entity_id: str, file_name: str) -> str:
|
| 45 |
"""
|
| 46 |
Generate file path in bucket
|
| 47 |
-
Format: {entity_type}/{entity_id}/{
|
|
|
|
|
|
|
| 48 |
"""
|
| 49 |
-
|
| 50 |
-
return f"{entity_type}/{entity_id}/{unique_id}_{file_name}"
|
| 51 |
|
| 52 |
@staticmethod
|
| 53 |
async def upload(
|
| 54 |
file: UploadFile,
|
| 55 |
entity_type: str,
|
| 56 |
entity_id: str,
|
| 57 |
-
document_type: str
|
|
|
|
| 58 |
) -> Dict[str, Any]:
|
| 59 |
"""
|
| 60 |
Upload file to Supabase Storage
|
|
@@ -64,6 +66,7 @@ class SupabaseStorageService:
|
|
| 64 |
entity_type: Type of entity (user, ticket, etc.)
|
| 65 |
entity_id: ID of the entity
|
| 66 |
document_type: Type of document
|
|
|
|
| 67 |
|
| 68 |
Returns:
|
| 69 |
Dict containing upload response with public URL
|
|
@@ -77,9 +80,12 @@ class SupabaseStorageService:
|
|
| 77 |
# Read file content
|
| 78 |
file_content = await file.read()
|
| 79 |
|
|
|
|
|
|
|
|
|
|
| 80 |
# Get bucket and file path
|
| 81 |
bucket_name = SupabaseStorageService.get_bucket_name(entity_type)
|
| 82 |
-
file_path = SupabaseStorageService.get_file_path(entity_type, entity_id,
|
| 83 |
|
| 84 |
# Upload to Supabase Storage
|
| 85 |
response = client.storage.from_(bucket_name).upload(
|
|
|
|
| 44 |
def get_file_path(entity_type: str, entity_id: str, file_name: str) -> str:
|
| 45 |
"""
|
| 46 |
Generate file path in bucket
|
| 47 |
+
Format: {entity_type}/{entity_id}/{filename}
|
| 48 |
+
|
| 49 |
+
Note: filename should already be contextual and unique from FileNamingService
|
| 50 |
"""
|
| 51 |
+
return f"{entity_type}/{entity_id}/{file_name}"
|
|
|
|
| 52 |
|
| 53 |
@staticmethod
|
| 54 |
async def upload(
|
| 55 |
file: UploadFile,
|
| 56 |
entity_type: str,
|
| 57 |
entity_id: str,
|
| 58 |
+
document_type: str,
|
| 59 |
+
contextual_filename: str = None
|
| 60 |
) -> Dict[str, Any]:
|
| 61 |
"""
|
| 62 |
Upload file to Supabase Storage
|
|
|
|
| 66 |
entity_type: Type of entity (user, ticket, etc.)
|
| 67 |
entity_id: ID of the entity
|
| 68 |
document_type: Type of document
|
| 69 |
+
contextual_filename: Optional contextual filename to use instead of original
|
| 70 |
|
| 71 |
Returns:
|
| 72 |
Dict containing upload response with public URL
|
|
|
|
| 80 |
# Read file content
|
| 81 |
file_content = await file.read()
|
| 82 |
|
| 83 |
+
# Use contextual filename if provided, otherwise use original
|
| 84 |
+
filename_to_use = contextual_filename if contextual_filename else file.filename
|
| 85 |
+
|
| 86 |
# Get bucket and file path
|
| 87 |
bucket_name = SupabaseStorageService.get_bucket_name(entity_type)
|
| 88 |
+
file_path = SupabaseStorageService.get_file_path(entity_type, entity_id, filename_to_use)
|
| 89 |
|
| 90 |
# Upload to Supabase Storage
|
| 91 |
response = client.storage.from_(bucket_name).upload(
|
src/app/services/media_service.py
CHANGED
|
@@ -4,14 +4,16 @@ Routes uploads to appropriate storage provider based on file type
|
|
| 4 |
"""
|
| 5 |
from fastapi import UploadFile, HTTPException
|
| 6 |
from sqlalchemy.orm import Session
|
| 7 |
-
from typing import Dict, Any
|
| 8 |
from uuid import UUID
|
| 9 |
import logging
|
|
|
|
| 10 |
|
| 11 |
from app.integrations.cloudinary import CloudinaryService
|
| 12 |
from app.integrations.supabase import SupabaseStorageService
|
| 13 |
from app.models.document import Document
|
| 14 |
from app.models.user import User
|
|
|
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
|
@@ -60,7 +62,8 @@ class StorageService:
|
|
| 60 |
uploaded_by_user_id: UUID,
|
| 61 |
db: Session,
|
| 62 |
force_provider: str = None,
|
| 63 |
-
enable_versioning: bool = True
|
|
|
|
| 64 |
) -> Document:
|
| 65 |
"""
|
| 66 |
Universal file upload handler with automatic fallback
|
|
@@ -92,10 +95,22 @@ class StorageService:
|
|
| 92 |
HTTPException: If both providers fail
|
| 93 |
"""
|
| 94 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
# Determine primary provider
|
| 96 |
provider = StorageService.determine_provider(file.content_type, force_provider)
|
| 97 |
|
| 98 |
-
logger.info(f"Uploading {file.filename}
|
| 99 |
|
| 100 |
# Try primary provider with fallback
|
| 101 |
upload_result = None
|
|
@@ -109,7 +124,8 @@ class StorageService:
|
|
| 109 |
file=file,
|
| 110 |
entity_type=entity_type,
|
| 111 |
entity_id=str(entity_id),
|
| 112 |
-
document_type=document_type
|
|
|
|
| 113 |
)
|
| 114 |
file_url = upload_result['secure_url']
|
| 115 |
file_size = upload_result.get('bytes')
|
|
@@ -128,7 +144,8 @@ class StorageService:
|
|
| 128 |
file=file,
|
| 129 |
entity_type=entity_type,
|
| 130 |
entity_id=str(entity_id),
|
| 131 |
-
document_type=document_type
|
|
|
|
| 132 |
)
|
| 133 |
file_url = upload_result['public_url']
|
| 134 |
file_size = upload_result.get('size')
|
|
@@ -151,7 +168,8 @@ class StorageService:
|
|
| 151 |
file=file,
|
| 152 |
entity_type=entity_type,
|
| 153 |
entity_id=str(entity_id),
|
| 154 |
-
document_type=document_type
|
|
|
|
| 155 |
)
|
| 156 |
file_url = upload_result['public_url']
|
| 157 |
file_size = upload_result.get('size')
|
|
@@ -178,11 +196,11 @@ class StorageService:
|
|
| 178 |
version_number = existing_doc.version + 1
|
| 179 |
logger.info(f"Creating version {version_number} (previous: {existing_doc.id})")
|
| 180 |
|
| 181 |
-
# Create document record
|
| 182 |
document = Document(
|
| 183 |
entity_type=entity_type,
|
| 184 |
entity_id=entity_id,
|
| 185 |
-
file_name=
|
| 186 |
file_type=file.content_type,
|
| 187 |
file_size=file_size,
|
| 188 |
file_url=file_url,
|
|
@@ -191,7 +209,11 @@ class StorageService:
|
|
| 191 |
document_category=document_category,
|
| 192 |
description=description,
|
| 193 |
tags=tags,
|
| 194 |
-
additional_metadata=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
uploaded_by_user_id=uploaded_by_user_id,
|
| 196 |
is_public=is_public,
|
| 197 |
version=version_number,
|
|
|
|
| 4 |
"""
|
| 5 |
from fastapi import UploadFile, HTTPException
|
| 6 |
from sqlalchemy.orm import Session
|
| 7 |
+
from typing import Dict, Any, Optional
|
| 8 |
from uuid import UUID
|
| 9 |
import logging
|
| 10 |
+
from pathlib import Path
|
| 11 |
|
| 12 |
from app.integrations.cloudinary import CloudinaryService
|
| 13 |
from app.integrations.supabase import SupabaseStorageService
|
| 14 |
from app.models.document import Document
|
| 15 |
from app.models.user import User
|
| 16 |
+
from app.utils.file_naming import FileNamingService
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
| 19 |
|
|
|
|
| 62 |
uploaded_by_user_id: UUID,
|
| 63 |
db: Session,
|
| 64 |
force_provider: str = None,
|
| 65 |
+
enable_versioning: bool = True,
|
| 66 |
+
additional_context: Optional[str] = None
|
| 67 |
) -> Document:
|
| 68 |
"""
|
| 69 |
Universal file upload handler with automatic fallback
|
|
|
|
| 95 |
HTTPException: If both providers fail
|
| 96 |
"""
|
| 97 |
try:
|
| 98 |
+
# Generate contextual filename (without extension)
|
| 99 |
+
contextual_filename = FileNamingService.generate_contextual_filename(
|
| 100 |
+
entity_type=entity_type,
|
| 101 |
+
entity_id=entity_id,
|
| 102 |
+
document_type=document_type,
|
| 103 |
+
original_filename=file.filename,
|
| 104 |
+
additional_context=additional_context
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Remove extension for Cloudinary public_id (Cloudinary adds it back)
|
| 108 |
+
contextual_filename_no_ext = Path(contextual_filename).stem
|
| 109 |
+
|
| 110 |
# Determine primary provider
|
| 111 |
provider = StorageService.determine_provider(file.content_type, force_provider)
|
| 112 |
|
| 113 |
+
logger.info(f"Uploading {file.filename} as {contextual_filename} to {provider}")
|
| 114 |
|
| 115 |
# Try primary provider with fallback
|
| 116 |
upload_result = None
|
|
|
|
| 124 |
file=file,
|
| 125 |
entity_type=entity_type,
|
| 126 |
entity_id=str(entity_id),
|
| 127 |
+
document_type=document_type,
|
| 128 |
+
contextual_filename=contextual_filename_no_ext
|
| 129 |
)
|
| 130 |
file_url = upload_result['secure_url']
|
| 131 |
file_size = upload_result.get('bytes')
|
|
|
|
| 144 |
file=file,
|
| 145 |
entity_type=entity_type,
|
| 146 |
entity_id=str(entity_id),
|
| 147 |
+
document_type=document_type,
|
| 148 |
+
contextual_filename=contextual_filename
|
| 149 |
)
|
| 150 |
file_url = upload_result['public_url']
|
| 151 |
file_size = upload_result.get('size')
|
|
|
|
| 168 |
file=file,
|
| 169 |
entity_type=entity_type,
|
| 170 |
entity_id=str(entity_id),
|
| 171 |
+
document_type=document_type,
|
| 172 |
+
contextual_filename=contextual_filename
|
| 173 |
)
|
| 174 |
file_url = upload_result['public_url']
|
| 175 |
file_size = upload_result.get('size')
|
|
|
|
| 196 |
version_number = existing_doc.version + 1
|
| 197 |
logger.info(f"Creating version {version_number} (previous: {existing_doc.id})")
|
| 198 |
|
| 199 |
+
# Create document record with contextual filename
|
| 200 |
document = Document(
|
| 201 |
entity_type=entity_type,
|
| 202 |
entity_id=entity_id,
|
| 203 |
+
file_name=contextual_filename, # Store contextual name, not original
|
| 204 |
file_type=file.content_type,
|
| 205 |
file_size=file_size,
|
| 206 |
file_url=file_url,
|
|
|
|
| 209 |
document_category=document_category,
|
| 210 |
description=description,
|
| 211 |
tags=tags,
|
| 212 |
+
additional_metadata={
|
| 213 |
+
**additional_metadata,
|
| 214 |
+
'original_filename': file.filename, # Preserve original name
|
| 215 |
+
'contextual_filename': contextual_filename
|
| 216 |
+
},
|
| 217 |
uploaded_by_user_id=uploaded_by_user_id,
|
| 218 |
is_public=is_public,
|
| 219 |
version=version_number,
|
src/app/services/ticket_completion_service.py
CHANGED
|
@@ -239,7 +239,7 @@ class TicketCompletionService:
|
|
| 239 |
# Upload photos using media_service.py (routes to Cloudinary for images)
|
| 240 |
for photo_type, files in photos.items():
|
| 241 |
for file in files:
|
| 242 |
-
# Upload via universal storage service
|
| 243 |
document = await StorageService.upload_file(
|
| 244 |
file=file,
|
| 245 |
entity_type="ticket",
|
|
@@ -252,7 +252,8 @@ class TicketCompletionService:
|
|
| 252 |
uploaded_by_user_id=uploaded_by_user_id,
|
| 253 |
db=db,
|
| 254 |
force_provider="cloudinary", # Force Cloudinary for photos
|
| 255 |
-
enable_versioning=False
|
|
|
|
| 256 |
)
|
| 257 |
|
| 258 |
# Create ticket_image record linking ticket to document
|
|
|
|
| 239 |
# Upload photos using media_service.py (routes to Cloudinary for images)
|
| 240 |
for photo_type, files in photos.items():
|
| 241 |
for file in files:
|
| 242 |
+
# Upload via universal storage service with contextual naming
|
| 243 |
document = await StorageService.upload_file(
|
| 244 |
file=file,
|
| 245 |
entity_type="ticket",
|
|
|
|
| 252 |
uploaded_by_user_id=uploaded_by_user_id,
|
| 253 |
db=db,
|
| 254 |
force_provider="cloudinary", # Force Cloudinary for photos
|
| 255 |
+
enable_versioning=False,
|
| 256 |
+
additional_context=photo_type # Add photo type to filename
|
| 257 |
)
|
| 258 |
|
| 259 |
# Create ticket_image record linking ticket to document
|
src/app/utils/file_naming.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
File Naming Utility
|
| 3 |
+
|
| 4 |
+
Generates contextual, self-documenting filenames for object storage.
|
| 5 |
+
Makes files easily identifiable even if database is lost.
|
| 6 |
+
|
| 7 |
+
Naming Pattern:
|
| 8 |
+
{entity_type}_{entity_short_id}_{document_type}_{timestamp}_{original_name}
|
| 9 |
+
|
| 10 |
+
Examples:
|
| 11 |
+
- ticket_8f08ad14_completion_photo_speedtest_20251130_105718.png
|
| 12 |
+
- user_43b778b0_profile_photo_20251130_105718.jpg
|
| 13 |
+
- expense_decafa10_receipt_20251130_105718.pdf
|
| 14 |
+
- project_0ade6bd1_contract_20251130_105718.pdf
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
from uuid import UUID
|
| 19 |
+
import re
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FileNamingService:
|
| 24 |
+
"""Service for generating contextual filenames"""
|
| 25 |
+
|
| 26 |
+
@staticmethod
|
| 27 |
+
def sanitize_filename(filename: str) -> str:
|
| 28 |
+
"""
|
| 29 |
+
Sanitize filename to be filesystem and URL safe
|
| 30 |
+
|
| 31 |
+
- Remove special characters
|
| 32 |
+
- Replace spaces with underscores
|
| 33 |
+
- Lowercase
|
| 34 |
+
- Keep only alphanumeric, underscore, hyphen, dot
|
| 35 |
+
"""
|
| 36 |
+
# Get filename without extension
|
| 37 |
+
stem = Path(filename).stem
|
| 38 |
+
ext = Path(filename).suffix
|
| 39 |
+
|
| 40 |
+
# Remove special characters, keep alphanumeric, underscore, hyphen
|
| 41 |
+
stem = re.sub(r'[^\w\-]', '_', stem)
|
| 42 |
+
|
| 43 |
+
# Replace multiple underscores with single
|
| 44 |
+
stem = re.sub(r'_+', '_', stem)
|
| 45 |
+
|
| 46 |
+
# Lowercase and strip
|
| 47 |
+
stem = stem.lower().strip('_')
|
| 48 |
+
|
| 49 |
+
# Limit length to 50 chars
|
| 50 |
+
if len(stem) > 50:
|
| 51 |
+
stem = stem[:50]
|
| 52 |
+
|
| 53 |
+
return f"{stem}{ext.lower()}"
|
| 54 |
+
|
| 55 |
+
@staticmethod
|
| 56 |
+
def get_short_id(entity_id: UUID) -> str:
|
| 57 |
+
"""
|
| 58 |
+
Get short version of UUID for filename (first 8 chars)
|
| 59 |
+
|
| 60 |
+
Example: 8f08ad14-df8b-4780-84e7-0d45e133f2a6 -> 8f08ad14
|
| 61 |
+
"""
|
| 62 |
+
return str(entity_id).split('-')[0]
|
| 63 |
+
|
| 64 |
+
@staticmethod
|
| 65 |
+
def get_timestamp() -> str:
|
| 66 |
+
"""
|
| 67 |
+
Get timestamp for filename in format: YYYYMMDD_HHMMSS
|
| 68 |
+
|
| 69 |
+
Example: 20251130_105718
|
| 70 |
+
"""
|
| 71 |
+
return datetime.utcnow().strftime('%Y%m%d_%H%M%S')
|
| 72 |
+
|
| 73 |
+
@staticmethod
|
| 74 |
+
def generate_contextual_filename(
|
| 75 |
+
entity_type: str,
|
| 76 |
+
entity_id: UUID,
|
| 77 |
+
document_type: str,
|
| 78 |
+
original_filename: str,
|
| 79 |
+
additional_context: str = None
|
| 80 |
+
) -> str:
|
| 81 |
+
"""
|
| 82 |
+
Generate contextual, self-documenting filename
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
entity_type: Type of entity (ticket, user, expense, etc.)
|
| 86 |
+
entity_id: UUID of the entity
|
| 87 |
+
document_type: Type of document (completion_photo, receipt, profile_photo, etc.)
|
| 88 |
+
original_filename: Original uploaded filename
|
| 89 |
+
additional_context: Optional additional context (photo_type, etc.)
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
Contextual filename
|
| 93 |
+
|
| 94 |
+
Examples:
|
| 95 |
+
ticket_8f08ad14_completion_photo_speedtest_20251130_105718.png
|
| 96 |
+
user_43b778b0_profile_photo_20251130_105718.jpg
|
| 97 |
+
expense_decafa10_receipt_fuel_20251130_105718.pdf
|
| 98 |
+
"""
|
| 99 |
+
# Get components
|
| 100 |
+
short_id = FileNamingService.get_short_id(entity_id)
|
| 101 |
+
timestamp = FileNamingService.get_timestamp()
|
| 102 |
+
sanitized_original = FileNamingService.sanitize_filename(original_filename)
|
| 103 |
+
|
| 104 |
+
# Remove extension from original for now
|
| 105 |
+
original_stem = Path(sanitized_original).stem
|
| 106 |
+
extension = Path(sanitized_original).suffix
|
| 107 |
+
|
| 108 |
+
# Build filename parts
|
| 109 |
+
parts = [
|
| 110 |
+
entity_type.lower(),
|
| 111 |
+
short_id,
|
| 112 |
+
document_type.lower().replace(' ', '_')
|
| 113 |
+
]
|
| 114 |
+
|
| 115 |
+
# Add additional context if provided
|
| 116 |
+
if additional_context:
|
| 117 |
+
sanitized_context = re.sub(r'[^\w\-]', '_', additional_context.lower())
|
| 118 |
+
parts.append(sanitized_context)
|
| 119 |
+
|
| 120 |
+
# Add timestamp
|
| 121 |
+
parts.append(timestamp)
|
| 122 |
+
|
| 123 |
+
# Add original filename (without extension) if it's meaningful
|
| 124 |
+
# Skip if it's generic like "image", "photo", "file", "screenshot"
|
| 125 |
+
generic_names = ['image', 'photo', 'file', 'screenshot', 'document', 'img', 'pic']
|
| 126 |
+
if original_stem and original_stem.lower() not in generic_names:
|
| 127 |
+
# Limit original name to 30 chars
|
| 128 |
+
if len(original_stem) > 30:
|
| 129 |
+
original_stem = original_stem[:30]
|
| 130 |
+
parts.append(original_stem)
|
| 131 |
+
|
| 132 |
+
# Join parts and add extension
|
| 133 |
+
filename = '_'.join(parts) + extension
|
| 134 |
+
|
| 135 |
+
return filename
|
| 136 |
+
|
| 137 |
+
@staticmethod
|
| 138 |
+
def generate_ticket_photo_filename(
|
| 139 |
+
ticket_id: UUID,
|
| 140 |
+
photo_type: str,
|
| 141 |
+
original_filename: str
|
| 142 |
+
) -> str:
|
| 143 |
+
"""
|
| 144 |
+
Generate filename for ticket completion photos
|
| 145 |
+
|
| 146 |
+
Example: ticket_8f08ad14_completion_photo_speedtest_20251130_105718.png
|
| 147 |
+
"""
|
| 148 |
+
return FileNamingService.generate_contextual_filename(
|
| 149 |
+
entity_type='ticket',
|
| 150 |
+
entity_id=ticket_id,
|
| 151 |
+
document_type='completion_photo',
|
| 152 |
+
original_filename=original_filename,
|
| 153 |
+
additional_context=photo_type
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
@staticmethod
|
| 157 |
+
def generate_expense_receipt_filename(
|
| 158 |
+
expense_id: UUID,
|
| 159 |
+
expense_category: str,
|
| 160 |
+
original_filename: str
|
| 161 |
+
) -> str:
|
| 162 |
+
"""
|
| 163 |
+
Generate filename for expense receipts
|
| 164 |
+
|
| 165 |
+
Example: expense_decafa10_receipt_fuel_20251130_105718.pdf
|
| 166 |
+
"""
|
| 167 |
+
return FileNamingService.generate_contextual_filename(
|
| 168 |
+
entity_type='expense',
|
| 169 |
+
entity_id=expense_id,
|
| 170 |
+
document_type='receipt',
|
| 171 |
+
original_filename=original_filename,
|
| 172 |
+
additional_context=expense_category
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
@staticmethod
|
| 176 |
+
def generate_user_document_filename(
|
| 177 |
+
user_id: UUID,
|
| 178 |
+
document_type: str,
|
| 179 |
+
original_filename: str
|
| 180 |
+
) -> str:
|
| 181 |
+
"""
|
| 182 |
+
Generate filename for user documents
|
| 183 |
+
|
| 184 |
+
Examples:
|
| 185 |
+
user_43b778b0_profile_photo_20251130_105718.jpg
|
| 186 |
+
user_43b778b0_id_document_20251130_105718.pdf
|
| 187 |
+
"""
|
| 188 |
+
return FileNamingService.generate_contextual_filename(
|
| 189 |
+
entity_type='user',
|
| 190 |
+
entity_id=user_id,
|
| 191 |
+
document_type=document_type,
|
| 192 |
+
original_filename=original_filename
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
@staticmethod
|
| 196 |
+
def generate_project_document_filename(
|
| 197 |
+
project_id: UUID,
|
| 198 |
+
document_type: str,
|
| 199 |
+
original_filename: str
|
| 200 |
+
) -> str:
|
| 201 |
+
"""
|
| 202 |
+
Generate filename for project documents
|
| 203 |
+
|
| 204 |
+
Example: project_0ade6bd1_contract_20251130_105718.pdf
|
| 205 |
+
"""
|
| 206 |
+
return FileNamingService.generate_contextual_filename(
|
| 207 |
+
entity_type='project',
|
| 208 |
+
entity_id=project_id,
|
| 209 |
+
document_type=document_type,
|
| 210 |
+
original_filename=original_filename
|
| 211 |
+
)
|