From 5d39e048ba216447d33ab55012f4ecf84d065105 Mon Sep 17 00:00:00 2001 From: mariomitte Date: Thu, 9 Jul 2026 21:21:17 +0200 Subject: [PATCH] prvi kod za live uporabu --- .gitea/workflows/ci.yml | 39 + .../NotificationSystem.instructions.md | 8 + .gitignore | 211 + backend/.dockerignore | 20 + backend/Dockerfile | 35 + backend/core/__init__.py | 0 backend/core/base_models.py | 34 + backend/core/celery.py | 15 + backend/core/management/__init__.py | 0 backend/core/management/commands/__init__.py | 0 .../management/commands/seed_all_models.py | 385 ++ .../management/commands/seed_fleet_assets.py | 99 + .../management/commands/seed_knezljubo.py | 0 backend/core/serializers.py | 14 + backend/core/settings/__init__.py | 8 + backend/core/settings/base.py | 215 + backend/core/settings/development.py | 36 + backend/core/settings/production.py | 28 + backend/core/urls.py | 33 + backend/core/users/__init__.py | 0 backend/core/users/admin.py | 27 + backend/core/users/apps.py | 7 + backend/core/users/management/__init__.py | 0 .../users/management/commands/__init__.py | 0 .../management/commands/seed_all_models.py | 2 + .../management/commands/seed_fleet_assets.py | 2 + .../management/commands/seed_knezljubo.py | 107 + .../users/management/commands/seed_users.py | 52 + backend/core/users/managers.py | 30 + backend/core/users/migrations/0001_initial.py | 47 + ...gers_customuser_client_profile_and_more.py | 40 + .../0003_remove_customuser_username.py | 17 + .../0004_customuser_is_team_member.py | 19 + .../0005_customuser_occupation_residence.py | 21 + .../0006_customuser_work_position.py | 16 + backend/core/users/migrations/__init__.py | 0 backend/core/users/models.py | 52 + backend/core/users/serializers.py | 73 + backend/core/users/services.py | 35 + backend/core/users/tests/test_models.py | 25 + backend/core/users/tests/test_permissions.py | 37 + .../tests/test_seed_all_models_command.py | 56 + backend/core/users/tests/test_services.py | 35 + backend/core/users/tests/test_users.py | 61 + backend/core/users/urls.py | 13 + backend/core/users/views.py | 44 + backend/core/utils.py | 20 + backend/core/validators.py | 0 backend/core/wsgi.py | 16 + backend/entrypoint.sh | 101 + backend/infrastructure/__init__.py | 0 backend/infrastructure/paperless_client.py | 43 + backend/infrastructure/pusher_service.py | 221 + backend/infrastructure/webhooks.py | 0 backend/manage.py | 22 + backend/modules/crm/__init__.py | 0 backend/modules/crm/admin.py | 86 + backend/modules/crm/apps.py | 8 + .../modules/crm/migrations/0001_initial.py | 37 + backend/modules/crm/migrations/__init__.py | 0 backend/modules/crm/models.py | 31 + backend/modules/crm/serializers.py | 50 + backend/modules/crm/services.py | 65 + backend/modules/crm/tests/test_models.py | 75 + backend/modules/crm/tests/test_serializers.py | 43 + backend/modules/crm/tests/test_services.py | 36 + backend/modules/crm/tests/test_views.py | 62 + backend/modules/crm/urls.py | 7 + backend/modules/crm/views.py | 41 + backend/modules/fleet/__init__.py | 0 backend/modules/fleet/admin.py | 197 + backend/modules/fleet/apps.py | 8 + .../modules/fleet/migrations/0001_initial.py | 124 + .../0002_alter_vehicle_assigned_servicer.py | 21 + ...03_add_is_company_vehicle_and_client_fk.py | 25 + .../0004_vehicleserviceattachment.py | 36 + .../0005_vehiclenotification_metadata.py | 18 + .../migrations/0006_crane_proxy_model.py | 26 + .../migrations/0007_vehicle_asset_type.py | 18 + .../0008_backfill_asset_type_vehicle.py | 34 + ...009_workorder_location_and_travel_times.py | 29 + ..._record_work_order_and_optional_mileage.py | 42 + .../0011_workorder_start_mileage_optional.py | 19 + .../0012_workorder_purpose_choices.py | 19 + .../0013_vehicleservicerecord_task.py | 28 + .../0014_servicerecord_remove_workorder.py | 29 + .../fleet/migrations/0015_workorderinvoice.py | 47 + .../0016_workorder_servicer_vehicle_fields.py | 36 + .../0017_vehicle_crane_serial_number.py | 23 + ..._vehicle_chassis_working_hours_and_more.py | 23 + ...0019_vehicleservicerecord_service_title.py | 16 + ...er_workorderinvoice_created_at_and_more.py | 35 + backend/modules/fleet/migrations/__init__.py | 0 backend/modules/fleet/models.py | 433 ++ backend/modules/fleet/serializers.py | 314 + backend/modules/fleet/services.py | 412 ++ backend/modules/fleet/tasks.py | 165 + backend/modules/fleet/tests/conftest.py | 18 + .../tests/test_bulk_close_work_orders.py | 72 + .../fleet/tests/test_crane_serial_number.py | 67 + .../fleet/tests/test_delete_work_order.py | 24 + .../modules/fleet/tests/test_integration.py | 28 + backend/modules/fleet/tests/test_models.py | 33 + .../modules/fleet/tests/test_notifications.py | 201 + .../fleet/tests/test_photo_services.py | 362 + .../fleet/tests/test_pusher_service.py | 88 + .../test_service_history_and_expenses.py | 17 + .../tests/test_service_photo_upload_to.py | 22 + backend/modules/fleet/tests/test_services.py | 52 + .../fleet/tests/trest_travel_expenses.py | 30 + backend/modules/fleet/urls.py | 22 + backend/modules/fleet/views.py | 1563 +++++ backend/modules/invoicing/__init__.py | 0 backend/modules/invoicing/admin.py | 43 + backend/modules/invoicing/apps.py | 8 + .../invoicing/migrations/0001_initial.py | 30 + .../migrations/0002_invoice_invoiceitem.py | 56 + ...nvoicetransaction_delete_financialentry.py | 34 + ...nvoicetransaction_payment_date_and_more.py | 26 + ...5_alter_invoicetransaction_payment_date.py | 19 + .../0006_invoicetransaction_work_order.py | 28 + .../modules/invoicing/migrations/__init__.py | 0 backend/modules/invoicing/models.py | 123 + backend/modules/invoicing/serializers.py | 57 + backend/modules/invoicing/services.py | 77 + backend/modules/invoicing/tasks.py | 81 + backend/modules/invoicing/tests/conftest.py | 112 + backend/modules/invoicing/tests/test_api.py | 46 + .../modules/invoicing/tests/test_models.py | 18 + .../invoicing/tests/test_permissions.py | 10 + .../invoicing/tests/test_serializers.py | 22 + .../modules/invoicing/tests/test_services.py | 40 + backend/modules/invoicing/tests/test_tasks.py | 40 + backend/modules/invoicing/tests/test_utils.py | 28 + backend/modules/invoicing/tests/test_views.py | 46 + backend/modules/invoicing/urls.py | 8 + backend/modules/invoicing/utils.py | 26 + backend/modules/invoicing/views.py | 75 + backend/modules/services.py | 70 + backend/modules/task_management/__init__.py | 0 backend/modules/task_management/admin.py | 21 + backend/modules/task_management/apps.py | 5 + .../migrations/0001_initial.py | 34 + .../migrations/0002_task_work_order.py | 27 + .../migrations/0003_task_vehicle.py | 37 + .../task_management/migrations/__init__.py | 0 backend/modules/task_management/models.py | 59 + .../modules/task_management/serializers.py | 75 + backend/modules/task_management/services.py | 48 + backend/modules/task_management/tasks.py | 37 + .../task_management/tests/test_models.py | 12 + .../task_management/tests/test_serializers.py | 32 + .../task_management/tests/test_services.py | 53 + .../task_management/tests/test_tasks.py | 25 + .../task_management/tests/test_views.py | 54 + backend/modules/task_management/urls.py | 9 + backend/modules/task_management/views.py | 64 + backend/pytest.ini | 3 + backend/requirements.txt | 75 + docker-compose.dev.yml | 51 + docker-compose.yml | 47 + frontend/.dockerignore | 32 + frontend/Dockerfile | 20 + frontend/astro.config.mjs | 62 + frontend/package-lock.json | 5977 +++++++++++++++++ frontend/package.json | 33 + frontend/public/manifest.webmanifest | 19 + frontend/public/offline.html | 53 + frontend/public/pwa-icon.svg | 9 + frontend/public/service-worker.js | 62 + frontend/src/components/AuthWidget.jsx | 61 + frontend/src/components/Navbar.jsx | 100 + frontend/src/components/NotificationBell.jsx | 135 + frontend/src/components/ToastContainer.jsx | 50 + frontend/src/components/ToastTrigger.jsx | 13 + .../components/atom/ButtonDisplayCounter.jsx | 15 + frontend/src/components/auth/LoginForm.jsx | 86 + .../src/components/crm/ClientsSection.jsx | 171 + .../components/dashboard/DashboardTopbar.jsx | 83 + .../dashboard/FleetDashboardShell.jsx | 1036 +++ .../dashboard/ServiceContextSelector.jsx | 172 + .../src/components/dashboard/SyncLogPanel.jsx | 57 + .../components/dashboard/TaskCreateModal.jsx | 188 + .../dashboard/TaskServiceRecordsModal.jsx | 199 + .../dashboard/WorkOrderDetailModal.jsx | 1115 +++ .../dashboard/WorkOrderImageCarousel.jsx | 152 + .../dashboard/WorkOrderInvoicesPdfPage.jsx | 258 + .../components/dashboard/WorkOrderModal.jsx | 592 ++ frontend/src/components/data/DataView.tsx | 114 + .../components/fleet/ServicePhotoUpload.jsx | 247 + .../fleet/ServiceRecordCreateButton.jsx | 11 + .../fleet/ServiceRecordDetailModal.jsx | 537 ++ .../components/fleet/ServiceRecordForm.jsx | 374 ++ .../src/components/invoicing/InvoiceForm.jsx | 258 + .../notifications/NotificationDetailModal.jsx | 239 + frontend/src/components/tasks/TaskForm.jsx | 143 + frontend/src/components/toast/ToastItem.tsx | 35 + .../src/components/toast/ToastProvider.tsx | 28 + .../src/components/ui/AnimatedDataTable.jsx | 82 + frontend/src/components/ui/AnimatedPage.jsx | 15 + frontend/src/components/ui/ModalShell.jsx | 51 + frontend/src/components/ui/NetworkGuard.jsx | 20 + frontend/src/components/ui/ThemeToggle.jsx | 41 + frontend/src/components/ui/Toast.jsx | 5 + frontend/src/components/ui/UserDisplay.jsx | 151 + frontend/src/config.css | 21 + frontend/src/hooks/useToast.ts | 44 + frontend/src/layouts/Layout.astro | 105 + frontend/src/lib/api.ts | 112 + frontend/src/lib/db.ts | 53 + frontend/src/lib/displayIds.js | 22 + frontend/src/pages/clients.astro | 9 + frontend/src/pages/data/[...slug].astro | 37 + frontend/src/pages/index.astro | 9 + frontend/src/pages/login.astro | 10 + frontend/src/pages/putni-nalozi.astro | 9 + frontend/src/pages/putni-nalozi/racuni.astro | 9 + frontend/src/pages/service-recordsssss.astro | 9 + frontend/src/pages/servisni-zapisi.astro | 9 + frontend/src/pages/vehicles.astro | 9 + frontend/src/pages/work-orders.astro | 9 + frontend/src/services/apiClient.js | 133 + frontend/src/stores/authStore.js | 116 + frontend/src/stores/clientStore.js | 39 + frontend/src/stores/collectionStore.js | 8 + frontend/src/stores/counter.js | 3 + frontend/src/stores/craneSerialStore.js | 57 + frontend/src/stores/fleetDashboardStore.js | 830 +++ frontend/src/stores/invoiceStore.js | 44 + frontend/src/stores/networkStore.js | 41 + frontend/src/stores/notificationStore.js | 149 + frontend/src/stores/racunStore.js | 20 + frontend/src/stores/serviceContextStore.js | 85 + frontend/src/stores/taskStore.js | 63 + frontend/src/stores/toastStore.js | 41 + frontend/src/stores/todoStore.js | 242 + frontend/src/stores/uiStore.js | 8 + frontend/src/styles/global.css | 61 + frontend/tsconfig.json | 5 + 239 files changed, 25151 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .github/instructions/NotificationSystem.instructions.md create mode 100644 .gitignore create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/core/__init__.py create mode 100644 backend/core/base_models.py create mode 100644 backend/core/celery.py create mode 100644 backend/core/management/__init__.py create mode 100644 backend/core/management/commands/__init__.py create mode 100644 backend/core/management/commands/seed_all_models.py create mode 100644 backend/core/management/commands/seed_fleet_assets.py create mode 100644 backend/core/management/commands/seed_knezljubo.py create mode 100644 backend/core/serializers.py create mode 100644 backend/core/settings/__init__.py create mode 100644 backend/core/settings/base.py create mode 100644 backend/core/settings/development.py create mode 100644 backend/core/settings/production.py create mode 100644 backend/core/urls.py create mode 100644 backend/core/users/__init__.py create mode 100644 backend/core/users/admin.py create mode 100644 backend/core/users/apps.py create mode 100644 backend/core/users/management/__init__.py create mode 100644 backend/core/users/management/commands/__init__.py create mode 100644 backend/core/users/management/commands/seed_all_models.py create mode 100644 backend/core/users/management/commands/seed_fleet_assets.py create mode 100644 backend/core/users/management/commands/seed_knezljubo.py create mode 100644 backend/core/users/management/commands/seed_users.py create mode 100644 backend/core/users/managers.py create mode 100644 backend/core/users/migrations/0001_initial.py create mode 100644 backend/core/users/migrations/0002_alter_customuser_managers_customuser_client_profile_and_more.py create mode 100644 backend/core/users/migrations/0003_remove_customuser_username.py create mode 100644 backend/core/users/migrations/0004_customuser_is_team_member.py create mode 100644 backend/core/users/migrations/0005_customuser_occupation_residence.py create mode 100644 backend/core/users/migrations/0006_customuser_work_position.py create mode 100644 backend/core/users/migrations/__init__.py create mode 100644 backend/core/users/models.py create mode 100644 backend/core/users/serializers.py create mode 100644 backend/core/users/services.py create mode 100644 backend/core/users/tests/test_models.py create mode 100644 backend/core/users/tests/test_permissions.py create mode 100644 backend/core/users/tests/test_seed_all_models_command.py create mode 100644 backend/core/users/tests/test_services.py create mode 100644 backend/core/users/tests/test_users.py create mode 100644 backend/core/users/urls.py create mode 100644 backend/core/users/views.py create mode 100644 backend/core/utils.py create mode 100644 backend/core/validators.py create mode 100644 backend/core/wsgi.py create mode 100644 backend/entrypoint.sh create mode 100644 backend/infrastructure/__init__.py create mode 100644 backend/infrastructure/paperless_client.py create mode 100644 backend/infrastructure/pusher_service.py create mode 100644 backend/infrastructure/webhooks.py create mode 100644 backend/manage.py create mode 100644 backend/modules/crm/__init__.py create mode 100644 backend/modules/crm/admin.py create mode 100644 backend/modules/crm/apps.py create mode 100644 backend/modules/crm/migrations/0001_initial.py create mode 100644 backend/modules/crm/migrations/__init__.py create mode 100644 backend/modules/crm/models.py create mode 100644 backend/modules/crm/serializers.py create mode 100644 backend/modules/crm/services.py create mode 100644 backend/modules/crm/tests/test_models.py create mode 100644 backend/modules/crm/tests/test_serializers.py create mode 100644 backend/modules/crm/tests/test_services.py create mode 100644 backend/modules/crm/tests/test_views.py create mode 100644 backend/modules/crm/urls.py create mode 100644 backend/modules/crm/views.py create mode 100644 backend/modules/fleet/__init__.py create mode 100644 backend/modules/fleet/admin.py create mode 100644 backend/modules/fleet/apps.py create mode 100644 backend/modules/fleet/migrations/0001_initial.py create mode 100644 backend/modules/fleet/migrations/0002_alter_vehicle_assigned_servicer.py create mode 100644 backend/modules/fleet/migrations/0003_add_is_company_vehicle_and_client_fk.py create mode 100644 backend/modules/fleet/migrations/0004_vehicleserviceattachment.py create mode 100644 backend/modules/fleet/migrations/0005_vehiclenotification_metadata.py create mode 100644 backend/modules/fleet/migrations/0006_crane_proxy_model.py create mode 100644 backend/modules/fleet/migrations/0007_vehicle_asset_type.py create mode 100644 backend/modules/fleet/migrations/0008_backfill_asset_type_vehicle.py create mode 100644 backend/modules/fleet/migrations/0009_workorder_location_and_travel_times.py create mode 100644 backend/modules/fleet/migrations/0010_service_record_work_order_and_optional_mileage.py create mode 100644 backend/modules/fleet/migrations/0011_workorder_start_mileage_optional.py create mode 100644 backend/modules/fleet/migrations/0012_workorder_purpose_choices.py create mode 100644 backend/modules/fleet/migrations/0013_vehicleservicerecord_task.py create mode 100644 backend/modules/fleet/migrations/0014_servicerecord_remove_workorder.py create mode 100644 backend/modules/fleet/migrations/0015_workorderinvoice.py create mode 100644 backend/modules/fleet/migrations/0016_workorder_servicer_vehicle_fields.py create mode 100644 backend/modules/fleet/migrations/0017_vehicle_crane_serial_number.py create mode 100644 backend/modules/fleet/migrations/0018_vehicle_chassis_working_hours_and_more.py create mode 100644 backend/modules/fleet/migrations/0019_vehicleservicerecord_service_title.py create mode 100644 backend/modules/fleet/migrations/0020_alter_workorderinvoice_created_at_and_more.py create mode 100644 backend/modules/fleet/migrations/__init__.py create mode 100644 backend/modules/fleet/models.py create mode 100644 backend/modules/fleet/serializers.py create mode 100644 backend/modules/fleet/services.py create mode 100644 backend/modules/fleet/tasks.py create mode 100644 backend/modules/fleet/tests/conftest.py create mode 100644 backend/modules/fleet/tests/test_bulk_close_work_orders.py create mode 100644 backend/modules/fleet/tests/test_crane_serial_number.py create mode 100644 backend/modules/fleet/tests/test_delete_work_order.py create mode 100644 backend/modules/fleet/tests/test_integration.py create mode 100644 backend/modules/fleet/tests/test_models.py create mode 100644 backend/modules/fleet/tests/test_notifications.py create mode 100644 backend/modules/fleet/tests/test_photo_services.py create mode 100644 backend/modules/fleet/tests/test_pusher_service.py create mode 100644 backend/modules/fleet/tests/test_service_history_and_expenses.py create mode 100644 backend/modules/fleet/tests/test_service_photo_upload_to.py create mode 100644 backend/modules/fleet/tests/test_services.py create mode 100644 backend/modules/fleet/tests/trest_travel_expenses.py create mode 100644 backend/modules/fleet/urls.py create mode 100644 backend/modules/fleet/views.py create mode 100644 backend/modules/invoicing/__init__.py create mode 100644 backend/modules/invoicing/admin.py create mode 100644 backend/modules/invoicing/apps.py create mode 100644 backend/modules/invoicing/migrations/0001_initial.py create mode 100644 backend/modules/invoicing/migrations/0002_invoice_invoiceitem.py create mode 100644 backend/modules/invoicing/migrations/0003_invoicetransaction_delete_financialentry.py create mode 100644 backend/modules/invoicing/migrations/0004_invoicetransaction_payment_date_and_more.py create mode 100644 backend/modules/invoicing/migrations/0005_alter_invoicetransaction_payment_date.py create mode 100644 backend/modules/invoicing/migrations/0006_invoicetransaction_work_order.py create mode 100644 backend/modules/invoicing/migrations/__init__.py create mode 100644 backend/modules/invoicing/models.py create mode 100644 backend/modules/invoicing/serializers.py create mode 100644 backend/modules/invoicing/services.py create mode 100644 backend/modules/invoicing/tasks.py create mode 100644 backend/modules/invoicing/tests/conftest.py create mode 100644 backend/modules/invoicing/tests/test_api.py create mode 100644 backend/modules/invoicing/tests/test_models.py create mode 100644 backend/modules/invoicing/tests/test_permissions.py create mode 100644 backend/modules/invoicing/tests/test_serializers.py create mode 100644 backend/modules/invoicing/tests/test_services.py create mode 100644 backend/modules/invoicing/tests/test_tasks.py create mode 100644 backend/modules/invoicing/tests/test_utils.py create mode 100644 backend/modules/invoicing/tests/test_views.py create mode 100644 backend/modules/invoicing/urls.py create mode 100644 backend/modules/invoicing/utils.py create mode 100644 backend/modules/invoicing/views.py create mode 100644 backend/modules/services.py create mode 100644 backend/modules/task_management/__init__.py create mode 100644 backend/modules/task_management/admin.py create mode 100644 backend/modules/task_management/apps.py create mode 100644 backend/modules/task_management/migrations/0001_initial.py create mode 100644 backend/modules/task_management/migrations/0002_task_work_order.py create mode 100644 backend/modules/task_management/migrations/0003_task_vehicle.py create mode 100644 backend/modules/task_management/migrations/__init__.py create mode 100644 backend/modules/task_management/models.py create mode 100644 backend/modules/task_management/serializers.py create mode 100644 backend/modules/task_management/services.py create mode 100644 backend/modules/task_management/tasks.py create mode 100644 backend/modules/task_management/tests/test_models.py create mode 100644 backend/modules/task_management/tests/test_serializers.py create mode 100644 backend/modules/task_management/tests/test_services.py create mode 100644 backend/modules/task_management/tests/test_tasks.py create mode 100644 backend/modules/task_management/tests/test_views.py create mode 100644 backend/modules/task_management/urls.py create mode 100644 backend/modules/task_management/views.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/astro.config.mjs create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/public/offline.html create mode 100644 frontend/public/pwa-icon.svg create mode 100644 frontend/public/service-worker.js create mode 100644 frontend/src/components/AuthWidget.jsx create mode 100644 frontend/src/components/Navbar.jsx create mode 100644 frontend/src/components/NotificationBell.jsx create mode 100644 frontend/src/components/ToastContainer.jsx create mode 100644 frontend/src/components/ToastTrigger.jsx create mode 100644 frontend/src/components/atom/ButtonDisplayCounter.jsx create mode 100644 frontend/src/components/auth/LoginForm.jsx create mode 100644 frontend/src/components/crm/ClientsSection.jsx create mode 100644 frontend/src/components/dashboard/DashboardTopbar.jsx create mode 100644 frontend/src/components/dashboard/FleetDashboardShell.jsx create mode 100644 frontend/src/components/dashboard/ServiceContextSelector.jsx create mode 100644 frontend/src/components/dashboard/SyncLogPanel.jsx create mode 100644 frontend/src/components/dashboard/TaskCreateModal.jsx create mode 100644 frontend/src/components/dashboard/TaskServiceRecordsModal.jsx create mode 100644 frontend/src/components/dashboard/WorkOrderDetailModal.jsx create mode 100644 frontend/src/components/dashboard/WorkOrderImageCarousel.jsx create mode 100644 frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx create mode 100644 frontend/src/components/dashboard/WorkOrderModal.jsx create mode 100644 frontend/src/components/data/DataView.tsx create mode 100644 frontend/src/components/fleet/ServicePhotoUpload.jsx create mode 100644 frontend/src/components/fleet/ServiceRecordCreateButton.jsx create mode 100644 frontend/src/components/fleet/ServiceRecordDetailModal.jsx create mode 100644 frontend/src/components/fleet/ServiceRecordForm.jsx create mode 100644 frontend/src/components/invoicing/InvoiceForm.jsx create mode 100644 frontend/src/components/notifications/NotificationDetailModal.jsx create mode 100644 frontend/src/components/tasks/TaskForm.jsx create mode 100644 frontend/src/components/toast/ToastItem.tsx create mode 100644 frontend/src/components/toast/ToastProvider.tsx create mode 100644 frontend/src/components/ui/AnimatedDataTable.jsx create mode 100644 frontend/src/components/ui/AnimatedPage.jsx create mode 100644 frontend/src/components/ui/ModalShell.jsx create mode 100644 frontend/src/components/ui/NetworkGuard.jsx create mode 100644 frontend/src/components/ui/ThemeToggle.jsx create mode 100644 frontend/src/components/ui/Toast.jsx create mode 100644 frontend/src/components/ui/UserDisplay.jsx create mode 100644 frontend/src/config.css create mode 100644 frontend/src/hooks/useToast.ts create mode 100644 frontend/src/layouts/Layout.astro create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/db.ts create mode 100644 frontend/src/lib/displayIds.js create mode 100644 frontend/src/pages/clients.astro create mode 100644 frontend/src/pages/data/[...slug].astro create mode 100644 frontend/src/pages/index.astro create mode 100644 frontend/src/pages/login.astro create mode 100644 frontend/src/pages/putni-nalozi.astro create mode 100644 frontend/src/pages/putni-nalozi/racuni.astro create mode 100644 frontend/src/pages/service-recordsssss.astro create mode 100644 frontend/src/pages/servisni-zapisi.astro create mode 100644 frontend/src/pages/vehicles.astro create mode 100644 frontend/src/pages/work-orders.astro create mode 100644 frontend/src/services/apiClient.js create mode 100644 frontend/src/stores/authStore.js create mode 100644 frontend/src/stores/clientStore.js create mode 100644 frontend/src/stores/collectionStore.js create mode 100644 frontend/src/stores/counter.js create mode 100644 frontend/src/stores/craneSerialStore.js create mode 100644 frontend/src/stores/fleetDashboardStore.js create mode 100644 frontend/src/stores/invoiceStore.js create mode 100644 frontend/src/stores/networkStore.js create mode 100644 frontend/src/stores/notificationStore.js create mode 100644 frontend/src/stores/racunStore.js create mode 100644 frontend/src/stores/serviceContextStore.js create mode 100644 frontend/src/stores/taskStore.js create mode 100644 frontend/src/stores/toastStore.js create mode 100644 frontend/src/stores/todoStore.js create mode 100644 frontend/src/stores/uiStore.js create mode 100644 frontend/src/styles/global.css create mode 100644 frontend/tsconfig.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..24ab30c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,39 @@ +name: ERP CI Pipeline + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + services: + db: + image: postgres:16-alpine + env: + POSTGRES_DB: erp_db + POSTGRES_PASSWORD: password + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install -r backend/requirements.txt + pip install pytest pytest-django + + - name: Run Unit Tests + env: + DATABASE_URL: postgres://postgres:password@localhost:5432/erp_db + run: | + # Pokrećemo testove iz našeg backend direktorija + pytest backend/modules/invoicing/tests/test_services.py + + - name: Run Task Management Tests + env: + DATABASE_URL: postgres://postgres:password@localhost:5432/erp_db + run: | + pytest backend/modules/task_management/tests/test_services.py \ No newline at end of file diff --git a/.github/instructions/NotificationSystem.instructions.md b/.github/instructions/NotificationSystem.instructions.md new file mode 100644 index 0000000..ea95ac3 --- /dev/null +++ b/.github/instructions/NotificationSystem.instructions.md @@ -0,0 +1,8 @@ +--- +description: Describe when these instructions should be loaded by the agent based on task context +# applyTo: 'Describe when these instructions should be loaded by the agent based on task context' # when provided, instructions will automatically be added to the request context when the pattern matches an attached file +--- + + + +Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb8b2d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,211 @@ +# ---> Python +# Django +staticfiles/ + + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +#lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + + + +# build output +dist/ + +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ + +# Docker +docker-compose.yml.backup +docker-compose.override.yml +docker-compose.override.yml.backup \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..d097b60 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,20 @@ +env/ +venv/ +.venv/ +__pycache__/ +__pycache__ +*.pyc +*.pyo +*.pyd +.git/ +.gitignore +db.sqlite3 +.env +staticfiles/ +media/ +.vscode/ + +# Docker higijena (Dodano) +Dockerfile +.dockerignore +docker-compose*.yml \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..737773f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,35 @@ +# --- Stage 1: Builder (Ovdje se događa kompajliranje) --- +FROM python:3.11-slim AS builder + +# Instaliraj alate za build +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +# Instaliraj sve u /install direktorij +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + + +# --- Stage 2: Final (Samo čisti runtime) --- +FROM python:3.11-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=builder /install /usr/local +COPY . . + +# 1. Kreiraj korisnika i postavi dozvole dok si još ROOT +RUN useradd -m appuser && \ + mkdir -p /app/staticfiles && \ + chown -R appuser:appuser /app + +# 2. Sada prijeđi na ne-root korisnika +USER appuser + +RUN chmod +x entrypoint.sh +ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/base_models.py b/backend/core/base_models.py new file mode 100644 index 0000000..376c97d --- /dev/null +++ b/backend/core/base_models.py @@ -0,0 +1,34 @@ +import uuid +from django.db import models + +class BaseModel(models.Model): + """ + Industrijski standardni model za ERP entitete. + Svi modeli u našim modulima nasljeđuju ovu klasu. + """ + id = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False, + help_text="Unikatni identifikator entiteta (UUID)." + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Vrijeme kreiranja" + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="Vrijeme zadnje izmjene" + ) + is_active = models.BooleanField( + default=True, + verbose_name="Aktivan zapis" + ) + + class Meta: + abstract = True # Ovo je ključ: Django ne kreira tablicu za ovu klasu + ordering = ['-created_at'] + + def __str__(self): + # Defaultni prikaz koji olakšava debugging u adminu + return f"{self.__class__.__name__} ({str(self.id)[:8]})" \ No newline at end of file diff --git a/backend/core/celery.py b/backend/core/celery.py new file mode 100644 index 0000000..4af78a7 --- /dev/null +++ b/backend/core/celery.py @@ -0,0 +1,15 @@ +# backend/core/celery.py + +import os +from celery import Celery + +# Postavi Django settings modul +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +app = Celery('core') + +# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_' +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Automatski pronalazi taskove u svim 'modules.*' +app.autodiscover_tasks() \ No newline at end of file diff --git a/backend/core/management/__init__.py b/backend/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/management/commands/__init__.py b/backend/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/management/commands/seed_all_models.py b/backend/core/management/commands/seed_all_models.py new file mode 100644 index 0000000..b69308c --- /dev/null +++ b/backend/core/management/commands/seed_all_models.py @@ -0,0 +1,385 @@ +import random +import sys +import uuid +from datetime import timedelta +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.core.files.base import ContentFile +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone + +from modules.crm.models import Client +from modules.fleet.models import ( + Vehicle, + WorkOrder, + VehicleNotification, + VehicleServiceRecord, + VehicleServicePhoto, +) +from modules.invoicing.models import Invoice, InvoiceItem, InvoiceTransaction +from modules.task_management.models import Task + + +class Command(BaseCommand): + help = ( + "Generira demo podatke za sve domenske modele. " + "Ako --count nije zadan, komanda će u terminalu pitati količinu po modelu." + ) + + model_labels = [ + ("clients", "CRM klijenti"), + ("users", "korisnici"), + ("vehicles", "vozila"), + ("work_orders", "radni/putni nalozi"), + ("service_records", "servisni zapisi"), + ("notifications", "notifikacije"), + ("tasks", "task zapisi"), + ("invoices", "fakture"), + ("invoice_items", "stavke fakture"), + ("invoice_transactions", "transakcije fakture"), + ("service_photos", "fotografije servisa"), + ] + + def add_arguments(self, parser): + parser.add_argument( + "--count", + type=int, + default=None, + help="Količina zapisa za svaki model. Ako nije zadano, pita se po modelu.", + ) + + def handle(self, *args, **options): + global_count = options["count"] + if global_count is not None and global_count < 0: + raise CommandError("--count mora biti >= 0") + + counts = self._resolve_counts(global_count) + + self.stdout.write(self.style.MIGRATE_HEADING("Pokrećem generiranje demo podataka...")) + with transaction.atomic(): + clients = self._seed_clients(counts["clients"]) + users = self._seed_users(counts["users"], clients) + vehicles = self._seed_vehicles(counts["vehicles"], users) + work_orders = self._seed_work_orders(counts["work_orders"], vehicles, users) + tasks = self._seed_tasks(counts["tasks"], users, vehicles) + service_records = self._seed_service_records(counts["service_records"], users, tasks) + self._seed_notifications(counts["notifications"], users) + invoices = self._seed_invoices(counts["invoices"], users, clients) + self._seed_invoice_items(counts["invoice_items"], invoices) + self._seed_invoice_transactions(counts["invoice_transactions"], invoices) + self._seed_service_photos(counts["service_photos"], service_records, users) + + self.stdout.write(self.style.SUCCESS("Generiranje završeno.")) + + def _resolve_counts(self, global_count): + counts = {} + for key, label in self.model_labels: + counts[key] = self._ask_count(label, global_count) + return counts + + def _ask_count(self, label, global_count): + if global_count is not None: + return global_count + + if not sys.stdin.isatty(): + raise CommandError( + "Detektiran je non-interactive terminal. " + "Pokreni s --count (npr. `python manage.py seed_all_models --count 10`) " + "ili interaktivno s TTY (`docker compose exec -it backend python manage.py seed_all_models`)." + ) + + while True: + raw = input(f"Koliko zapisa želiš generirati za {label}? [0]: ").strip() + if raw == "": + return 0 + try: + value = int(raw) + if value < 0: + self.stdout.write(self.style.WARNING("Vrijednost mora biti >= 0.")) + continue + return value + except ValueError: + self.stdout.write(self.style.WARNING("Unesi cijeli broj.")) + + def _seed_clients(self, count): + created = [] + for i in range(count): + suffix = uuid.uuid4().hex[:8] + client = Client.objects.create( + name=f"Klijent {suffix}", + client_type=random.choice(["legal", "individual"]), + tax_id=f"{random.randint(10000000000, 99999999999)}", + email=f"client.{suffix}@example.test", + phone=f"+38591{random.randint(1000000, 9999999)}", + address=f"Ulica {random.randint(1, 120)}", + city=random.choice(["Zagreb", "Split", "Rijeka", "Osijek"]), + postal_code=str(random.randint(10000, 53000)), + ) + created.append(client) + + if created: + self.stdout.write(self.style.SUCCESS(f"Client: kreirano {len(created)}")) + return created or list(Client.objects.all()) + + def _seed_users(self, count, clients): + User = get_user_model() + created = [] + clients_pool = clients or list(Client.objects.all()) + + for i in range(count): + suffix = uuid.uuid4().hex[:8] + is_kupac = random.choice([True, False]) + client_profile = random.choice(clients_pool) if is_kupac and clients_pool else None + user = User.objects.create_user( + email=f"user.{suffix}@example.test", + password="password123", + first_name=f"Ime{i + 1}", + last_name=f"Prezime{i + 1}", + is_serviser=random.choice([True, False]), + is_team_member=random.choice([True, False]), + is_kupac=is_kupac if client_profile else False, + client_profile=client_profile, + telefon=f"+38595{random.randint(1000000, 9999999)}", + is_verified=random.choice([True, False]), + ) + created.append(user) + + if created: + self.stdout.write(self.style.SUCCESS(f"CustomUser: kreirano {len(created)}")) + return created or list(User.objects.all()) + + def _seed_vehicles(self, count, users): + created = [] + users_pool = [u for u in users if getattr(u, "is_serviser", False)] + assigned_servicer_ids = set( + Vehicle.objects.exclude(assigned_servicer__isnull=True).values_list("assigned_servicer_id", flat=True) + ) + clients_pool = list(Client.objects.all()) + + for _ in range(count): + reg = f"ZG-{random.randint(1000, 9999)}-{uuid.uuid4().hex[:2].upper()}" + available_servicers = [u for u in users_pool if u.id not in assigned_servicer_ids] + assigned = random.choice(available_servicers) if available_servicers and random.choice([True, False]) else None + asset_type = random.choice(["vehicle", "crane"]) + + # 40% vozila su klijentova (is_company_vehicle=False, client=random klijent) + is_company = random.random() > 0.4 + client_obj = None if is_company else (random.choice(clients_pool) if clients_pool else None) + + vehicle = Vehicle.objects.create( + asset_type=asset_type, + registration_number=reg, + crane_serial_number=f"{random.randint(0, 999999):06d}" if asset_type == "crane" else None, + make=random.choice(["VW", "Ford", "Mercedes", "Renault"]), + model=random.choice(["Model A", "Model B", "Model C"]), + year=random.randint(2008, timezone.now().year), + vin=f"VIN{uuid.uuid4().hex[:16].upper()}", + current_mileage=random.randint(10000, 250000), + service_interval_km=random.choice([10000, 15000, 20000]), + assigned_servicer=assigned, + is_company_vehicle=is_company, + client=client_obj, + ) + if assigned: + assigned_servicer_ids.add(assigned.id) + created.append(vehicle) + + if created: + self.stdout.write(self.style.SUCCESS(f"Vehicle: kreirano {len(created)} ({sum(1 for v in created if not v.is_company_vehicle)} klijentskih)")) + return created or list(Vehicle.objects.all()) + + def _seed_work_orders(self, count, vehicles, users): + created = [] + if not vehicles or not users: + return created + + for _ in range(count): + vehicle = random.choice(vehicles) + creator = random.choice(users) + start_mileage = max(0, vehicle.current_mileage - random.randint(0, 2000)) + is_closed = random.choice([True, False]) + end_mileage = start_mileage + random.randint(1, 600) if is_closed else None + status = "closed" if is_closed else "open" + + order = WorkOrder.objects.create( + vehicle=vehicle, + creator=creator, + start_mileage=start_mileage, + end_mileage=end_mileage, + purpose=random.choice(["Redovni pregled", "Hitna intervencija", "Kontrola"]), + notes="Auto-generirani demo nalog.", + status=status, + ) + created.append(order) + + if created: + self.stdout.write(self.style.SUCCESS(f"WorkOrder: kreirano {len(created)}")) + return created or list(WorkOrder.objects.all()) + + def _seed_service_records(self, count, users, tasks): + created = [] + if not users: + return created + + tasks_pool = tasks or list(Task.objects.filter(is_active=True)) + tasks_with_vehicle = [t for t in tasks_pool if t.vehicle_id] + + for _ in range(count): + if not tasks_with_vehicle: + break + task = random.choice(tasks_with_vehicle) + vehicle = task.vehicle + performed_by = random.choice(users) + mileage = max(vehicle.current_mileage, random.randint(5000, 280000)) + record = VehicleServiceRecord.objects.create( + vehicle=vehicle, + task=task, + performed_by=performed_by, + description=random.choice( + ["Zamjena ulja i filtera", "Kočnice i diskovi", "Dijagnostika motora", "Generalni servis"] + ), + parts="Filter ulja, ulje 5W30", + cost=Decimal(str(random.randint(50, 900))), + mileage=mileage, + next_service_due_at=mileage + random.choice([8000, 10000, 12000]), + ) + created.append(record) + + if created: + self.stdout.write(self.style.SUCCESS(f"VehicleServiceRecord: kreirano {len(created)}")) + return created or list(VehicleServiceRecord.objects.all()) + + def _seed_notifications(self, count, users): + created = [] + users_pool = users or list(get_user_model().objects.all()) + if not users_pool: + return created + + for _ in range(count): + notification = VehicleNotification.objects.create( + recipient=random.choice(users_pool), + title=random.choice(["Novi nalog", "Servis uskoro", "Status ažuriran"]), + message="Auto-generirana notifikacija za testiranje.", + level=random.choice(["info", "warning", "critical"]), + is_sent=random.choice([True, False]), + is_read=random.choice([True, False]), + ) + created.append(notification) + + if created: + self.stdout.write(self.style.SUCCESS(f"VehicleNotification: kreirano {len(created)}")) + return created + + def _seed_tasks(self, count, users, vehicles): + created = [] + users_pool = users or list(get_user_model().objects.all()) + # Preferiramo dizalice (asset_type='crane') za taskove + cranes_pool = [v for v in vehicles if getattr(v, 'asset_type', None) == 'crane'] + vehicles_pool = cranes_pool or vehicles or list(Vehicle.objects.filter(asset_type='crane')) + + for i in range(count): + status = random.choice(["aktivan", "servis"]) + vehicle = random.choice(vehicles_pool) if vehicles_pool else None + task = Task.objects.create( + title=f"Task {uuid.uuid4().hex[:6]}", + description="Auto-generirani zadatak.", + status=status, + assigned_to=random.choice(users_pool) if users_pool else None, + vehicle=vehicle, + ) + created.append(task) + + if created: + self.stdout.write(self.style.SUCCESS(f"Task: kreirano {len(created)}")) + return created + + def _seed_invoices(self, count, users, clients): + created = [] + users_pool = users or list(get_user_model().objects.all()) + clients_pool = clients or list(Client.objects.all()) + if not users_pool or not clients_pool: + return created + + for _ in range(count): + amount = Decimal(str(random.randint(100, 5000))) + invoice = Invoice.objects.create( + creator=random.choice(users_pool), + client=random.choice(clients_pool), + amount=amount, + currency="EUR", + tax_rate=Decimal("25.00"), + status=random.choice(["draft", "sent", "paid", "cancelled"]), + due_date=timezone.now().date() + timedelta(days=random.randint(7, 45)), + ) + created.append(invoice) + + if created: + self.stdout.write(self.style.SUCCESS(f"Invoice: kreirano {len(created)}")) + return created or list(Invoice.objects.all()) + + def _seed_invoice_items(self, count, invoices): + created = [] + invoices_pool = invoices or list(Invoice.objects.all()) + if not invoices_pool: + return created + + for _ in range(count): + item = InvoiceItem.objects.create( + invoice=random.choice(invoices_pool), + description=random.choice(["Servisna usluga", "Rezervni dio", "Terenski rad"]), + quantity=Decimal(str(random.randint(1, 5))), + unit_price=Decimal(str(random.randint(20, 700))), + ) + created.append(item) + + if created: + self.stdout.write(self.style.SUCCESS(f"InvoiceItem: kreirano {len(created)}")) + return created + + def _seed_invoice_transactions(self, count, invoices): + created = [] + invoices_pool = invoices or list(Invoice.objects.all()) + if not invoices_pool: + return created + + for _ in range(count): + invoice = random.choice(invoices_pool) + txn = InvoiceTransaction.objects.create( + invoice=invoice, + invoice_number=invoice.invoice_number, + amount=invoice.amount, + payment_date=timezone.now().date() - timedelta(days=random.randint(0, 30)), + ) + created.append(txn) + + if created: + self.stdout.write(self.style.SUCCESS(f"InvoiceTransaction: kreirano {len(created)}")) + return created + + def _seed_service_photos(self, count, service_records, users): + created = [] + records_pool = service_records or list(VehicleServiceRecord.objects.all()) + users_pool = users or list(get_user_model().objects.all()) + if not records_pool: + return created + + tiny_gif = ( + b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00" + b"\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00" + b"\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b" + ) + + for i in range(count): + photo = VehicleServicePhoto( + service_record=random.choice(records_pool), + uploaded_by=random.choice(users_pool) if users_pool else None, + description="Auto-generirana servisna fotografija.", + ) + photo.image.save(f"service_photo_{i}_{uuid.uuid4().hex[:6]}.gif", ContentFile(tiny_gif), save=True) + created.append(photo) + + if created: + self.stdout.write(self.style.SUCCESS(f"VehicleServicePhoto: kreirano {len(created)}")) + return created diff --git a/backend/core/management/commands/seed_fleet_assets.py b/backend/core/management/commands/seed_fleet_assets.py new file mode 100644 index 0000000..d81a914 --- /dev/null +++ b/backend/core/management/commands/seed_fleet_assets.py @@ -0,0 +1,99 @@ +import random +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from modules.fleet.models import Vehicle + + +class Command(BaseCommand): + help = "Kreira/demo-popunjava fleet assete: default 12 dizalica i 2 vozila." + + def add_arguments(self, parser): + parser.add_argument( + "--cranes", + type=int, + default=12, + help="Broj dizalica za kreirati/ažurirati (default: 12).", + ) + parser.add_argument( + "--vehicles", + type=int, + default=2, + help="Broj vozila za kreirati/ažurirati (default: 2).", + ) + + def handle(self, *args, **options): + cranes_count = options["cranes"] + vehicles_count = options["vehicles"] + if cranes_count < 0 or vehicles_count < 0: + raise CommandError("--cranes i --vehicles moraju biti >= 0") + + with transaction.atomic(): + created_cranes, updated_cranes = self._seed_assets( + asset_type="crane", + count=cranes_count, + reg_prefix="CRN", + make="Liebherr", + model="LTM", + mileage_start=12000, + service_interval=8000, + ) + created_vehicles, updated_vehicles = self._seed_assets( + asset_type="vehicle", + count=vehicles_count, + reg_prefix="VEH", + make="Mercedes", + model="Sprinter", + mileage_start=45000, + service_interval=15000, + ) + + self.stdout.write( + self.style.SUCCESS( + "Fleet seed završen: " + f"crane created={created_cranes}, updated={updated_cranes}; " + f"vehicle created={created_vehicles}, updated={updated_vehicles}" + ) + ) + + def _seed_assets( + self, + *, + asset_type, + count, + reg_prefix, + make, + model, + mileage_start, + service_interval, + ): + created = 0 + updated = 0 + + for index in range(1, count + 1): + reg = f"ZG-{reg_prefix}-{index:02d}" + vin = f"VIN-{reg_prefix}-{index:02d}" + defaults = { + "asset_type": asset_type, + "crane_serial_number": f"{random.randint(0, 999999):06d}" if asset_type == "crane" else None, + "make": make, + "model": f"{model}-{index:02d}", + "year": 2020 + (index % 4), + "vin": vin, + "current_mileage": mileage_start + (index * 1000), + "service_interval_km": service_interval, + "is_company_vehicle": True, + "client": None, + "assigned_servicer": None, + "is_active": True, + } + _, was_created = Vehicle.objects.update_or_create( + registration_number=reg, + defaults=defaults, + ) + if was_created: + created += 1 + else: + updated += 1 + + return created, updated diff --git a/backend/core/management/commands/seed_knezljubo.py b/backend/core/management/commands/seed_knezljubo.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/serializers.py b/backend/core/serializers.py new file mode 100644 index 0000000..b07a6b2 --- /dev/null +++ b/backend/core/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + +class BaseSerializer(serializers.ModelSerializer): + """ + Bazni serijalizator koji automatski uključuje ID i vremenske žigove. + Svi naši moduli će koristiti ovaj serializer umjesto DRF-ovog. + """ + id = serializers.UUIDField(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + updated_at = serializers.DateTimeField(read_only=True) + + class Meta: + # Ovo će biti nadjačano u konkretnim serializerima + fields = ['id', 'created_at', 'updated_at'] \ No newline at end of file diff --git a/backend/core/settings/__init__.py b/backend/core/settings/__init__.py new file mode 100644 index 0000000..3b98daf --- /dev/null +++ b/backend/core/settings/__init__.py @@ -0,0 +1,8 @@ +# backend/core/settings/__init__.py +import os + +# Provjeravamo varijablu okruženja koju Docker šalje +if os.environ.get('DEBUG') == 'False': + from .production import * +else: + from .development import * \ No newline at end of file diff --git a/backend/core/settings/base.py b/backend/core/settings/base.py new file mode 100644 index 0000000..ab9d1ea --- /dev/null +++ b/backend/core/settings/base.py @@ -0,0 +1,215 @@ +import os +from pathlib import Path +from datetime import timedelta + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +# --- SIGURNOST I OKRUŽENJE (Docker Environment Variables) --- +# Ako varijabla nije definirana u Dockeru, koristi se fallback (razvojni ključ) +SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-fk#ywtdyi8=t6^vdbo!=br8op0rdwf*ks#sji20&mp2+nwjs_5') + +# Docker prosljeđuje string 'True' ili 'False'. Pretvaramo ga u Python Boolean. +DEBUG = os.environ.get('DEBUG', 'True') == 'True' + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'corsheaders', + 'rest_framework', + 'django_extensions', + 'rest_framework_simplejwt', + 'modules.invoicing', + 'modules.task_management', + 'core.users.apps.UsersConfig', + 'modules.crm', + 'modules.fleet' +] +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', # Mora biti prvi + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', # Za statiku + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +] + +ROOT_URLCONF = 'core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'core.wsgi.application' + +# --- VALIDACIJA LOZINKI --- +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +AUTH_USER_MODEL = 'users.CustomUser' + + +# --- INTERNACIONALIZACIJA (Lokalizacija) --- +LANGUAGE_CODE = 'hr' # Postavljeno na hrvatski jezik za ERP/Zadatke + +TIME_ZONE = 'Europe/Zagreb' # Sinkronizirano s našom vremenskom zonom + +USE_I18N = True + +USE_TZ = True + + +CORS_ALLOW_CREDENTIALS = True +# U bazi definiramo default, a u prod/dev override-amo +CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:4321').split(',') + +CORS_ALLOW_METHODS = [ + "DELETE", + "GET", + "OPTIONS", + "PATCH", + "POST", + "PUT", +] + +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', + 'access-control-request-private-network', +] + +# Dopušta preflight zahtjeve unutar lokalne mreže (npr. s Docker mreže na tvoje računalo) +CORS_ALLOW_PRIVATE_NETWORK = True +CORS_PREFLIGHT_MAX_AGE = 86400 +# Dopusti slanje Authorization zaglavlja (JWT Tokena) kroz mrežne zahtjeve +CORS_ALLOW_CREDENTIALS = True + + +# --- REST FRAMEWORK & JWT POSTAVKE --- +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + # 'rest_framework.permissions.AllowAny', + ), +} + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, # Dodano + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', 'super-tajni-dugacki-kljuc-koji-ces-promijeniti') + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# ============================================================================== +# 🔥 CELERY & REDIS POSTAVKE (Lean Worker Model) +# ============================================================================== + +# Čitamo REDIS_URL iz okruženja (Docker prosljeđuje 'redis://redis:6379/0'). +# Ako pokrećeš izvan Dockera, koristi localhost kao fallback. +REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') + +CELERY_BROKER_URL = REDIS_URL +CELERY_RESULT_BACKEND = REDIS_URL + +# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse) +# Za ovo ti je potreban paket 'django-redis' u requirements.txt +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': REDIS_URL, # Koristi isti Redis kontejner i bazu /0 + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'PARSER_KWARGS': {'decode_responses': True}, + } + } +} + +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' + +# URL kojim će preglednik dohvaćati statiku +STATIC_URL = '/static/' + +# Direktorij gdje će 'collectstatic' skupljati sve statičke datoteke (za produkciju) +STATIC_ROOT = BASE_DIR / '/app/staticfiles' + +# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente) +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + + +# ============================================================================== +# 🔥 Paperless postavke +# ============================================================================== + + +PAPERLESS_BASE_URL = os.environ.get('PAPERLESS_BASE_URL', 'http://paperless:8000') # paperless na drugoj docker compose instanci +PAPERLESS_API_TOKEN = os.environ.get('PAPERLESS_API_TOKEN', '144b62542009c1c1a5a296b16601b4ab488f6a01') + +# ============================================================================== +# 🔥 Email postavke (Google) +# ============================================================================== + +# SMTP Konfiguracija +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True + +# Čitanje iz .env varijabli +EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', 'tvoj.email@gmail.com') +EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', 'xxxx-xxxx-xxxx-xxxx') +DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'tvoj.email@gmail.com') + +# ============================================================================== +# 🔥 PUSHER CONFIGURATION +# ============================================================================== + +# Pusher Configuration +PUSHER_APP_ID = os.environ.get('PUSHER_APP_ID') +PUSHER_KEY = os.environ.get('PUSHER_KEY') +PUSHER_SECRET = os.environ.get('PUSHER_SECRET') +PUSHER_CLUSTER = os.environ.get('PUSHER_CLUSTER', default='mt1') + +# Cache za fallback mehanizam +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': 'redis://127.0.0.1:6379/1', + } +} \ No newline at end of file diff --git a/backend/core/settings/development.py b/backend/core/settings/development.py new file mode 100644 index 0000000..ab6d84d --- /dev/null +++ b/backend/core/settings/development.py @@ -0,0 +1,36 @@ +from .base import * + +DEBUG = True +ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'backend'] + +# SQLite za brzi lokalni razvoj (nema potrebe za instalacijom Postgresa na laptopu) +# DATABASES = { +# 'default': { +# 'ENGINE': 'django.db.backends.sqlite3', +# 'NAME': BASE_DIR / 'db.sqlite3', +# } +# } + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ.get('DB_NAME'), + 'USER': os.environ.get('DB_USER'), + 'PASSWORD': os.environ.get('DB_PASSWORD'), + 'HOST': os.environ.get('DB_HOST', 'db'), + 'PORT': os.environ.get('DB_PORT', '5432'), + } +} + +# CORS za Astro razvojni port (4321) +CORS_ALLOWED_ORIGINS = [ + "http://localhost:4321", + "http://127.0.0.1:4321", +] + +# Dodajemo Debug Toolbar za analizu performansi +INSTALLED_APPS += ['debug_toolbar'] +MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] +INTERNAL_IPS = ['127.0.0.1'] + +CELERY_TASK_ALWAYS_EAGER = True \ No newline at end of file diff --git a/backend/core/settings/production.py b/backend/core/settings/production.py new file mode 100644 index 0000000..b377d1e --- /dev/null +++ b/backend/core/settings/production.py @@ -0,0 +1,28 @@ +from .base import * + +DEBUG = False +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',') + +# PostgreSQL za produkciju (industrijski standard) +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ.get('DB_NAME'), + 'USER': os.environ.get('DB_USER'), + 'PASSWORD': os.environ.get('DB_PASSWORD'), + 'HOST': os.environ.get('DB_HOST', 'db'), + 'PORT': os.environ.get('DB_PORT', '5432'), + } +} + +# Sigurnosne postavke +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# Produkcijski CORS (samo domena na kojoj vrti Astro) +CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',') + +# Statika i Mediji +STATIC_ROOT = '/app/staticfiles' +MEDIA_ROOT = '/app/media' \ No newline at end of file diff --git a/backend/core/urls.py b/backend/core/urls.py new file mode 100644 index 0000000..46b0cd9 --- /dev/null +++ b/backend/core/urls.py @@ -0,0 +1,33 @@ +# backend/core/urls.py +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import path, include +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, +) + +urlpatterns = [ + path('admin/', admin.site.urls), + + # Uključujemo rute iz modula (svaki modul ima svoj urls.py) + path('api/crm/', include('modules.crm.urls')), + path('api/invoicing/', include('modules.invoicing.urls')), + path('api/tasks/', include('modules.task_management.urls')), + path('api/fleet/', include('modules.fleet.urls')), + + # JWT Auth rute + path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + + # KORISNICI - registriramo ovdje + path('api/users/', include('core.users.urls')), +] + +if settings.DEBUG: + import debug_toolbar + urlpatterns += [ + path('__debug__/', include(debug_toolbar.urls)), + ] + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/backend/core/users/__init__.py b/backend/core/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/users/admin.py b/backend/core/users/admin.py new file mode 100644 index 0000000..20d2880 --- /dev/null +++ b/backend/core/users/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from .models import CustomUser + +@admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + model = CustomUser + # Definiramo koja polja se vide u admin listi i formama + list_display = ('email', 'first_name', 'last_name', 'occupation', 'work_position', 'residence', 'is_serviser', 'is_kupac', 'is_team_member', 'is_active', 'client_profile') + fieldsets = ( + (None, {'fields': ('email', 'password')}), + ('Osobni podaci', {'fields': ('first_name', 'last_name', 'telefon', 'oib', 'occupation', 'work_position', 'residence')}), + ('ERP Status', {'fields': ('is_active', 'is_staff', 'is_superuser', 'is_serviser', 'is_kupac', 'is_team_member', 'is_verified', 'client_profile')}), + ('Važni datumi', {'fields': ('last_login', 'date_joined')}), + ) + + # 3. Add fieldsets (za kreiranje novog korisnika u adminu) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password', 'is_team_member'), + }), + ) + + search_fields = ('email', 'first_name', 'last_name', 'occupation', 'work_position', 'residence') + ordering = ('email',) + raw_id_fields = ('client_profile',) diff --git a/backend/core/users/apps.py b/backend/core/users/apps.py new file mode 100644 index 0000000..30920fd --- /dev/null +++ b/backend/core/users/apps.py @@ -0,0 +1,7 @@ +# backend/core/users/apps.py +from django.apps import AppConfig + +class UsersConfig(AppConfig): + name = 'core.users' # Ovo je putanja do modula + label = 'users' # Ovo je kratko ime koje Django koristi (app_label) + verbose_name = 'User Management' \ No newline at end of file diff --git a/backend/core/users/management/__init__.py b/backend/core/users/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/users/management/commands/__init__.py b/backend/core/users/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/users/management/commands/seed_all_models.py b/backend/core/users/management/commands/seed_all_models.py new file mode 100644 index 0000000..4449128 --- /dev/null +++ b/backend/core/users/management/commands/seed_all_models.py @@ -0,0 +1,2 @@ +from core.management.commands.seed_all_models import Command # noqa: F401 + diff --git a/backend/core/users/management/commands/seed_fleet_assets.py b/backend/core/users/management/commands/seed_fleet_assets.py new file mode 100644 index 0000000..892e8aa --- /dev/null +++ b/backend/core/users/management/commands/seed_fleet_assets.py @@ -0,0 +1,2 @@ +from core.management.commands.seed_fleet_assets import Command # noqa: F401 + diff --git a/backend/core/users/management/commands/seed_knezljubo.py b/backend/core/users/management/commands/seed_knezljubo.py new file mode 100644 index 0000000..76ba4cd --- /dev/null +++ b/backend/core/users/management/commands/seed_knezljubo.py @@ -0,0 +1,107 @@ +""" +seed_knezljubo.py + +Kreira demo klijenta "Knezljubo d.o.o." s jednim fleet vozilom (is_company_vehicle=True) +i jednim vozilom u vlasništvu klijenta (is_company_vehicle=False). + +Idempotentna: ponavljanjem ne kreira duplikate (koristi get_or_create). + +Uporaba: + python manage.py seed_knezljubo + docker compose exec -it backend python manage.py seed_knezljubo +""" + +from django.core.management.base import BaseCommand +from django.db import transaction + +from modules.crm.models import Client +from modules.fleet.models import Vehicle + + +KNEZLJUBO_TAX_ID = "12345678901" +FLEET_VIN = "VIN_FLEET_KNEZLJUBO001" +CLIENT_VIN = "VIN_CLIENT_KNEZLJUBO002" + + +class Command(BaseCommand): + help = "Kreira klijenta Knezljubo s fleet vozilom i vozilom u vlasništvu klijenta." + + def handle(self, *args, **options): + with transaction.atomic(): + client = self._seed_client() + fleet_vehicle = self._seed_fleet_vehicle(client) + client_vehicle = self._seed_client_vehicle(client) + + self.stdout.write(self.style.SUCCESS( + f"\nZavršeno!\n" + f" Klijent : {client} (id={client.id})\n" + f" Fleet vozilo : {fleet_vehicle} [is_company_vehicle=True]\n" + f" Klijentovo : {client_vehicle} [is_company_vehicle=False]\n" + )) + + # ── Klijent ─────────────────────────────────────────────────────────────── + + def _seed_client(self) -> Client: + client, created = Client.objects.get_or_create( + tax_id=KNEZLJUBO_TAX_ID, + defaults={ + "name": "Knezljubo d.o.o.", + "client_type": "legal", + "email": "info@knezljubo.hr", + "phone": "+385912345678", + "address": "Ilica 100", + "city": "Zagreb", + "postal_code": "10000", + }, + ) + action = "Kreiran" if created else "Već postoji" + self.stdout.write(self.style.SUCCESS(f"Client: {action} — {client}")) + return client + + # ── Fleet vozilo (in vlasništvu naše tvrtke, ali vezano za klijenta) ────── + + def _seed_fleet_vehicle(self, client: Client) -> Vehicle: + """ + Vozilo je dio naše fleet-a (is_company_vehicle=True) ALI je operativno + vezano za klijenta Knezljubo — npr. vozilo koje servisiramo po ugovoru. + """ + vehicle, created = Vehicle.objects.get_or_create( + vin=FLEET_VIN, + defaults={ + "registration_number": "ZG-0001-KZ", + "make": "Mercedes", + "model": "Sprinter", + "year": 2021, + "current_mileage": 48500, + "service_interval_km": 15000, + "is_company_vehicle": True, # dio naše flote + "client": client, # operativno vezan za klijenta + }, + ) + action = "Kreiran" if created else "Već postoji" + self.stdout.write(self.style.SUCCESS(f"Fleet vozilo: {action} — {vehicle}")) + return vehicle + + # ── Vozilo u vlasništvu klijenta ────────────────────────────────────────── + + def _seed_client_vehicle(self, client: Client) -> Vehicle: + """ + Vozilo koje KLIJENT posjeduje — naša tvrtka ga servisira. + is_company_vehicle=False → filter u admin i frontend pokazuje ovo kao klijentovo. + """ + vehicle, created = Vehicle.objects.get_or_create( + vin=CLIENT_VIN, + defaults={ + "registration_number": "ZG-0002-KZ", + "make": "VW", + "model": "Crafter", + "year": 2019, + "current_mileage": 112000, + "service_interval_km": 10000, + "is_company_vehicle": False, # klijentovo vozilo + "client": client, + }, + ) + action = "Kreiran" if created else "Već postoji" + self.stdout.write(self.style.SUCCESS(f"Klijentovo vozilo: {action} — {vehicle}")) + return vehicle diff --git a/backend/core/users/management/commands/seed_users.py b/backend/core/users/management/commands/seed_users.py new file mode 100644 index 0000000..ab71ead --- /dev/null +++ b/backend/core/users/management/commands/seed_users.py @@ -0,0 +1,52 @@ +# backend/core/users/management/commands/seed_users.py +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from django.db import transaction +from modules.crm.models import Client + +User = get_user_model() + +class Command(BaseCommand): + help = 'Kreira testne korisnike za razvojno okruženje' + + def handle(self, *args, **kwargs): + users_to_create = [ + { + "email": "serviser@primjer.hr", + "first_name": "Ivan", + "last_name": "Serviser", + "is_serviser": True, + "is_team_member": True, + "password": "password123" + }, + { + "email": "kupac@primjer.hr", + "first_name": "Marko", + "last_name": "Kupac", + "is_kupac": True, + "password": "password123" + } + ] + + with transaction.atomic(): + for user_data in users_to_create: + email = user_data.pop('email') + password = user_data.pop('password') + + if user_data.get('is_kupac'): + client, _ = Client.objects.get_or_create(name="Testni Klijent DOO") + user_data['client_profile'] = client + + # Provjeri postoji li korisnik + if not User.objects.filter(email=email).exists(): + # KORISTI MANAGER UMJESTO MODELA + user = User.objects.create_user( + email=email, + password=password, + **user_data + ) + self.stdout.write(self.style.SUCCESS(f'Kreiran korisnik: {email}')) + else: + self.stdout.write(self.style.WARNING(f'Korisnik već postoji: {email}')) + + self.stdout.write(self.style.SUCCESS('Seeding završen!')) \ No newline at end of file diff --git a/backend/core/users/managers.py b/backend/core/users/managers.py new file mode 100644 index 0000000..a2afdeb --- /dev/null +++ b/backend/core/users/managers.py @@ -0,0 +1,30 @@ +from django.contrib.auth.models import BaseUserManager + +class CustomUserManager(BaseUserManager): + """ + Custom manager za User model gdje koristimo email kao identifikator. + """ + def create_user(self, email, password=None, **extra_fields): + if not email: + raise ValueError('Email adresa mora biti zadana.') + + extra_fields.pop('username', None) + + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, password=None, **extra_fields): + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + extra_fields.setdefault('is_active', True) + extra_fields.setdefault('is_serviser', True) + + if extra_fields.get('is_staff') is not True: + raise ValueError('Superuser mora imati is_staff=True.') + if extra_fields.get('is_superuser') is not True: + raise ValueError('Superuser mora imati is_superuser=True.') + + return self.create_user(email, password, **extra_fields) \ No newline at end of file diff --git a/backend/core/users/migrations/0001_initial.py b/backend/core/users/migrations/0001_initial.py new file mode 100644 index 0000000..0807e52 --- /dev/null +++ b/backend/core/users/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.15 on 2026-06-29 13:34 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=254, unique=True)), + ('telefon', models.CharField(blank=True, max_length=20)), + ('oib', models.CharField(blank=True, max_length=11, null=True)), + ('is_serviser', models.BooleanField(default=False)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/backend/core/users/migrations/0002_alter_customuser_managers_customuser_client_profile_and_more.py b/backend/core/users/migrations/0002_alter_customuser_managers_customuser_client_profile_and_more.py new file mode 100644 index 0000000..a258323 --- /dev/null +++ b/backend/core/users/migrations/0002_alter_customuser_managers_customuser_client_profile_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.15 on 2026-06-29 20:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '__first__'), + ('users', '0001_initial'), + ] + + operations = [ + migrations.AlterModelManagers( + name='customuser', + managers=[ + ], + ), + migrations.AddField( + model_name='customuser', + name='client_profile', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='crm.client', verbose_name='Povezani klijent'), + ), + migrations.AddField( + model_name='customuser', + name='is_kupac', + field=models.BooleanField(default=False, verbose_name='Kupac'), + ), + migrations.AddField( + model_name='customuser', + name='is_verified', + field=models.BooleanField(default=False, verbose_name='Verificiran profil'), + ), + migrations.AddField( + model_name='customuser', + name='licence_number', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Broj licence'), + ), + ] diff --git a/backend/core/users/migrations/0003_remove_customuser_username.py b/backend/core/users/migrations/0003_remove_customuser_username.py new file mode 100644 index 0000000..6ef42f9 --- /dev/null +++ b/backend/core/users/migrations/0003_remove_customuser_username.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.15 on 2026-06-30 06:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_alter_customuser_managers_customuser_client_profile_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='customuser', + name='username', + ), + ] diff --git a/backend/core/users/migrations/0004_customuser_is_team_member.py b/backend/core/users/migrations/0004_customuser_is_team_member.py new file mode 100644 index 0000000..09f47d0 --- /dev/null +++ b/backend/core/users/migrations/0004_customuser_is_team_member.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.15 on 2026-07-06 14:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_remove_customuser_username'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='is_team_member', + field=models.BooleanField(default=False, verbose_name='Član tima'), + ), + ] + diff --git a/backend/core/users/migrations/0005_customuser_occupation_residence.py b/backend/core/users/migrations/0005_customuser_occupation_residence.py new file mode 100644 index 0000000..cdfd631 --- /dev/null +++ b/backend/core/users/migrations/0005_customuser_occupation_residence.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_customuser_is_team_member'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='occupation', + field=models.CharField(blank=True, default='', max_length=120, verbose_name='Zanimanje'), + ), + migrations.AddField( + model_name='customuser', + name='residence', + field=models.CharField(blank=True, default='', max_length=255, verbose_name='Prebivalište'), + ), + ] diff --git a/backend/core/users/migrations/0006_customuser_work_position.py b/backend/core/users/migrations/0006_customuser_work_position.py new file mode 100644 index 0000000..ff69a26 --- /dev/null +++ b/backend/core/users/migrations/0006_customuser_work_position.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_customuser_occupation_residence'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='work_position', + field=models.CharField(blank=True, default='', max_length=120, verbose_name='Na radnom mjestu'), + ), + ] diff --git a/backend/core/users/migrations/__init__.py b/backend/core/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/users/models.py b/backend/core/users/models.py new file mode 100644 index 0000000..abd6880 --- /dev/null +++ b/backend/core/users/models.py @@ -0,0 +1,52 @@ +from django.contrib.auth.models import AbstractUser +from django.core.exceptions import ValidationError +from django.db import models +from .managers import CustomUserManager + +class CustomUser(AbstractUser): + # Email koristimo za login, pa mora biti jedinstven + username=None + email = models.EmailField(unique=True) + + # ERP Specifična polja + is_serviser = models.BooleanField(default=False) # Razlikovanje uloga + is_kupac = models.BooleanField(default=False, verbose_name="Kupac") + is_team_member = models.BooleanField(default=False, verbose_name="Član tima") + + # Dodatne informacije o servisera + telefon = models.CharField(max_length=20, blank=True) + oib = models.CharField(max_length=11, blank=True, null=True) + occupation = models.CharField(max_length=120, blank=True, default='', verbose_name="Zanimanje") + residence = models.CharField(max_length=255, blank=True, default='', verbose_name="Prebivalište") + work_position = models.CharField(max_length=120, blank=True, default='', verbose_name="Na radnom mjestu") + licence_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Broj licence") + is_verified = models.BooleanField(default=False, verbose_name="Verificiran profil") + + # Poveznica na klijenta (ako je korisnik kupac) + client_profile = models.ForeignKey( + 'crm.Client', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='users', # Omogućuje pristup: klijent.users.all() + verbose_name="Povezani klijent" + ) + + objects = CustomUserManager() + # Govorimo Djangu da koristi email umjesto username-a + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['first_name', 'last_name'] + + def __str__(self): + return f"{self.first_name} {self.last_name} ({self.email})" + + def clean(self): + # if self.email == "bot@erp.hr": + # return # Preskoči validaciju za bota + super().clean() + if self.is_kupac and not self.client_profile: + raise ValidationError({'client_profile': "Korisnik koji je označen kao kupac mora imati povezan klijentski profil."}) + + def save(self, *args, **kwargs): + self.full_clean() # Prisiljava pokretanje clean() prije svakog save() + super().save(*args, **kwargs) \ No newline at end of file diff --git a/backend/core/users/serializers.py b/backend/core/users/serializers.py new file mode 100644 index 0000000..9106620 --- /dev/null +++ b/backend/core/users/serializers.py @@ -0,0 +1,73 @@ +from rest_framework import serializers +from django.core.exceptions import ObjectDoesNotExist +from .models import CustomUser + +class UserSerializer(serializers.ModelSerializer): + assigned_vehicle = serializers.SerializerMethodField() + assigned_crane = serializers.SerializerMethodField() + + class Meta: + model = CustomUser + fields = ['id', 'email', 'first_name', 'last_name', 'is_serviser', 'is_kupac', 'is_team_member', 'telefon', 'oib', 'occupation', 'residence', 'work_position', 'client_profile', 'assigned_vehicle', 'assigned_crane'] + read_only_fields = ['id', 'email'] + extra_kwargs = { + 'first_name': {'required': True, 'allow_blank': False} + } + + def _get_assigned_asset(self, obj): + try: + return obj.assigned_vehicles + except ObjectDoesNotExist: + return None + + def _serialize_asset(self, vehicle): + if not vehicle: + return None + return { + 'id': vehicle.id, + 'asset_type': vehicle.asset_type, + 'registration_number': vehicle.registration_number, + 'make': vehicle.make, + 'model': vehicle.model, + 'current_mileage': vehicle.current_mileage, + 'service_interval_km': vehicle.service_interval_km, + } + + def get_assigned_vehicle(self, obj): + return self._serialize_asset(self._get_assigned_asset(obj)) + + def get_assigned_crane(self, obj): + return self._serialize_asset(self._get_assigned_asset(obj)) + + def validate(self, data): + """ + Provodi poslovnu logiku na razini serializer-a. + """ + is_kupac = data.get('is_kupac') + client_profile = data.get('client_profile') + + # Logika: Ako je kupac, mora imati profil + if is_kupac and not client_profile: + raise serializers.ValidationError({ + "client_profile": "Korisnik označen kao kupac mora imati dodijeljen klijentski profil." + }) + + # Logika: Ako ima profil, trebao bi biti kupac (opcionalno, ali preporučeno) + if client_profile and not is_kupac: + raise serializers.ValidationError({ + "is_kupac": "Korisnik s dodijeljenim klijentskim profilom mora biti označen kao kupac." + }) + + return data + + +class TeamMemberSerializer(serializers.ModelSerializer): + full_name = serializers.SerializerMethodField() + + class Meta: + model = CustomUser + fields = ['id', 'email', 'full_name', 'is_team_member'] + read_only_fields = fields + + def get_full_name(self, obj): + return obj.get_full_name() or obj.email \ No newline at end of file diff --git a/backend/core/users/services.py b/backend/core/users/services.py new file mode 100644 index 0000000..175f01e --- /dev/null +++ b/backend/core/users/services.py @@ -0,0 +1,35 @@ +# backend/core/users/services.py + +import logging +from django.contrib.auth import get_user_model +from rest_framework.exceptions import ValidationError +from .serializers import UserSerializer + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from .models import CustomUser as User +else: + User = get_user_model() + +logger = logging.getLogger(__name__) + +class UserService: + @staticmethod + def update_user_profile(user, data: dict) -> User: + """ + Ažurira profil koristeći serializer za validaciju. + Ovo osigurava da poslovna pravila definirana u serializeru + budu poštivana i kod programskog ažuriranja. + """ + # Koristimo serializer za validaciju dolaznih podataka + serializer = UserSerializer(user, data=data, partial=True) + + if not serializer.is_valid(): + logger.error(f"Neuspješna validacija profila korisnika {user.id}: {serializer.errors}") + raise ValidationError(serializer.errors) + + # Spremanje validiranih podataka + updated_user = serializer.save() + logger.info(f"Korisnik {user.id} je uspješno ažurirao profil.") + + return updated_user \ No newline at end of file diff --git a/backend/core/users/tests/test_models.py b/backend/core/users/tests/test_models.py new file mode 100644 index 0000000..f511742 --- /dev/null +++ b/backend/core/users/tests/test_models.py @@ -0,0 +1,25 @@ +import pytest +from django.contrib.auth import get_user_model + +User = get_user_model() + +@pytest.mark.django_db +def test_create_user(): + """Testira kreiranje standardnog korisnika.""" + user = User.objects.create_user( + email="test@example.com", + password="password123" + ) + assert user.email == "test@example.com" + assert user.check_password("password123") + assert user.is_active is True + +@pytest.mark.django_db +def test_create_superuser(): + """Testira kreiranje superuser-a.""" + admin = User.objects.create_superuser( + email="admin@example.com", + password="password123" + ) + assert admin.is_superuser is True + assert admin.is_staff is True \ No newline at end of file diff --git a/backend/core/users/tests/test_permissions.py b/backend/core/users/tests/test_permissions.py new file mode 100644 index 0000000..690fcfa --- /dev/null +++ b/backend/core/users/tests/test_permissions.py @@ -0,0 +1,37 @@ +# backend/core/users/tests/test_permissions.py + +import pytest +from rest_framework import status +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model + +User = get_user_model() + +@pytest.mark.django_db +class TestUserPermissions: + + def setup_method(self): + self.client = APIClient() + # Kreiramo dva korisnika + self.user1 = User.objects.create_user(email="user1@erp.hr", password="password123") + self.user2 = User.objects.create_user(email="user2@erp.hr", password="password123") + + def test_user_cannot_edit_other_user(self): + """Osigurava da user1 ne može mijenjati podatke od user2.""" + self.client.force_authenticate(user=self.user1) + + # Pokušavamo patchati user2 (pretpostavljamo URL /api/users/{id}/) + response = self.client.patch(f'/api/users/{self.user2.id}/', {"first_name": "Haker"}) + + # Trebali bismo dobiti 403 Forbidden ili 404 (ovisno kako skrivaš postojanje drugih) + assert response.status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND] + + def test_authenticated_user_can_edit_self(self): + """Osigurava da user1 može mijenjati svoje podatke.""" + self.client.force_authenticate(user=self.user1) + + response = self.client.patch(f'/api/users/{self.user1.id}/', {"first_name": "Ime"}) + + assert response.status_code == status.HTTP_200_OK + self.user1.refresh_from_db() + assert self.user1.first_name == "Ime" \ No newline at end of file diff --git a/backend/core/users/tests/test_seed_all_models_command.py b/backend/core/users/tests/test_seed_all_models_command.py new file mode 100644 index 0000000..e531d06 --- /dev/null +++ b/backend/core/users/tests/test_seed_all_models_command.py @@ -0,0 +1,56 @@ +import uuid + +import pytest +from django.contrib.auth import get_user_model + +from core.management.commands.seed_all_models import Command +from modules.fleet.models import Vehicle, VehicleServiceRecord +from modules.task_management.models import Task + + +@pytest.mark.django_db +def test_seed_service_records_creates_record_with_task_and_task_vehicle(): + user = get_user_model().objects.create_user( + email=f"seed-test-{uuid.uuid4().hex[:8]}@example.test", + password="pass12345", + ) + vehicle = Vehicle.objects.create( + registration_number=f"ST-{uuid.uuid4().hex[:6].upper()}", + asset_type="crane", + ) + task = Task.objects.create( + title=f"Task {uuid.uuid4().hex[:6]}", + status="aktivan", + vehicle=vehicle, + assigned_to=user, + ) + + cmd = Command() + before = VehicleServiceRecord.objects.count() + cmd._seed_service_records(1, [user], [task]) + + assert VehicleServiceRecord.objects.count() == before + 1 + record = VehicleServiceRecord.objects.order_by("-created_at").first() + assert record is not None + assert record.task_id == task.id + assert record.vehicle_id == task.vehicle_id + + +@pytest.mark.django_db +def test_seed_service_records_skips_tasks_without_vehicle(): + user = get_user_model().objects.create_user( + email=f"seed-test-{uuid.uuid4().hex[:8]}@example.test", + password="pass12345", + ) + task = Task.objects.create( + title=f"Task {uuid.uuid4().hex[:6]}", + status="aktivan", + vehicle=None, + assigned_to=user, + ) + + cmd = Command() + before = VehicleServiceRecord.objects.count() + cmd._seed_service_records(1, [user], [task]) + + assert VehicleServiceRecord.objects.count() == before diff --git a/backend/core/users/tests/test_services.py b/backend/core/users/tests/test_services.py new file mode 100644 index 0000000..ffb333c --- /dev/null +++ b/backend/core/users/tests/test_services.py @@ -0,0 +1,35 @@ +import pytest +from django.core.exceptions import ValidationError as DjangoValidationError +from rest_framework.exceptions import ValidationError as DRFValidationError +from core.users.services import UserService +from django.contrib.auth import get_user_model + +User = get_user_model() + +@pytest.mark.django_db +class TestUserService: + + def setup_method(self): + self.user = User.objects.create_user( + email="servis@primjer.hr", + password="password123" + ) + + def test_update_profile_success(self): + """Testira uspješno ažuriranje profila preko servisa.""" + new_data = {"first_name": "Novi", "last_name": "Korisnik"} + + updated_user = UserService.update_user_profile(self.user, new_data) + + assert updated_user.first_name == "Novi" + assert updated_user.last_name == "Korisnik" + # Provjera da email nije promijenjen ako nismo poslali + assert updated_user.email == "servis@primjer.hr" + + def test_update_profile_invalid_data(self): + """Testira validaciju (npr. neispravan format podataka).""" + # Pretpostavimo da dodamo logiku u serializer koja brani prazno ime + invalid_data = {"first_name": ""} + + with pytest.raises(DRFValidationError): + UserService.update_user_profile(self.user, invalid_data) \ No newline at end of file diff --git a/backend/core/users/tests/test_users.py b/backend/core/users/tests/test_users.py new file mode 100644 index 0000000..d84ce7a --- /dev/null +++ b/backend/core/users/tests/test_users.py @@ -0,0 +1,61 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model + +User = get_user_model() + +@pytest.mark.django_db +class TestUserAPI: + + def setup_method(self): + self.client = APIClient() + self.user_data = { + "email": "test@primjer.hr", + "first_name": "Test", + "last_name": "Korisnik", + "password": "strongpassword123" + } + self.user = User.objects.create_user(**self.user_data) + + def test_get_me_authenticated(self): + """Testira dohvat vlastitih podataka (uspjeh).""" + self.client.force_authenticate(user=self.user) + url = reverse('user-me') # Provjeri naziv u routeru + + response = self.client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['email'] == self.user.email + assert 'is_serviser' in response.data + + def test_get_me_unauthenticated(self): + """Testira pristup bez prijave (neuspjeh).""" + url = reverse('user-me') + + response = self.client.get(url) + + # Ovisno o tvojim permission_classes, ovdje bi trebalo biti 401 ili 403 + assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN] + + def test_user_creation(self): + """Testira osnovno stvaranje korisnika u bazi.""" + assert User.objects.count() == 1 + assert self.user.is_serviser is False + + def test_update_user_via_api(self): + """Testira cjelokupan tok: API -> ViewSet -> Service.""" + self.client.force_authenticate(user=self.user) + url = reverse('user-detail', kwargs={'pk': self.user.id}) # Provjeri basename + + new_data = {"first_name": "Inženjer"} + + # Simuliramo PATCH zahtjev + response = self.client.patch(url, new_data) + + # Osvježi instancu iz baze + self.user.refresh_from_db() + + assert response.status_code == status.HTTP_200_OK + assert self.user.first_name == "Inženjer" \ No newline at end of file diff --git a/backend/core/users/urls.py b/backend/core/users/urls.py new file mode 100644 index 0000000..2c70b25 --- /dev/null +++ b/backend/core/users/urls.py @@ -0,0 +1,13 @@ +# backend/core/users/urls.py +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import UserViewSet + +router = DefaultRouter() +# Registriramo UserViewSet pod imenom 'users' +# Ovo će generirati rute poput /api/users/me/ +router.register(r'', UserViewSet, basename='user') + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/backend/core/users/views.py b/backend/core/users/views.py new file mode 100644 index 0000000..1cd6468 --- /dev/null +++ b/backend/core/users/views.py @@ -0,0 +1,44 @@ +# backend/core/users/views.py + +from rest_framework import viewsets, permissions +from rest_framework.decorators import action +from rest_framework.response import Response +from django.contrib.auth import get_user_model +from .serializers import TeamMemberSerializer, UserSerializer +from .services import UserService + +User = get_user_model() + +class IsOwner(permissions.BasePermission): + """Prilagođeni permission: Korisnik može uređivati samo sebe.""" + def has_object_permission(self, request, view, obj): + return obj.id == request.user.id + +class UserViewSet(viewsets.ModelViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + @action(detail=False, methods=['get']) + def me(self, request): + serializer = self.get_serializer(request.user) + return Response(serializer.data) + + @action(detail=False, methods=['get'], url_path='team-members') + def team_members(self, request): + queryset = User.objects.filter(is_active=True, is_team_member=True).order_by('first_name', 'last_name', 'email') + serializer = TeamMemberSerializer(queryset, many=True) + return Response(serializer.data) + + def perform_update(self, serializer): + # Delegiramo logiku servisu + UserService.update_user_profile( + user=self.request.user, + data=serializer.validated_data + ) + + def get_queryset(self): + # Korisnik vidi samo sebe (ili admini vide sve) + if self.request.user.is_staff: + return User.objects.all() + return User.objects.filter(id=self.request.user.id) \ No newline at end of file diff --git a/backend/core/utils.py b/backend/core/utils.py new file mode 100644 index 0000000..c24b522 --- /dev/null +++ b/backend/core/utils.py @@ -0,0 +1,20 @@ +# backend/core/utils.py + +from datetime import datetime +from decimal import Decimal, InvalidOperation + +def format_currency(value: str) -> Decimal: + """Standardizirano pretvaranje valuta iz stringa u Decimal.""" + try: + # Čišćenje stringa (npr. uklanjanje 'kn' ili '.' separatora) + clean_value = value.replace(',', '.').replace(' ', '') + return Decimal(clean_value) + except (InvalidOperation, ValueError): + return Decimal('0.00') + +def parse_iso_date(date_str: str): + """Standardizirano parsiranje datuma za cijeli sustav.""" + try: + return datetime.fromisoformat(date_str.replace('Z', '+00:00')) + except (ValueError, TypeError): + return None \ No newline at end of file diff --git a/backend/core/validators.py b/backend/core/validators.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/wsgi.py b/backend/core/wsgi.py new file mode 100644 index 0000000..6d36530 --- /dev/null +++ b/backend/core/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for core project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_wsgi_application() diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..2e20369 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,101 @@ +#!/bin/sh +set -e + +export PATH="/usr/local/bin:$PATH" + +APP_DIR="/app" +APP_USER="appuser" +APP_UID=1000 +STATIC_ROOT="${STATIC_ROOT:-/app/staticfiles}" +MEDIA_ROOT="${MEDIA_ROOT:-/app/media}" + +# Kreiraj korisnika 'appuser' s UID 1000 ako ne postoji +if ! getent passwd "${APP_USER}" > /dev/null; then + echo "--> Kreiram ${APP_USER}..." + addgroup --system --gid ${APP_UID} ${APP_USER} || true + adduser --system --uid ${APP_UID} --ingroup ${APP_USER} --no-create-home --disabled-login ${APP_USER} || true +fi + +# Ako smo root, pokušaj osigurati ownership. Ako nismo root, samo logiramo i nastavljamo. +if [ "$(id -u)" -eq 0 ]; then + echo "--> (root) Kreiram/ajustiram direktorije i dozvole..." + mkdir -p "${STATIC_ROOT}" "${MEDIA_ROOT}" "${APP_DIR}" + # pokušaj chown, ali ne izlazi na grešku ako neuspije (možda host FS to ne dozvoljava) + if chown -R ${APP_USER}:${APP_USER} "${STATIC_ROOT}" "${MEDIA_ROOT}" "${APP_DIR}"; then + echo "--> Chown uspješan." + else + echo "--> Upozorenje: chown nije uspio (moguće root_squash ili mount bez chown dopuštenja)." + fi +else + echo "--> Nismo root (uid=$(id -u)), preskačem chown. Provjeri vlasništvo na hostu ako su problemi s permisijama." +fi + +# Helper: run command as appuser but return to the script (do not replace shell) +run_as_appuser_now() { + if command -v gosu >/dev/null 2>&1; then + gosu ${APP_USER} "$@" + elif command -v su-exec >/dev/null 2>&1; then + su-exec ${APP_USER} "$@" + elif command -v runuser >/dev/null 2>&1; then + runuser -u ${APP_USER} -- "$@" + else + # If we're already non-root (appuser), run directly; if root and no helpers, run directly anyway (with warning) + if [ "$(id -u)" -ne 0 ]; then + "$@" + else + echo "--> Upozorenje: nema gosu/su-exec/runuser; izvršavam kao root (nepoželjno)." + "$@" + fi + fi +} + +# Helper: exec the given command as appuser (replace current shell / PID 1) +exec_as_appuser() { + # Ako smo već appuser, samo izvrši naredbu direktno + if [ "$(id -u -n)" = "${APP_USER}" ]; then + exec "$@" + # Inače, pokušaj koristiti alate za switch + elif command -v gosu >/dev/null 2>&1; then + exec gosu ${APP_USER} "$@" + elif command -v su-exec >/dev/null 2>&1; then + exec su-exec ${APP_USER} "$@" + else + # Fallback + exec "$@" + fi +} + +if [ "$1" = "gunicorn" ]; then + echo "--> Pokrećem migracije..." + # Ako smo root, pokrenimo migrate kao appuser; ako smo non-root (već appuser), pokreni direktno + if [ "$(id -u)" -eq 0 ]; then + run_as_appuser_now python manage.py migrate --noinput + else + python manage.py migrate --noinput + fi + + echo "--> Osiguravam prava nad statikom prije collectstatic..." + mkdir -p "${STATIC_ROOT}" + if [ "$(id -u)" -eq 0 ]; then + chown -R ${APP_USER}:${APP_USER} "${STATIC_ROOT}" || echo "--> chown statika nije uspio" + fi + + echo "--> Skupljam statiku..." + if command -v gosu >/dev/null 2>&1 || command -v su-exec >/dev/null 2>&1 || command -v runuser >/dev/null 2>&1; then + run_as_appuser_now python manage.py collectstatic --noinput + else + python manage.py collectstatic --noinput + fi + + echo "--> Pokrećem Gunicorn kao appuser..." + exec_as_appuser gunicorn --bind 0.0.0.0:8000 --workers 3 core.wsgi:application + +elif [ "$1" = "celery" ]; then + echo "--> Pokrećem Celery kao appuser..." + shift + # Replace PID1 with celery running as appuser + exec_as_appuser celery "$@" + +else + exec "$@" +fi \ No newline at end of file diff --git a/backend/infrastructure/__init__.py b/backend/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/infrastructure/paperless_client.py b/backend/infrastructure/paperless_client.py new file mode 100644 index 0000000..7a15632 --- /dev/null +++ b/backend/infrastructure/paperless_client.py @@ -0,0 +1,43 @@ +# backend/infrastructure/paperless_client.py + +import requests +from django.conf import settings + +class PaperlessGateway: + """ + Gateway za komunikaciju s Paperless-ngx API-jem. + """ + def __init__(self): + self.base_url = settings.PAPERLESS_API_URL + self.headers = { + "Authorization": f"Token {settings.PAPERLESS_API_TOKEN}", + "Content-Type": "application/json" + } + + def upload_document(self, file_content, filename): + """Šalje dokument u Paperless.""" + url = f"{self.base_url}/documents/post_document/" + files = {'document': (filename, file_content)} + response = requests.post(url, headers={"Authorization": self.headers["Authorization"]}, files=files) + response.raise_for_status() + return response.json() + + def get_document_status(self, task_id): + """Provjerava status procesiranja dokumenta.""" + url = f"{self.base_url}/tasks/{task_id}/" + response = requests.get(url, headers=self.headers) + return response.json() + + def get_document_metadata(self, document_id: int): + """Dohvaća metapodatke dokumenta iz Paperless-a.""" + url = f"{self.base_url}/api/documents/{document_id}/" + response = requests.get(url, headers=self.headers) + response.raise_for_status() + return response.json() + + def fetch_unprocessed_documents(self): + """Dohvaća listu dokumenata koji čekaju obradu.""" + # Filtriramo dokumente koji nemaju naš 'processed' tag + url = f"{self.base_url}/api/documents/?query=is_tagged:false" + response = requests.get(url, headers=self.headers) + return response.json().get('results', []) \ No newline at end of file diff --git a/backend/infrastructure/pusher_service.py b/backend/infrastructure/pusher_service.py new file mode 100644 index 0000000..3fa4152 --- /dev/null +++ b/backend/infrastructure/pusher_service.py @@ -0,0 +1,221 @@ +# backend/modules/fleet/services/pusher_service.py +""" +Pusher Integration Service for Real-time Notifications +Handles broadcasting notifications to users via WebSocket channels +""" + +import logging +import json +from typing import Optional, Dict, Any +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder + +try: + import pusher + PUSHER_AVAILABLE = True +except ImportError: + PUSHER_AVAILABLE = False + pusher = None + +logger = logging.getLogger(__name__) + + +class PusherService: + """ + Wrapper service for Pusher integration. + Handles event broadcasting, channel authentication, and error handling. + """ + + _instance = None + _client = None + + def __new__(cls): + """Singleton pattern to reuse Pusher client""" + if cls._instance is None: + cls._instance = super(PusherService, cls).__new__(cls) + return cls._instance + + @classmethod + def get_client(cls): + """Get or initialize Pusher client""" + if cls._client is None: + if not PUSHER_AVAILABLE: + logger.warning("Pusher library not installed. Install with: pip install pusher") + return None + + try: + pusher_config = { + 'app_id': settings.PUSHER_APP_ID, + 'key': settings.PUSHER_KEY, + 'secret': settings.PUSHER_SECRET, + 'cluster': settings.PUSHER_CLUSTER, + 'ssl': True, + 'json_encoder': DjangoJSONEncoder, + } + cls._client = pusher.Pusher(**pusher_config) + except AttributeError as e: + logger.error(f"Pusher configuration missing: {e}") + return None + + return cls._client + + @staticmethod + def is_available() -> bool: + """Check if Pusher is available and configured""" + if not PUSHER_AVAILABLE: + return False + + required_values = [ + settings.PUSHER_APP_ID, + settings.PUSHER_KEY, + settings.PUSHER_SECRET, + settings.PUSHER_CLUSTER, + ] + return all(required_values) + + @staticmethod + def get_private_channel_name(user_id: int) -> str: + """Generate private channel name for user""" + return f"private-user-{user_id}" + + @staticmethod + def get_presence_channel_name() -> str: + """Get the presence channel name for fleet activity""" + return "presence-fleet" + + @classmethod + def broadcast_notification( + cls, + user_id: int, + title: str, + message: str, + level: str = 'info', + notification_id: Optional[Any] = None, + metadata: Optional[Dict[str, Any]] = None + ) -> bool: + """ + Broadcast a notification to a specific user via private channel. + + Args: + user_id: ID of the user to notify + title: Notification title + message: Notification message + level: Notification level (info, warning, critical) + notification_id: ID of the VehicleNotification record + metadata: Additional metadata to send with notification + + Returns: + True if broadcast successful, False otherwise + """ + if not cls.is_available(): + logger.debug("Pusher not available, skipping broadcast") + return False + + client = cls.get_client() + if not client: + return False + + channel_name = cls.get_private_channel_name(user_id) + event_name = "vehicle.notification.created" + + from django.utils.timezone import now + event_data = { + 'id': cls._to_json_safe(notification_id), + 'title': title, + 'message': message, + 'level': level, + 'is_read': False, + 'created_at': now().isoformat(), + } + + if metadata: + event_data['metadata'] = cls._to_json_safe(metadata) + + try: + response = client.trigger( + channels=channel_name, + event_name=event_name, + data=event_data + ) + logger.info(f"Notification broadcasted to {channel_name}: {event_name}") + return True + except Exception as e: + logger.exception(f"Error broadcasting notification to {channel_name}: {e}") + return False + + @staticmethod + def _to_json_safe(value: Any) -> Any: + """ + Convert complex Python/Django values (UUID, date, Decimal, etc.) into + JSON-serializable primitives using DjangoJSONEncoder. + """ + return json.loads(json.dumps(value, cls=DjangoJSONEncoder)) + + @classmethod + def broadcast_to_multiple_users( + cls, + user_ids: list, + title: str, + message: str, + level: str = 'info', + notification_ids: Optional[Dict[int, int]] = None + ) -> Dict[int, bool]: + """ + Broadcast the same notification to multiple users. + + Args: + user_ids: List of user IDs to notify + title: Notification title + message: Notification message + level: Notification level + notification_ids: Dict mapping user_id to notification_id + + Returns: + Dict of user_id -> success boolean + """ + results = {} + for user_id in user_ids: + notification_id = notification_ids.get(user_id) if notification_ids else None + results[user_id] = cls.broadcast_notification( + user_id=user_id, + title=title, + message=message, + level=level, + notification_id=notification_id + ) + return results + + @classmethod + def authenticate_channel(cls, socket_id: str, channel_name: str, user_id: int) -> Optional[Dict[str, Any]]: + """ + Authenticate a user for a private or presence channel. + + Args: + socket_id: Pusher socket ID from client + channel_name: Channel name to authenticate + user_id: Current user ID + + Returns: + Signed authentication data or None if authentication fails + """ + if not cls.is_available(): + logger.debug("Pusher not available for authentication") + return None + + client = cls.get_client() + if not client: + return None + + # Only allow subscription to user's own private channel + expected_channel = cls.get_private_channel_name(user_id) + if channel_name != expected_channel and channel_name != cls.get_presence_channel_name(): + logger.warning(f"Unauthorized channel subscription attempt: {channel_name} by user {user_id}") + return None + + try: + auth_data = client.authenticate(channel=channel_name, socket_id=socket_id) + logger.info(f"Channel {channel_name} authenticated for user {user_id}") + return auth_data + except Exception as e: + logger.exception(f"Error authenticating channel {channel_name}: {e}") + return None \ No newline at end of file diff --git a/backend/infrastructure/webhooks.py b/backend/infrastructure/webhooks.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..f2a662c --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/modules/crm/__init__.py b/backend/modules/crm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/crm/admin.py b/backend/modules/crm/admin.py new file mode 100644 index 0000000..7842090 --- /dev/null +++ b/backend/modules/crm/admin.py @@ -0,0 +1,86 @@ +from django import forms +from django.contrib import admin +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.utils.translation import gettext_lazy as _ +from modules.fleet.models import Crane +from .models import Client + + +class ClientAdminForm(forms.ModelForm): + customer_cranes = forms.ModelMultipleChoiceField( + label=_("Vozila kupca"), + queryset=Crane.objects.filter(is_active=True, asset_type='crane').order_by('registration_number'), + required=False, + widget=FilteredSelectMultiple(_("Dizalice"), is_stacked=False), + help_text=_("Odaberite postojeće dizalice koje pripadaju kupcu."), + ) + + class Meta: + model = Client + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.fields['customer_cranes'].initial = Crane.objects.filter(client=self.instance, is_active=True, asset_type='crane') + + +@admin.register(Client) +class ClientAdmin(admin.ModelAdmin): + form = ClientAdminForm + # Polja koja se prikazuju u listi + list_display = ('name', 'tax_id', 'city', 'client_type', 'vehicle_count', 'is_active', 'created_at') + + # Polja koja omogućuju klik na objekt + list_display_links = ('name', 'tax_id') + + # Filteri sa strane (vrlo korisno za ERP) + list_filter = ('client_type', 'is_active', 'created_at') + + # Polja po kojima admin može pretraživati + search_fields = ('name', 'tax_id', 'email') + + readonly_fields = ('created_at', 'updated_at', 'linked_cranes_preview') + + # Grupiranje polja u formi za ljepši pregled + fieldsets = ( + (_('Osnovni podaci'), { + 'fields': ('name', 'client_type', 'tax_id') + }), + (_('Kontakt podaci'), { + 'fields': ('email', 'phone', 'address', 'city', 'postal_code') + }), + (_('Vozila kupca'), { + 'fields': ('customer_cranes', 'linked_cranes_preview') + }), + (_('Sustavni podaci'), { + 'fields': ('is_active', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + list_per_page = 50 + + @admin.display(description="Dizalice") + def vehicle_count(self, obj): + return obj.vehicles.filter(asset_type='crane').count() + + @admin.display(description="Povezane dizalice") + def linked_cranes_preview(self, obj): + if not obj.pk: + return _("Spremite klijenta za prikaz povezanih dizalica.") + cranes = Crane.objects.filter(client=obj, is_active=True, asset_type='crane').order_by('registration_number') + if not cranes.exists(): + return _("Nema povezanih dizalica.") + return ", ".join(str(crane) for crane in cranes) + + def save_related(self, request, form, formsets, change): + super().save_related(request, form, formsets, change) + selected_cranes = form.cleaned_data.get('customer_cranes') + if selected_cranes is None: + return + + selected_ids = list(selected_cranes.values_list('id', flat=True)) + Crane.objects.filter(client=form.instance, asset_type='crane').exclude(pk__in=selected_ids).update(client=None) + if selected_ids: + Crane.objects.filter(pk__in=selected_ids).update(client=form.instance, is_company_vehicle=False, asset_type='crane') \ No newline at end of file diff --git a/backend/modules/crm/apps.py b/backend/modules/crm/apps.py new file mode 100644 index 0000000..4cba3db --- /dev/null +++ b/backend/modules/crm/apps.py @@ -0,0 +1,8 @@ +# /backend/modules/crm/apps.py + +from django.apps import AppConfig + +class FleetConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'modules.crm' + verbose_name = "Customer Relationship Management" \ No newline at end of file diff --git a/backend/modules/crm/migrations/0001_initial.py b/backend/modules/crm/migrations/0001_initial.py new file mode 100644 index 0000000..560a9d4 --- /dev/null +++ b/backend/modules/crm/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.15 on 2026-06-29 20:13 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Client', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('name', models.CharField(max_length=255, verbose_name='Naziv/Ime')), + ('client_type', models.CharField(choices=[('legal', 'Pravna osoba'), ('individual', 'Fizička osoba')], default='legal', max_length=20)), + ('tax_id', models.CharField(max_length=50, unique=True, verbose_name='OIB/VAT broj')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')), + ('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Telefon')), + ('address', models.TextField(verbose_name='Adresa')), + ('city', models.CharField(max_length=100, verbose_name='Grad')), + ('postal_code', models.CharField(max_length=20, verbose_name='Poštanski broj')), + ], + options={ + 'verbose_name': 'Klijent', + 'verbose_name_plural': 'Klijenti', + 'ordering': ['name'], + }, + ), + ] diff --git a/backend/modules/crm/migrations/__init__.py b/backend/modules/crm/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/crm/models.py b/backend/modules/crm/models.py new file mode 100644 index 0000000..63d9b1f --- /dev/null +++ b/backend/modules/crm/models.py @@ -0,0 +1,31 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from core.base_models import BaseModel + +class Client(BaseModel): + # Tip klijenta: OIB/VAT broj je ključan za fakturiranje + CLIENT_TYPES = [ + ('legal', _('Pravna osoba')), + ('individual', _('Fizička osoba')), + ] + + name = models.CharField(max_length=255, verbose_name=_("Naziv/Ime")) + client_type = models.CharField(max_length=20, choices=CLIENT_TYPES, default='legal') + + # Porezni podaci (za fakturiranje) + tax_id = models.CharField(max_length=50, unique=True, verbose_name=_("OIB/VAT broj")) + + # Kontakt podaci + email = models.EmailField(unique=True, verbose_name=_("Email")) + phone = models.CharField(max_length=20, blank=True, null=True, verbose_name=_("Telefon")) + address = models.TextField(verbose_name=_("Adresa")) + city = models.CharField(max_length=100, verbose_name=_("Grad")) + postal_code = models.CharField(max_length=20, verbose_name=_("Poštanski broj")) + + class Meta: + verbose_name = _("Klijent") + verbose_name_plural = _("Klijenti") + ordering = ['name'] + + def __str__(self): + return f"{self.name} ({self.tax_id})" \ No newline at end of file diff --git a/backend/modules/crm/serializers.py b/backend/modules/crm/serializers.py new file mode 100644 index 0000000..5547937 --- /dev/null +++ b/backend/modules/crm/serializers.py @@ -0,0 +1,50 @@ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError as DRFValidationError +from .models import Client +from .services import ClientService + + +class ClientSerializer(serializers.ModelSerializer): + class Meta: + model = Client + fields = [ + 'id', 'name', 'client_type', 'tax_id', + 'email', 'phone', 'address', 'city', 'postal_code', + 'created_at', 'is_active' + ] + read_only_fields = ['id', 'created_at', 'is_active'] + + def _get_request_user(self): + """ + Sigurno dohvaćanje korisnika iz contexta serializer-a. + Request iz RequestFactory može biti WSGIRequest bez atributa 'user', + zato koristimo getattr. + """ + request = self.context.get('request') if self.context else None + return getattr(request, 'user', None) + + def create(self, validated_data): + """ + Delegate creation to service layer. + """ + try: + user = self._get_request_user() + return ClientService.create_client(validated_data, user=user) + except DRFValidationError: + # re-raise as-is + raise + except Exception as exc: + # wrap non-DRF exceptions + raise DRFValidationError({"detail": str(exc)}) + + def update(self, instance, validated_data): + """ + Delegate update to service layer. + """ + try: + user = self._get_request_user() + return ClientService.update_client(instance, validated_data, user=user) + except DRFValidationError: + raise + except Exception as exc: + raise DRFValidationError({"detail": str(exc)}) \ No newline at end of file diff --git a/backend/modules/crm/services.py b/backend/modules/crm/services.py new file mode 100644 index 0000000..732b0ae --- /dev/null +++ b/backend/modules/crm/services.py @@ -0,0 +1,65 @@ +from django.db import transaction, IntegrityError +from rest_framework.exceptions import ValidationError +from .models import Client + +import logging + +logger = logging.getLogger(__name__) + + +class ClientService: + @staticmethod + def create_client(data: dict, user=None) -> Client: + """ + Create a Client instance with model validation and handle IntegrityError. + """ + # Instantiate model and run model-level validation + client = Client(**data) + try: + client.full_clean() + except Exception as e: + logger.error(f"Client validation failed: {e}") + raise ValidationError({"detail": str(e)}) + + try: + with transaction.atomic(): + client.save() + except IntegrityError as exc: + logger.error(f"Integrity error creating client: {exc}") + # Convert DB integrity errors into DRF ValidationError for the API layer + raise ValidationError({"detail": str(exc)}) + + return client + + @staticmethod + def update_client(instance: Client, data: dict, user=None) -> Client: + """ + Update the given Client instance. Validate and save. + """ + for attr, value in data.items(): + setattr(instance, attr, value) + + try: + instance.full_clean() + except Exception as e: + logger.error(f"Client validation failed on update: {e}") + raise ValidationError({"detail": str(e)}) + + try: + with transaction.atomic(): + instance.save() + except IntegrityError as exc: + logger.error(f"Integrity error updating client: {exc}") + raise ValidationError({"detail": str(exc)}) + + return instance + + @staticmethod + def delete_client(instance: Client, user=None) -> Client: + """ + Soft-delete the client (if you want soft delete). + If hard delete is desired, call instance.delete() instead. + """ + instance.is_active = False + instance.save() + return instance \ No newline at end of file diff --git a/backend/modules/crm/tests/test_models.py b/backend/modules/crm/tests/test_models.py new file mode 100644 index 0000000..7d9d91b --- /dev/null +++ b/backend/modules/crm/tests/test_models.py @@ -0,0 +1,75 @@ +import uuid +from django.test import TestCase +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +from modules.crm.models import Client + + +class ClientModelTests(TestCase): + def setUp(self): + # osnovni validni podaci koje ćemo koristiti u testovima + self.base_data = { + "name": "Test Client", + "tax_id": str(uuid.uuid4()), # jedinstveno + "email": f"client+{uuid.uuid4()}@example.test", + "address": "Ulica 1", + "city": "Grad", + "postal_code": "10000", + } + + def test_create_client_success(self): + client = Client.objects.create( + client_type="legal", + **self.base_data + ) + self.assertIsNotNone(client.pk) + self.assertEqual(client.name, "Test Client") + self.assertEqual(client.client_type, "legal") + # default ordering ili drugi atributi mogu se dodatno provjeriti + self.assertTrue(str(client).startswith("Test Client")) + self.assertIn(client.tax_id, str(client)) + + def test_default_client_type_is_legal(self): + # Ako eksplicitno ne pošaljemo client_type, default je 'legal' + data = dict(self.base_data) + data["tax_id"] = str(uuid.uuid4()) + data["email"] = f"client+{uuid.uuid4()}@example.test" + client = Client.objects.create(**data) + self.assertEqual(client.client_type, "legal") + + def test_str_representation_contains_name_and_taxid(self): + client = Client.objects.create(client_type="individual", **self.base_data) + expected = f"{client.name} ({client.tax_id})" + self.assertEqual(str(client), expected) + + def test_unique_tax_id_constraint(self): + # Prva kreacija prođe + Client.objects.create(client_type="legal", **self.base_data) + # Pokušaj dupliciranja tax_id-a treba podići IntegrityError + dup = dict(self.base_data) + dup["email"] = f"client+{uuid.uuid4()}@example.test" # unique email drugi + with self.assertRaises(IntegrityError): + Client.objects.create(client_type="legal", **dup) + + def test_unique_email_constraint(self): + Client.objects.create(client_type="legal", **self.base_data) + dup = dict(self.base_data) + dup["tax_id"] = str(uuid.uuid4()) # unique tax_id drugi + with self.assertRaises(IntegrityError): + Client.objects.create(client_type="legal", **dup) + + def test_full_clean_validates_email_format(self): + data = dict(self.base_data) + data["email"] = "not-an-email" + client = Client(**data) + with self.assertRaises(ValidationError): + client.full_clean() # EmailField validation je pokrenuta + + def test_full_clean_requires_required_fields(self): + # Uklonimo obavezno polje name i očekujemo ValidationError + data = dict(self.base_data) + data.pop("name") + client = Client(**data) + with self.assertRaises(ValidationError): + client.full_clean() \ No newline at end of file diff --git a/backend/modules/crm/tests/test_serializers.py b/backend/modules/crm/tests/test_serializers.py new file mode 100644 index 0000000..3feb968 --- /dev/null +++ b/backend/modules/crm/tests/test_serializers.py @@ -0,0 +1,43 @@ +import uuid +from django.test import TestCase, RequestFactory +from unittest.mock import patch +from modules.crm.serializers import ClientSerializer +from modules.crm.models import Client + + +class ClientSerializerTests(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.base_data = { + "name": "Ser Client", + "client_type": "legal", + "tax_id": str(uuid.uuid4()), + "email": f"client+{uuid.uuid4()}@example.test", + "address": "Street 1", + "city": "City", + "postal_code": "00000", + } + + @patch("modules.crm.serializers.ClientService.create_client") + def test_create_calls_service(self, mock_create): + # Prepare mock return value + mock_instance = Client(**self.base_data) + mock_instance.pk = 1 + mock_create.return_value = mock_instance + + serializer = ClientSerializer(data=self.base_data, context={"request": self.factory.post("/")}) + self.assertTrue(serializer.is_valid(), serializer.errors) + result = serializer.save() + mock_create.assert_called_once() + self.assertEqual(result.pk, 1) + + @patch("modules.crm.serializers.ClientService.update_client") + def test_update_calls_service(self, mock_update): + client = Client.objects.create(**self.base_data) + mock_update.return_value = client + + serializer = ClientSerializer(instance=client, data={"city": "UpdatedCity"}, partial=True, context={"request": self.factory.put("/")}) + self.assertTrue(serializer.is_valid(), serializer.errors) + result = serializer.save() + mock_update.assert_called_once() + self.assertEqual(result, client) \ No newline at end of file diff --git a/backend/modules/crm/tests/test_services.py b/backend/modules/crm/tests/test_services.py new file mode 100644 index 0000000..8b47578 --- /dev/null +++ b/backend/modules/crm/tests/test_services.py @@ -0,0 +1,36 @@ +import uuid +from django.test import TestCase +from rest_framework.exceptions import ValidationError +from modules.crm.services import ClientService +from modules.crm.models import Client + + +class ClientServiceTests(TestCase): + def setUp(self): + self.base_data = { + "name": "Service Client", + "client_type": "legal", + "tax_id": str(uuid.uuid4()), + "email": f"client+{uuid.uuid4()}@example.test", + "address": "Street 1", + "city": "City", + "postal_code": "00000", + } + + def test_create_client_success(self): + client = ClientService.create_client(self.base_data) + self.assertIsNotNone(client.pk) + self.assertEqual(client.name, "Service Client") + + def test_create_client_duplicate_tax_id_raises(self): + # create first + Client.objects.create(**self.base_data) + dup = dict(self.base_data) + dup["email"] = f"client+{uuid.uuid4()}@example.test" + with self.assertRaises(ValidationError): + ClientService.create_client(dup) + + def test_update_client_success(self): + client = Client.objects.create(**self.base_data) + updated = ClientService.update_client(client, {"city": "NewCity"}) + self.assertEqual(updated.city, "NewCity") \ No newline at end of file diff --git a/backend/modules/crm/tests/test_views.py b/backend/modules/crm/tests/test_views.py new file mode 100644 index 0000000..c8143f4 --- /dev/null +++ b/backend/modules/crm/tests/test_views.py @@ -0,0 +1,62 @@ +import uuid +from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model +from unittest.mock import patch +from modules.crm.serializers import ClientSerializer +from modules.crm.views import ClientViewSet +from modules.crm.models import Client + + +class ClientViewSetTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username=f"user_{uuid.uuid4().hex[:6]}", email=f"user+{uuid.uuid4()}@example.test", password="pass") + self.factory = RequestFactory() + self.view = ClientViewSet() + self.view.request = self.factory.post("/") + self.view.request.user = self.user + + self.base_data = { + "name": "View Client", + "client_type": "legal", + "tax_id": str(uuid.uuid4()), + "email": f"client+{uuid.uuid4()}@example.test", + "address": "Street 1", + "city": "City", + "postal_code": "00000", + } + + @patch("modules.crm.views.ClientService.create_client") + def test_perform_create_delegates_to_service(self, mock_create): + # make serializer valid + serializer = ClientSerializer(data=self.base_data, context={"request": self.view.request}) + self.assertTrue(serializer.is_valid(), serializer.errors) + + mock_inst = Client(**self.base_data) + mock_inst.pk = 1 + mock_create.return_value = mock_inst + + self.view.request = self.factory.post("/") + self.view.request.user = self.user + + # perform_create should call serializer.save() which delegates to service + self.view.perform_create(serializer) + mock_create.assert_called_once() + + @patch("modules.crm.views.ClientService.update_client") + def test_perform_update_delegates_to_service(self, mock_update): + client = Client.objects.create(**self.base_data) + serializer = ClientSerializer(instance=client, data={"city": "UpdatedCity"}, partial=True, context={"request": self.view.request}) + self.assertTrue(serializer.is_valid(), serializer.errors) + + mock_update.return_value = client + + self.view.request = self.factory.put("/") + self.view.request.user = self.user + serializer.instance = client + + self.view.perform_update(serializer) + mock_update.assert_called_once() + client.refresh_from_db() + # serializer.perform_update causes serializer.save() which we delegated; check instance still exists + self.assertIsNotNone(client.pk) \ No newline at end of file diff --git a/backend/modules/crm/urls.py b/backend/modules/crm/urls.py new file mode 100644 index 0000000..57259cf --- /dev/null +++ b/backend/modules/crm/urls.py @@ -0,0 +1,7 @@ +from rest_framework.routers import DefaultRouter +from .views import ClientViewSet + +router = DefaultRouter() +router.register(r'clients', ClientViewSet, basename='client') + +urlpatterns = router.urls diff --git a/backend/modules/crm/views.py b/backend/modules/crm/views.py new file mode 100644 index 0000000..55e54c8 --- /dev/null +++ b/backend/modules/crm/views.py @@ -0,0 +1,41 @@ +import logging +from rest_framework import viewsets, permissions +from rest_framework.exceptions import ValidationError +from .models import Client +from .serializers import ClientSerializer +from .services import ClientService + +logger = logging.getLogger(__name__) + + +class ClientViewSet(viewsets.ModelViewSet): + queryset = Client.objects.filter(is_active=True) + serializer_class = ClientSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_create(self, serializer): + try: + instance = serializer.save() + serializer.instance = instance + except ValidationError as e: + logger.warning(f"Validation error creating client by {self.request.user}: {e}") + raise e + except Exception as e: + logger.error(f"Unexpected error creating client: {e}", exc_info=True) + raise ValidationError({"detail": "Došlo je do greške pri stvaranju klijenta."}) + + def perform_update(self, serializer): + try: + instance = serializer.save() + serializer.instance = instance + except ValidationError as e: + logger.warning(f"Validation error updating client by {self.request.user}: {e}") + raise e + except Exception as e: + logger.error(f"Unexpected error updating client: {e}", exc_info=True) + raise ValidationError({"detail": "Došlo je do greške pri ažuriranju klijenta."}) + + def perform_destroy(self, instance): + # Delegate deletion (soft-delete) to service layer + ClientService.delete_client(instance, user=self.request.user) + logger.info(f"Client {instance.id} soft-deleted by {self.request.user}") \ No newline at end of file diff --git a/backend/modules/fleet/__init__.py b/backend/modules/fleet/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py new file mode 100644 index 0000000..8bb9864 --- /dev/null +++ b/backend/modules/fleet/admin.py @@ -0,0 +1,197 @@ +from django.contrib import admin +from django.contrib.admin import ModelAdmin +from django.utils.html import format_html +from .models import ( + Crane, + Vehicle, + WorkOrder, + WorkOrderInvoice, + VehicleServiceRecord, + VehicleServicePhoto, + VehicleServiceAttachment, +) + + +class ServicePhotoInline(admin.TabularInline): + model = VehicleServicePhoto + extra = 0 + fields = ('thumbnail_preview', 'image', 'description', 'uploaded_by', 'created_at') + readonly_fields = ('thumbnail_preview', 'created_at') + show_change_link = True + + @admin.display(description="Pregled") + def thumbnail_preview(self, obj): + if obj.image: + return format_html( + '' + '' + '', + obj.image.url, + obj.image.url, + ) + return "—" + +class ServiceAttachmentInline(admin.TabularInline): + model = VehicleServiceAttachment + extra = 0 + fields = ('file', 'description', 'uploaded_by', 'created_at') + readonly_fields = ('created_at',) + show_change_link = True + + +class WorkOrderInline(admin.TabularInline): + model = WorkOrder + extra = 0 + fields = ('creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status') + readonly_fields = ('date',) + + +class ServiceRecordInline(admin.TabularInline): + model = VehicleServiceRecord + extra = 0 + fields = ('task', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost') + readonly_fields = ('service_date', 'next_service_due_at') + + +class WorkOrderInvoiceInline(admin.TabularInline): + model = WorkOrderInvoice + extra = 0 + fields = ('naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active') + readonly_fields = ('created_at',) + + +@admin.register(Vehicle) +class VehicleAdmin(admin.ModelAdmin): + list_display = ( + 'registration_number', + 'crane_serial_number', + 'superstructure_working_hours', + 'chassis_working_hours', + 'make', + 'model', + 'year', + 'current_mileage', + 'service_interval_km', + 'next_service_at_display', + 'is_company_vehicle', + 'client', + 'assigned_servicer', + 'is_active', + ) + list_filter = ('is_active', 'is_company_vehicle', 'make', 'service_interval_km') + search_fields = ('registration_number', 'vin', 'make', 'model', 'assigned_servicer__username', 'client__name') + readonly_fields = () + inlines = (WorkOrderInline, ServiceRecordInline) + ordering = ('registration_number',) + fieldsets = ( + (None, { + 'fields': ('asset_type', 'registration_number', 'vin', 'make', 'model', 'year'), + }), + ('Vlasništvo', { + 'fields': ('is_company_vehicle', 'client', 'assigned_servicer'), + }), + ('Servis', { + 'fields': ('current_mileage', 'service_interval_km', 'superstructure_working_hours', 'chassis_working_hours', 'is_active'), + }), + ) + + def next_service_at_display(self, obj): + try: + return obj.next_service_at() + except Exception: + return None + next_service_at_display.short_description = "Sljedeći servis @ (km)" + next_service_at_display.admin_order_field = 'current_mileage' + + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.filter(asset_type='vehicle') + + def save_model(self, request, obj, form, change): + obj.asset_type = 'vehicle' + super().save_model(request, obj, form, change) + + +@admin.register(Crane) +class CraneAdmin(VehicleAdmin): + list_display = ( + 'registration_number', + 'crane_serial_number', + 'superstructure_working_hours', + 'chassis_working_hours', + 'make', + 'model', + 'year', + 'current_mileage', + 'service_interval_km', + 'next_service_at_display', + 'client', + 'assigned_servicer', + 'is_active', + ) + list_filter = ('is_active', 'client', 'make', 'service_interval_km', 'crane_serial_number') + search_fields = ('registration_number', 'crane_serial_number', 'vin', 'make', 'model', 'assigned_servicer__username', 'client__name') + fieldsets = ( + (None, { + 'fields': ('asset_type', 'registration_number', 'crane_serial_number', 'vin', 'make', 'model', 'year'), + }), + ('Povezivanje', { + 'fields': ('client', 'assigned_servicer', 'is_company_vehicle'), + }), + ('Servis', { + 'fields': ('current_mileage', 'service_interval_km', 'superstructure_working_hours', 'chassis_working_hours', 'is_active'), + }), + ) + + def get_queryset(self, request): + queryset = ModelAdmin.get_queryset(self, request) + return queryset.filter(asset_type='crane') + + def save_model(self, request, obj, form, change): + obj.asset_type = 'crane' + ModelAdmin.save_model(self, request, obj, form, change) + + +@admin.register(WorkOrder) +class WorkOrderAdmin(admin.ModelAdmin): + list_display = ('id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status') + list_filter = ('status', 'date', 'vehicle') + search_fields = ('vehicle__registration_number', 'creator__username', 'purpose') + readonly_fields = ('date',) + ordering = ('-date',) + inlines = (WorkOrderInvoiceInline,) + + def distance_display(self, obj): + return obj.distance + distance_display.short_description = "Prijeđeno (km)" + + +@admin.register(WorkOrderInvoice) +class WorkOrderInvoiceAdmin(admin.ModelAdmin): + list_display = ('id', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active') + list_filter = ('datum', 'is_active', 'work_order') + search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id') + ordering = ('-datum', '-created_at') + +@admin.register(VehicleServiceRecord) +class VehicleServiceRecordAdmin(admin.ModelAdmin): + list_display = ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost', 'short_description') + list_filter = ('service_date', 'performed_by', 'vehicle') + search_fields = ('service_title', 'vehicle__registration_number', 'performed_by__username', 'description', 'parts') + readonly_fields = ('id', 'service_date', 'next_service_due_at') + ordering = ('-service_date',) + inlines = (ServicePhotoInline, ServiceAttachmentInline) + fieldsets = ( + (None, { + 'fields': ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date'), + }), + ('Detalji servisa', { + 'fields': ('description', 'parts', 'mileage', 'cost', 'next_service_due_at'), + }), + ) + + @admin.display(description="Opis") + def short_description(self, obj): + if obj.description: + return obj.description[:60] + ('…' if len(obj.description) > 60 else '') + return "—" \ No newline at end of file diff --git a/backend/modules/fleet/apps.py b/backend/modules/fleet/apps.py new file mode 100644 index 0000000..1a22654 --- /dev/null +++ b/backend/modules/fleet/apps.py @@ -0,0 +1,8 @@ +# /backend/modules/fleet/apps.py + +from django.apps import AppConfig + +class FleetConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'modules.fleet' + verbose_name = "Fleet" \ No newline at end of file diff --git a/backend/modules/fleet/migrations/0001_initial.py b/backend/modules/fleet/migrations/0001_initial.py new file mode 100644 index 0000000..2773eed --- /dev/null +++ b/backend/modules/fleet/migrations/0001_initial.py @@ -0,0 +1,124 @@ +# Generated by Django 5.2.15 on 2026-07-01 11:39 + +import django.db.models.deletion +import modules.fleet.models +import uuid +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Vehicle', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('registration_number', models.CharField(max_length=20, unique=True, verbose_name='Registracija')), + ('make', models.CharField(blank=True, max_length=100, verbose_name='Proizvođač')), + ('model', models.CharField(blank=True, max_length=100, verbose_name='Model')), + ('year', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Godina')), + ('vin', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='VIN')), + ('current_mileage', models.PositiveIntegerField(default=0, verbose_name='Trenutna kilometraža (km)')), + ('service_interval_km', models.PositiveIntegerField(default=15000, verbose_name='Interval servisa (km)')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivno')), + ('assigned_servicer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_vehicles', to=settings.AUTH_USER_MODEL, verbose_name='Serviser')), + ], + options={ + 'verbose_name': 'Vozilo', + 'verbose_name_plural': 'Vozila', + 'ordering': ['registration_number'], + }, + ), + migrations.CreateModel( + name='VehicleNotification', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('title', models.CharField(max_length=255, verbose_name='Naslov')), + ('message', models.TextField(verbose_name='Poruka')), + ('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Upozorenje'), ('critical', 'Kritično')], default='info', max_length=20, verbose_name='Razina')), + ('is_sent', models.BooleanField(default=False, verbose_name='Poslano (e-mail)')), + ('is_read', models.BooleanField(default=False, verbose_name='Pročitano')), + ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vehicle_notifications', to=settings.AUTH_USER_MODEL, verbose_name='Primatelj')), + ], + options={ + 'verbose_name': 'Notifikacija za vozilo', + 'verbose_name_plural': 'Notifikacije za vozila', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='VehicleServiceRecord', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('description', models.TextField(verbose_name='Opis posla')), + ('parts', models.TextField(blank=True, verbose_name='Korišteni dijelovi')), + ('cost', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Trošak')), + ('service_date', models.DateField(auto_now_add=True, verbose_name='Datum servisa')), + ('mileage', models.PositiveIntegerField(verbose_name='Kilometraža pri servisu')), + ('next_service_due_at', models.PositiveIntegerField(blank=True, null=True, verbose_name='Kilometraža sljedećeg servisa')), + ('performed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='performed_vehicle_services', to=settings.AUTH_USER_MODEL, verbose_name='Izvršio')), + ('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='service_records', to='fleet.vehicle', verbose_name='Vozilo')), + ], + options={ + 'verbose_name': 'Servisni zapis', + 'verbose_name_plural': 'Servisni zapisi', + 'ordering': ['-service_date'], + }, + ), + migrations.CreateModel( + name='VehicleServicePhoto', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('image', models.ImageField(upload_to=modules.fleet.models._service_photo_upload_to, verbose_name='Fotografija')), + ('description', models.CharField(blank=True, max_length=255, verbose_name='Opis')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_service_photos', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')), + ('service_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')), + ], + options={ + 'verbose_name': 'Fotografija servisa', + 'verbose_name_plural': 'Fotografije servisa', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='WorkOrder', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('date', models.DateField(auto_now_add=True, verbose_name='Datum')), + ('start_mileage', models.PositiveIntegerField(verbose_name='Početna kilometraža')), + ('end_mileage', models.PositiveIntegerField(blank=True, null=True, verbose_name='Završna kilometraža')), + ('purpose', models.CharField(blank=True, max_length=255, verbose_name='Svrha')), + ('notes', models.TextField(blank=True, verbose_name='Bilješke')), + ('status', models.CharField(choices=[('open', 'Otvoren'), ('closed', 'Zatvoren')], default='open', max_length=20, verbose_name='Status')), + ('creator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_work_orders', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')), + ('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='work_orders', to='fleet.vehicle', verbose_name='Vozilo')), + ], + options={ + 'verbose_name': 'Putni nalog', + 'verbose_name_plural': 'Putni nalozi', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/modules/fleet/migrations/0002_alter_vehicle_assigned_servicer.py b/backend/modules/fleet/migrations/0002_alter_vehicle_assigned_servicer.py new file mode 100644 index 0000000..c024ff2 --- /dev/null +++ b/backend/modules/fleet/migrations/0002_alter_vehicle_assigned_servicer.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.15 on 2026-07-03 02:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='vehicle', + name='assigned_servicer', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='assigned_vehicles', to=settings.AUTH_USER_MODEL, verbose_name='Serviser'), + ), + ] diff --git a/backend/modules/fleet/migrations/0003_add_is_company_vehicle_and_client_fk.py b/backend/modules/fleet/migrations/0003_add_is_company_vehicle_and_client_fk.py new file mode 100644 index 0000000..81a6645 --- /dev/null +++ b/backend/modules/fleet/migrations/0003_add_is_company_vehicle_and_client_fk.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.15 on 2026-07-06 08:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0001_initial'), + ('fleet', '0002_alter_vehicle_assigned_servicer'), + ] + + operations = [ + migrations.AddField( + model_name='vehicle', + name='client', + field=models.ForeignKey(blank=True, help_text='Popunite samo za vozila kupaca (is_company_vehicle = Ne).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vehicles', to='crm.client', verbose_name='Kupac'), + ), + migrations.AddField( + model_name='vehicle', + name='is_company_vehicle', + field=models.BooleanField(default=True, help_text='Označite ako vozilo pripada našoj tvrtki. Poništite za vozila kupaca.', verbose_name='Vozilo tvrtke'), + ), + ] diff --git a/backend/modules/fleet/migrations/0004_vehicleserviceattachment.py b/backend/modules/fleet/migrations/0004_vehicleserviceattachment.py new file mode 100644 index 0000000..631d085 --- /dev/null +++ b/backend/modules/fleet/migrations/0004_vehicleserviceattachment.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.15 on 2026-07-06 11:45 + +import django.db.models.deletion +import modules.fleet.models +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('fleet', '0003_add_is_company_vehicle_and_client_fk'), + ] + + operations = [ + migrations.CreateModel( + name='VehicleServiceAttachment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('file', models.FileField(upload_to=modules.fleet.models._service_attachment_upload_to, verbose_name='Datoteka')), + ('description', models.CharField(blank=True, max_length=255, verbose_name='Opis')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan')), + ('service_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_service_attachments', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')), + ], + options={ + 'verbose_name': 'Prilog servisa', + 'verbose_name_plural': 'Prilozi servisa', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/modules/fleet/migrations/0005_vehiclenotification_metadata.py b/backend/modules/fleet/migrations/0005_vehiclenotification_metadata.py new file mode 100644 index 0000000..419c57b --- /dev/null +++ b/backend/modules/fleet/migrations/0005_vehiclenotification_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-06 13:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0004_vehicleserviceattachment'), + ] + + operations = [ + migrations.AddField( + model_name='vehiclenotification', + name='metadata', + field=models.JSONField(blank=True, default=dict, verbose_name='Metapodaci'), + ), + ] diff --git a/backend/modules/fleet/migrations/0006_crane_proxy_model.py b/backend/modules/fleet/migrations/0006_crane_proxy_model.py new file mode 100644 index 0000000..0f9ad89 --- /dev/null +++ b/backend/modules/fleet/migrations/0006_crane_proxy_model.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.15 on 2026-07-06 13:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0005_vehiclenotification_metadata'), + ] + + operations = [ + migrations.CreateModel( + name='Crane', + fields=[], + options={ + 'verbose_name': 'Dizalica', + 'verbose_name_plural': 'Dizalice', + 'ordering': ['registration_number'], + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('fleet.vehicle',), + ), + ] diff --git a/backend/modules/fleet/migrations/0007_vehicle_asset_type.py b/backend/modules/fleet/migrations/0007_vehicle_asset_type.py new file mode 100644 index 0000000..a287a9b --- /dev/null +++ b/backend/modules/fleet/migrations/0007_vehicle_asset_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-06 15:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0006_crane_proxy_model'), + ] + + operations = [ + migrations.AddField( + model_name='vehicle', + name='asset_type', + field=models.CharField(choices=[('vehicle', 'Vozilo'), ('crane', 'Dizalica')], db_index=True, default='crane', max_length=20, verbose_name='Tip sredstva'), + ), + ] diff --git a/backend/modules/fleet/migrations/0008_backfill_asset_type_vehicle.py b/backend/modules/fleet/migrations/0008_backfill_asset_type_vehicle.py new file mode 100644 index 0000000..832e67e --- /dev/null +++ b/backend/modules/fleet/migrations/0008_backfill_asset_type_vehicle.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.15 on 2026-07-06 13:55 + +from django.db import migrations + + +def backfill_asset_type_vehicle(apps, schema_editor): + Vehicle = apps.get_model('fleet', 'Vehicle') + WorkOrder = apps.get_model('fleet', 'WorkOrder') + + work_order_vehicle_ids = WorkOrder.objects.values_list('vehicle_id', flat=True) + service_vehicle_ids = ( + Vehicle.objects + .filter( + assigned_servicer__isnull=False, + is_company_vehicle=True, + client__isnull=True, + ) + .exclude(id__in=work_order_vehicle_ids) + .values_list('id', flat=True) + ) + + Vehicle.objects.filter(id__in=service_vehicle_ids).update(asset_type='vehicle') + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0007_vehicle_asset_type'), + ] + + operations = [ + migrations.RunPython(backfill_asset_type_vehicle, migrations.RunPython.noop), + ] + diff --git a/backend/modules/fleet/migrations/0009_workorder_location_and_travel_times.py b/backend/modules/fleet/migrations/0009_workorder_location_and_travel_times.py new file mode 100644 index 0000000..06b9d37 --- /dev/null +++ b/backend/modules/fleet/migrations/0009_workorder_location_and_travel_times.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.15 on 2026-07-07 17:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0008_backfill_asset_type_vehicle'), + ] + + operations = [ + migrations.AddField( + model_name='workorder', + name='location', + field=models.CharField(blank=True, max_length=255, verbose_name='Lokacija odredišta'), + ), + migrations.AddField( + model_name='workorder', + name='travel_end_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Kraj puta'), + ), + migrations.AddField( + model_name='workorder', + name='travel_start_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Početak puta'), + ), + ] + diff --git a/backend/modules/fleet/migrations/0010_service_record_work_order_and_optional_mileage.py b/backend/modules/fleet/migrations/0010_service_record_work_order_and_optional_mileage.py new file mode 100644 index 0000000..e5df8d1 --- /dev/null +++ b/backend/modules/fleet/migrations/0010_service_record_work_order_and_optional_mileage.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.15 on 2026-07-07 17:31 + +import django.db.models.deletion +from django.db import migrations, models + + +def assign_work_order_from_vehicle(apps, schema_editor): + WorkOrder = apps.get_model('fleet', 'WorkOrder') + VehicleServiceRecord = apps.get_model('fleet', 'VehicleServiceRecord') + + for record in VehicleServiceRecord.objects.filter(work_order__isnull=True).iterator(): + work_order = ( + WorkOrder.objects + .filter(vehicle_id=record.vehicle_id, is_active=True) + .order_by('-created_at') + .first() + ) + if work_order is not None: + record.work_order_id = work_order.id + record.save(update_fields=['work_order']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0009_workorder_location_and_travel_times'), + ] + + operations = [ + migrations.AddField( + model_name='vehicleservicerecord', + name='work_order', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='service_records', to='fleet.workorder', verbose_name='Radni zadatak'), + ), + migrations.AlterField( + model_name='vehicleservicerecord', + name='mileage', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Kilometraža pri servisu'), + ), + migrations.RunPython(assign_work_order_from_vehicle, migrations.RunPython.noop), + ] + diff --git a/backend/modules/fleet/migrations/0011_workorder_start_mileage_optional.py b/backend/modules/fleet/migrations/0011_workorder_start_mileage_optional.py new file mode 100644 index 0000000..ec31f75 --- /dev/null +++ b/backend/modules/fleet/migrations/0011_workorder_start_mileage_optional.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.15 on 2026-07-07 17:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0010_service_record_work_order_and_optional_mileage'), + ] + + operations = [ + migrations.AlterField( + model_name='workorder', + name='start_mileage', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Početna kilometraža'), + ), + ] + diff --git a/backend/modules/fleet/migrations/0012_workorder_purpose_choices.py b/backend/modules/fleet/migrations/0012_workorder_purpose_choices.py new file mode 100644 index 0000000..eae24c0 --- /dev/null +++ b/backend/modules/fleet/migrations/0012_workorder_purpose_choices.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.15 on 2026-07-07 17:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0011_workorder_start_mileage_optional'), + ] + + operations = [ + migrations.AlterField( + model_name='workorder', + name='purpose', + field=models.CharField(blank=True, choices=[('defektaza', 'Defektaža'), ('kontrola', 'Kontrola'), ('redovni_pregled', 'Redovni pregled')], max_length=50, verbose_name='Svrha'), + ), + ] + diff --git a/backend/modules/fleet/migrations/0013_vehicleservicerecord_task.py b/backend/modules/fleet/migrations/0013_vehicleservicerecord_task.py new file mode 100644 index 0000000..13a7dfd --- /dev/null +++ b/backend/modules/fleet/migrations/0013_vehicleservicerecord_task.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.15 on 2026-07-07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task_management', '0002_task_work_order'), + ('fleet', '0012_workorder_purpose_choices'), + ] + + operations = [ + migrations.AddField( + model_name='vehicleservicerecord', + name='task', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='service_records', + to='task_management.task', + verbose_name='Task', + ), + ), + ] + diff --git a/backend/modules/fleet/migrations/0014_servicerecord_remove_workorder.py b/backend/modules/fleet/migrations/0014_servicerecord_remove_workorder.py new file mode 100644 index 0000000..20a7cc5 --- /dev/null +++ b/backend/modules/fleet/migrations/0014_servicerecord_remove_workorder.py @@ -0,0 +1,29 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0013_vehicleservicerecord_task'), + ('task_management', '0003_task_vehicle'), + ] + + operations = [ + migrations.RemoveField( + model_name='vehicleservicerecord', + name='work_order', + ), + migrations.AlterField( + model_name='vehicleservicerecord', + name='task', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='service_records', + to='task_management.task', + verbose_name='Radni zadatak', + ), + ), + ] diff --git a/backend/modules/fleet/migrations/0015_workorderinvoice.py b/backend/modules/fleet/migrations/0015_workorderinvoice.py new file mode 100644 index 0000000..fd34114 --- /dev/null +++ b/backend/modules/fleet/migrations/0015_workorderinvoice.py @@ -0,0 +1,47 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +def work_order_invoice_upload_to(instance, filename): + vehicle_reg = 'unknown' + try: + vehicle_reg = instance.work_order.vehicle.registration_number + except Exception: + vehicle_reg = 'unknown' + unique_name = f"{uuid.uuid4().hex}_{filename}" + return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}" + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0014_servicerecord_remove_workorder'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WorkOrderInvoice', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('naziv_racuna', models.CharField(max_length=255, verbose_name='Naziv računa')), + ('lokacija', models.CharField(blank=True, max_length=255, verbose_name='Lokacija')), + ('datum', models.DateField(default=django.utils.timezone.now, verbose_name='Datum')), + ('opis', models.TextField(blank=True, verbose_name='Opis')), + ('image', models.ImageField(blank=True, null=True, upload_to=work_order_invoice_upload_to, verbose_name='Slika računa')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_work_order_invoices', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')), + ('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='fleet.workorder', verbose_name='Putni nalog')), + ], + options={ + 'verbose_name': 'Račun putnog naloga', + 'verbose_name_plural': 'Računi putnog naloga', + 'ordering': ['-datum', '-created_at'], + }, + ), + ] diff --git a/backend/modules/fleet/migrations/0016_workorder_servicer_vehicle_fields.py b/backend/modules/fleet/migrations/0016_workorder_servicer_vehicle_fields.py new file mode 100644 index 0000000..decebd6 --- /dev/null +++ b/backend/modules/fleet/migrations/0016_workorder_servicer_vehicle_fields.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0015_workorderinvoice'), + ] + + operations = [ + migrations.AddField( + model_name='workorder', + name='servicer_vehicle_end_mileage', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Vozilo servisera - završna kilometraža'), + ), + migrations.AddField( + model_name='workorder', + name='servicer_vehicle_fuel_cost', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Vozilo servisera - trošak goriva (EUR)'), + ), + migrations.AddField( + model_name='workorder', + name='servicer_vehicle_make_model', + field=models.CharField(blank=True, max_length=120, verbose_name='Vozilo servisera - marka/model'), + ), + migrations.AddField( + model_name='workorder', + name='servicer_vehicle_registration', + field=models.CharField(blank=True, max_length=20, verbose_name='Vozilo servisera - registracija'), + ), + migrations.AddField( + model_name='workorder', + name='servicer_vehicle_start_mileage', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Vozilo servisera - početna kilometraža'), + ), + ] diff --git a/backend/modules/fleet/migrations/0017_vehicle_crane_serial_number.py b/backend/modules/fleet/migrations/0017_vehicle_crane_serial_number.py new file mode 100644 index 0000000..474e803 --- /dev/null +++ b/backend/modules/fleet/migrations/0017_vehicle_crane_serial_number.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0016_workorder_servicer_vehicle_fields'), + ] + + operations = [ + migrations.AddField( + model_name='vehicle', + name='crane_serial_number', + field=models.CharField( + blank=True, + db_index=True, + help_text='Serijski broj dizalice mora imati točno 6 znamenki.', + max_length=6, + null=True, + verbose_name='Serijski broj dizalice', + ), + ), + ] diff --git a/backend/modules/fleet/migrations/0018_vehicle_chassis_working_hours_and_more.py b/backend/modules/fleet/migrations/0018_vehicle_chassis_working_hours_and_more.py new file mode 100644 index 0000000..c88c814 --- /dev/null +++ b/backend/modules/fleet/migrations/0018_vehicle_chassis_working_hours_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-07-09 12:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0017_vehicle_crane_serial_number'), + ] + + operations = [ + migrations.AddField( + model_name='vehicle', + name='chassis_working_hours', + field=models.PositiveIntegerField(default=0, verbose_name='Radni sati podvozja'), + ), + migrations.AddField( + model_name='vehicle', + name='superstructure_working_hours', + field=models.PositiveIntegerField(default=0, verbose_name='Radni sati nadogradnje'), + ), + ] diff --git a/backend/modules/fleet/migrations/0019_vehicleservicerecord_service_title.py b/backend/modules/fleet/migrations/0019_vehicleservicerecord_service_title.py new file mode 100644 index 0000000..a1c7712 --- /dev/null +++ b/backend/modules/fleet/migrations/0019_vehicleservicerecord_service_title.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0018_vehicle_chassis_working_hours_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='vehicleservicerecord', + name='service_title', + field=models.CharField(blank=True, default='', max_length=255, verbose_name='Naziv servisnog zapisa'), + ), + ] diff --git a/backend/modules/fleet/migrations/0020_alter_workorderinvoice_created_at_and_more.py b/backend/modules/fleet/migrations/0020_alter_workorderinvoice_created_at_and_more.py new file mode 100644 index 0000000..aa4c4de --- /dev/null +++ b/backend/modules/fleet/migrations/0020_alter_workorderinvoice_created_at_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.15 on 2026-07-09 16:41 + +import modules.fleet.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0019_vehicleservicerecord_service_title'), + ] + + operations = [ + migrations.AlterField( + model_name='workorderinvoice', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'), + ), + migrations.AlterField( + model_name='workorderinvoice', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='workorderinvoice', + name='image', + field=models.ImageField(blank=True, null=True, upload_to=modules.fleet.models._work_order_invoice_upload_to, verbose_name='Slika računa'), + ), + migrations.AlterField( + model_name='workorderinvoice', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'), + ), + ] diff --git a/backend/modules/fleet/migrations/__init__.py b/backend/modules/fleet/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py new file mode 100644 index 0000000..875c0a4 --- /dev/null +++ b/backend/modules/fleet/models.py @@ -0,0 +1,433 @@ +import uuid +import re +from django.db import models +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone +from core.base_models import BaseModel +from decimal import Decimal + +def _service_photo_upload_to(instance, filename): + """ + Putanja za upload: + fleet/{registration_number}/service_photos/{uuid4}_{original_filename} + Koristimo uuid jer instance.service_record.pk možda nije dostupna prije prvog save-a. + """ + vehicle_reg = 'unknown' + try: + vehicle_reg = instance.service_record.vehicle.registration_number + except Exception: + # fallback + vehicle_reg = 'unknown' + unique_name = f"{uuid.uuid4().hex}_{filename}" + return f"fleet/{vehicle_reg}/service_photos/{unique_name}" + +def _service_attachment_upload_to(instance, filename): + """ + Putanja za upload: + fleet/{registration_number}/service_attachments/{uuid4}_{original_filename} + """ + vehicle_reg = 'unknown' + try: + vehicle_reg = instance.service_record.vehicle.registration_number + except Exception: + vehicle_reg = 'unknown' + unique_name = f"{uuid.uuid4().hex}_{filename}" + return f"fleet/{vehicle_reg}/service_attachments/{unique_name}" + +def _work_order_invoice_upload_to(instance, filename): + vehicle_reg = 'unknown' + try: + vehicle_reg = instance.work_order.vehicle.registration_number + except Exception: + vehicle_reg = 'unknown' + unique_name = f"{uuid.uuid4().hex}_{filename}" + return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}" + + +class VehicleServicePhoto(BaseModel): + """ + Fotografije kvarova / radova povezane s VehicleServiceRecord. + Pohranjuju se slike i opcionalni opis te tko ih je upload-ao. + """ + service_record = models.ForeignKey( + 'fleet.VehicleServiceRecord', + on_delete=models.CASCADE, + related_name='photos', + verbose_name=_("Servisni zapis") + ) + image = models.ImageField( + upload_to=_service_photo_upload_to, + verbose_name=_("Fotografija") + ) + description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis")) + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='uploaded_service_photos', + verbose_name=_("Postavio") + ) + is_active = models.BooleanField(default=True, verbose_name=_("Aktivan")) + + class Meta: + verbose_name = _("Fotografija servisa") + verbose_name_plural = _("Fotografije servisa") + ordering = ['-created_at'] + + def __str__(self): + return f"Photo {self.pk} for {self.service_record} ({self.image.name if self.image else 'no-image'})" + +class VehicleServiceAttachment(BaseModel): + """ + Datoteke (PDF, DOC, XLS...) povezane sa servisnim zapisom. + """ + service_record = models.ForeignKey( + 'fleet.VehicleServiceRecord', + on_delete=models.CASCADE, + related_name='attachments', + verbose_name=_("Servisni zapis") + ) + file = models.FileField( + upload_to=_service_attachment_upload_to, + verbose_name=_("Datoteka") + ) + description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis")) + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='uploaded_service_attachments', + verbose_name=_("Postavio") + ) + is_active = models.BooleanField(default=True, verbose_name=_("Aktivan")) + + class Meta: + verbose_name = _("Prilog servisa") + verbose_name_plural = _("Prilozi servisa") + ordering = ['-created_at'] + + def __str__(self): + return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})" + +class Vehicle(BaseModel): + ASSET_TYPE_CHOICES = [ + ('vehicle', _("Vozilo")), + ('crane', _("Dizalica")), + ] + + asset_type = models.CharField( + max_length=20, + choices=ASSET_TYPE_CHOICES, + default='crane', + db_index=True, + verbose_name=_("Tip sredstva"), + ) + registration_number = models.CharField(max_length=20, unique=True, verbose_name=_("Registracija")) + make = models.CharField(max_length=100, blank=True, verbose_name=_("Proizvođač")) + model = models.CharField(max_length=100, blank=True, verbose_name=_("Model")) + year = models.PositiveSmallIntegerField(null=True, blank=True, verbose_name=_("Godina")) + vin = models.CharField(max_length=50, blank=True, null=True, unique=True, verbose_name=_("VIN")) + crane_serial_number = models.CharField( + max_length=6, + null=True, + blank=True, + db_index=True, + verbose_name=_("Serijski broj dizalice"), + help_text=_("Serijski broj dizalice mora imati točno 6 znamenki."), + ) + superstructure_working_hours = models.PositiveIntegerField( + default=0, + verbose_name=_("Radni sati nadogradnje"), + ) + chassis_working_hours = models.PositiveIntegerField( + default=0, + verbose_name=_("Radni sati podvozja"), + ) + current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)")) + service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)")) + assigned_servicer = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name='assigned_vehicles', + null=True, + blank=True, + verbose_name=_("Serviser") + ) + # Označava je li vozilo u vlasništvu naše tvrtke (fleet) ili kupca + is_company_vehicle = models.BooleanField( + default=True, + verbose_name=_("Vozilo tvrtke"), + help_text=_("Označite ako vozilo pripada našoj tvrtki. Poništite za vozila kupaca.") + ) + # Kupac kojemu pripada vozilo (samo za vozila kupaca) + client = models.ForeignKey( + 'crm.Client', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='vehicles', + verbose_name=_("Kupac"), + help_text=_("Popunite samo za vozila kupaca (is_company_vehicle = Ne).") + ) + is_active = models.BooleanField(default=True, verbose_name=_("Aktivno")) + + class Meta: + verbose_name = _("Vozilo") + verbose_name_plural = _("Vozila") + ordering = ['registration_number'] + + def __str__(self): + return f"{self.registration_number} - {self.make} {self.model}" + + def clean(self): + super().clean() + if self.asset_type == 'crane': + if not self.crane_serial_number and self._state.adding: + raise ValidationError({'crane_serial_number': _("Serijski broj dizalice je obavezan za dizalice.")}) + if self.crane_serial_number and not re.fullmatch(r"[0-9]{6}", str(self.crane_serial_number or '')): + raise ValidationError({'crane_serial_number': _("Serijski broj dizalice mora sadržavati točno 6 znamenki.")}) + elif self.crane_serial_number: + if not re.fullmatch(r"[0-9]{6}", str(self.crane_serial_number or '')): + raise ValidationError({'crane_serial_number': _("Serijski broj dizalice mora sadržavati točno 6 znamenki.")}) + + def next_service_at(self) -> int: + """ + Vrati kilometražu pri kojoj sljedeći servis pada, bazirano na trenutnoj kilometraži i intervalu. + Npr. current=12000, interval=15000 -> next_service_at = 15000 + Npr. current=32000, interval=15000 -> next_service_at = 45000 + """ + if self.service_interval_km <= 0: + return 0 + blocks = (self.current_mileage // self.service_interval_km) + 1 + return blocks * self.service_interval_km + + +class Crane(Vehicle): + class Meta: + proxy = True + verbose_name = _("Dizalica") + verbose_name_plural = _("Dizalice") + ordering = ['registration_number'] + + def __str__(self): + details = " ".join(part for part in [self.make, self.model] if part) + if details: + return f"{self.registration_number} - {details}" + return self.registration_number + +class WorkOrder(BaseModel): + """ + Putni nalog / radni nalog koji prati kilometražu vozila tijekom zadatka na terenu. + Kad se postavi end_mileage, model ažurira vehicle.current_mileage ako je end_mileage veći. + """ + STATUS_CHOICES = [ + ('open', 'Otvoren'), + ('closed', 'Zatvoren'), + ] + PURPOSE_CHOICES = [ + ('defektaza', _("Defektaža")), + ('kontrola', _("Kontrola")), + ('redovni_pregled', _("Redovni pregled")), + ] + + vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo")) + creator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_work_orders', verbose_name=_("Kreirao")) + date = models.DateField(auto_now_add=True, verbose_name=_("Datum")) + start_mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Početna kilometraža")) + end_mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Završna kilometraža")) + servicer_vehicle_registration = models.CharField( + max_length=20, + blank=True, + verbose_name=_("Vozilo servisera - registracija"), + ) + servicer_vehicle_make_model = models.CharField( + max_length=120, + blank=True, + verbose_name=_("Vozilo servisera - marka/model"), + ) + servicer_vehicle_start_mileage = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Vozilo servisera - početna kilometraža"), + ) + servicer_vehicle_end_mileage = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name=_("Vozilo servisera - završna kilometraža"), + ) + servicer_vehicle_fuel_cost = models.DecimalField( + max_digits=10, + decimal_places=2, + null=True, + blank=True, + verbose_name=_("Vozilo servisera - trošak goriva (EUR)"), + ) + location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija odredišta")) + travel_start_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Početak puta")) + travel_end_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Kraj puta")) + purpose = models.CharField(max_length=50, choices=PURPOSE_CHOICES, blank=True, verbose_name=_("Svrha")) + notes = models.TextField(blank=True, verbose_name=_("Bilješke")) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open', verbose_name=_("Status")) + + class Meta: + verbose_name = _("Putni nalog") + verbose_name_plural = _("Putni nalozi") + ordering = ['-created_at'] + + def __str__(self): + return f"WO-{self.pk} {self.vehicle.registration_number} ({self.status})" + + @property + def distance(self): + if self.end_mileage is None or self.start_mileage is None: + return None + return max(0, self.end_mileage - self.start_mileage) + + def save(self, *args, **kwargs): + self.full_clean() # Provjera validnosti prije svega + updating_vehicle = False + if self.end_mileage is not None: + # Ako je end_mileage veći od trenutne vehicle.current_mileage, ažuriraj vozilo. + if self.end_mileage > (self.vehicle.current_mileage or 0): + self.vehicle.current_mileage = self.end_mileage + updating_vehicle = True + + super().save(*args, **kwargs) + + if updating_vehicle: + # Spremi vozilo (nakon što nalog ima PK) + self.vehicle.save() + + def clean(self): + super().clean() + if self.travel_start_at and self.travel_end_at and self.travel_end_at < self.travel_start_at: + raise ValidationError({"travel_end_at": _("Kraj puta ne može biti prije početka puta.")}) + + +class WorkOrderInvoice(BaseModel): + work_order = models.ForeignKey( + WorkOrder, + on_delete=models.CASCADE, + related_name='invoices', + verbose_name=_("Putni nalog"), + ) + naziv_racuna = models.CharField(max_length=255, verbose_name=_("Naziv računa")) + lokacija = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija")) + datum = models.DateField(default=timezone.now, verbose_name=_("Datum")) + opis = models.TextField(blank=True, verbose_name=_("Opis")) + image = models.ImageField( + upload_to=_work_order_invoice_upload_to, + null=True, + blank=True, + verbose_name=_("Slika računa"), + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='created_work_order_invoices', + verbose_name=_("Kreirao"), + ) + is_active = models.BooleanField(default=True, verbose_name=_("Aktivan")) + + class Meta: + verbose_name = _("Račun putnog naloga") + verbose_name_plural = _("Računi putnog naloga") + ordering = ['-datum', '-created_at'] + + def __str__(self): + return f"{self.naziv_racuna} ({self.work_order_id})" + + +class VehicleServiceRecord(BaseModel): + """ + Zapisi o servisima/popravcima na vozilu. + """ + vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='service_records', verbose_name=_("Vozilo")) + task = models.ForeignKey( + 'task_management.Task', + on_delete=models.SET_NULL, + related_name='service_records', + null=True, + blank=True, + verbose_name=_("Radni zadatak"), + ) + performed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='performed_vehicle_services', verbose_name=_("Izvršio")) + service_title = models.CharField(max_length=255, blank=True, default='', verbose_name=_("Naziv servisnog zapisa")) + description = models.TextField(verbose_name=_("Opis posla")) + parts = models.TextField(blank=True, verbose_name=_("Korišteni dijelovi")) + cost = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'), verbose_name=_("Trošak")) + service_date = models.DateField(auto_now_add=True, verbose_name=_("Datum servisa")) + mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Kilometraža pri servisu")) + next_service_due_at = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Kilometraža sljedećeg servisa")) + + class Meta: + verbose_name = _("Servisni zapis") + verbose_name_plural = _("Servisni zapisi") + ordering = ['-service_date'] + + def __str__(self): + mileage_display = self.mileage if self.mileage is not None else "-" + title = (self.service_title or '').strip() + if title: + return f"{title} ({self.vehicle.registration_number} @ {mileage_display})" + return f"Service {self.vehicle.registration_number} @ {mileage_display}" + + def clean(self): + super().clean() + if self.task_id and self.vehicle_id: + task = self.task + if task.vehicle_id and task.vehicle_id != self.vehicle_id: + raise ValidationError({'task': _("Radni zadatak mora biti povezan s istom dizalicom kao servisni zapis.")}) + + def save(self, *args, **kwargs): + self.full_clean() # Provjera validnosti prije svega + # Ako nije zadan next_service_due_at, postavi prema vehicle.service_interval_km + if self.next_service_due_at is None and self.mileage is not None and self.vehicle and self.vehicle.service_interval_km: + self.next_service_due_at = self.mileage + self.vehicle.service_interval_km + + # VAŽNO: ažuriraj vehicle.current_mileage PRIJE save-a kako bi refresh-om nakon toga korisnik vidio svježe podatke + if self.mileage is not None and self.mileage > (self.vehicle.current_mileage or 0): + self.vehicle.current_mileage = self.mileage + self.vehicle.save(update_fields=['current_mileage']) + + super().save(*args, **kwargs) + +class VehicleNotification(BaseModel): + """ + Jednostavni zapis notifikacije vezane uz fleet objekte. + Može se koristiti za in-app obavijesti ili kao log koji je poslan. + """ + LEVEL_CHOICES = [ + ('info', 'Info'), + ('warning', 'Upozorenje'), + ('critical', 'Kritično'), + ] + + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='vehicle_notifications', + verbose_name=_("Primatelj") + ) + title = models.CharField(max_length=255, verbose_name=_("Naslov")) + message = models.TextField(verbose_name=_("Poruka")) + metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci")) + level = models.CharField(max_length=20, choices=LEVEL_CHOICES, default='info', verbose_name=_("Razina")) + is_sent = models.BooleanField(default=False, verbose_name=_("Poslano (e-mail)")) + is_read = models.BooleanField(default=False, verbose_name=_("Pročitano")) + + class Meta: + verbose_name = _("Notifikacija za vozilo") + verbose_name_plural = _("Notifikacije za vozila") + ordering = ['-created_at'] + + def __str__(self): + return f"{self.title} -> {self.recipient} ({self.level})" \ No newline at end of file diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py new file mode 100644 index 0000000..5a72815 --- /dev/null +++ b/backend/modules/fleet/serializers.py @@ -0,0 +1,314 @@ +from rest_framework import serializers +import re +from .models import ( + Crane, + Vehicle, + VehicleNotification, + WorkOrder, + WorkOrderInvoice, + VehicleServiceRecord, + VehicleServicePhoto, + VehicleServiceAttachment, +) +from . import services + + +class VehicleSerializer(serializers.ModelSerializer): + # Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu + client_name = serializers.SerializerMethodField() + + class Meta: + model = Vehicle + fields = [ + 'id', 'asset_type', 'registration_number', 'make', 'model', 'year', 'vin', + 'crane_serial_number', + 'superstructure_working_hours', 'chassis_working_hours', + 'current_mileage', 'service_interval_km', + 'is_company_vehicle', 'client', 'client_name', + 'assigned_servicer', 'is_active', + ] + read_only_fields = ['id', 'current_mileage', 'client_name'] + + def get_client_name(self, obj): + if obj.client_id: + return obj.client.name + return None + + def create(self, validated_data): + validated_data.setdefault('asset_type', 'vehicle') + return services.VehicleService.create_vehicle(validated_data) + + def update(self, instance, validated_data): + validated_data.pop('asset_type', None) + return services.VehicleService.update_vehicle(instance, validated_data) + + def validate_crane_serial_number(self, value): + if value in (None, ''): + return None + normalized = str(value).strip() + if not re.fullmatch(r"[0-9]{6}", normalized): + raise serializers.ValidationError('Serijski broj dizalice mora sadržavati točno 6 znamenki.') + return normalized + + def validate(self, attrs): + asset_type = attrs.get('asset_type', getattr(self.instance, 'asset_type', None)) + if asset_type is None and getattr(self.Meta, 'model', None) is Crane: + asset_type = 'crane' + serial = attrs.get('crane_serial_number', getattr(self.instance, 'crane_serial_number', None)) + is_create = self.instance is None + if asset_type == 'crane' and not serial and is_create: + raise serializers.ValidationError({ + 'crane_serial_number': 'Serijski broj dizalice je obavezan za dizalice.' + }) + return attrs + +class CraneSerializer(VehicleSerializer): + display_name = serializers.SerializerMethodField() + + class Meta(VehicleSerializer.Meta): + model = Crane + fields = VehicleSerializer.Meta.fields + ['display_name'] + read_only_fields = VehicleSerializer.Meta.read_only_fields + ['display_name'] + + def get_display_name(self, obj): + details = " ".join(part for part in [obj.make, obj.model] if part) + return f"{obj.registration_number} {details}".strip() + + def create(self, validated_data): + validated_data['asset_type'] = 'crane' + return services.VehicleService.create_vehicle(validated_data) + + def update(self, instance, validated_data): + validated_data.pop('asset_type', None) + return services.VehicleService.update_vehicle(instance, validated_data) + +class VehicleNotificationSerializer(serializers.ModelSerializer): + class Meta: + model = VehicleNotification + fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'is_read', 'created_at'] + read_only_fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'created_at'] + # is_read is writable via the "mark_read" action, but normally read-only in list + +class WorkOrderSerializer(serializers.ModelSerializer): + crane = serializers.SerializerMethodField() + crane_label = serializers.SerializerMethodField() + + class Meta: + model = WorkOrder + fields = [ + 'id', 'vehicle', 'crane', 'crane_label', 'creator', 'date', + 'start_mileage', 'end_mileage', + 'servicer_vehicle_registration', 'servicer_vehicle_make_model', + 'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage', + 'servicer_vehicle_fuel_cost', + 'location', 'travel_start_at', 'travel_end_at', + 'purpose', 'notes', 'status' + ] + read_only_fields = ['id', 'date'] + extra_kwargs = { + 'vehicle': {'required': False}, + 'creator': {'required': False}, + 'start_mileage': {'required': False, 'allow_null': True}, + 'end_mileage': {'required': False, 'allow_null': True}, + 'servicer_vehicle_registration': {'required': False, 'allow_blank': True}, + 'servicer_vehicle_make_model': {'required': False, 'allow_blank': True}, + 'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True}, + 'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True}, + 'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True}, + 'location': {'required': False, 'allow_blank': True}, + 'travel_start_at': {'required': False, 'allow_null': True}, + 'travel_end_at': {'required': False, 'allow_null': True}, + } + + def get_crane(self, obj): + return obj.vehicle_id + + def get_crane_label(self, obj): + if not obj.vehicle_id: + return None + details = " ".join(part for part in [obj.vehicle.registration_number, obj.vehicle.make, obj.vehicle.model] if part) + return details.strip() + + def create(self, validated_data): + request = self.context.get('request') + user = getattr(request, 'user', None) + + if not validated_data.get('vehicle'): + raise serializers.ValidationError({ + 'vehicle': 'Odaberite dizalicu za putni nalog iz servisnog konteksta.' + }) + if getattr(validated_data['vehicle'], 'asset_type', None) != 'crane': + raise serializers.ValidationError({ + 'vehicle': 'Za putni nalog morate odabrati dizalicu (asset_type=crane).' + }) + + if user and 'creator' not in validated_data: + validated_data['creator'] = user + + return services.VehicleService.create_work_order(validated_data) + + def update(self, instance, validated_data): + return services.VehicleService.update_work_order(instance, validated_data) + + def validate(self, attrs): + current = getattr(self, 'instance', None) + travel_start_at = attrs.get('travel_start_at', getattr(current, 'travel_start_at', None)) + travel_end_at = attrs.get('travel_end_at', getattr(current, 'travel_end_at', None)) + location = attrs.get('location', getattr(current, 'location', None)) + + start_mileage = attrs.get('start_mileage', getattr(current, 'start_mileage', None)) + end_mileage = attrs.get('end_mileage', getattr(current, 'end_mileage', None)) + servicer_start_mileage = attrs.get('servicer_vehicle_start_mileage', getattr(current, 'servicer_vehicle_start_mileage', None)) + servicer_end_mileage = attrs.get('servicer_vehicle_end_mileage', getattr(current, 'servicer_vehicle_end_mileage', None)) + servicer_fuel_cost = attrs.get('servicer_vehicle_fuel_cost', getattr(current, 'servicer_vehicle_fuel_cost', None)) + + if (travel_start_at is None) ^ (travel_end_at is None): + raise serializers.ValidationError({'travel_end_at': 'Unesite i početak i kraj puta, ili ostavite oba prazna.'}) + if travel_start_at is not None and not str(location or '').strip(): + raise serializers.ValidationError({'location': 'Lokacija je obavezna kada je definiran putni nalog.'}) + if travel_start_at and travel_end_at and travel_end_at < travel_start_at: + raise serializers.ValidationError({'travel_end_at': 'Kraj puta ne može biti prije početka puta.'}) + if end_mileage is not None and start_mileage is None: + raise serializers.ValidationError({'start_mileage': 'Početna kilometraža je obavezna ako je upisana završna.'}) + if start_mileage is not None and end_mileage is not None and end_mileage < start_mileage: + raise serializers.ValidationError({'end_mileage': 'Završna kilometraža mora biti veća ili jednaka početnoj.'}) + if servicer_end_mileage is not None and servicer_start_mileage is None: + raise serializers.ValidationError({'servicer_vehicle_start_mileage': 'Početna kilometraža vozila servisera je obavezna ako je upisana završna.'}) + if servicer_start_mileage is not None and servicer_end_mileage is not None and servicer_end_mileage <= servicer_start_mileage: + raise serializers.ValidationError({'servicer_vehicle_end_mileage': 'Završna kilometraža vozila servisera mora biti veća od početne.'}) + if servicer_fuel_cost is not None and servicer_fuel_cost < 0: + raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'}) + return attrs + + +class WorkOrderInvoiceSerializer(serializers.ModelSerializer): + class Meta: + model = WorkOrderInvoice + fields = [ + 'id', + 'work_order', + 'naziv_racuna', + 'lokacija', + 'datum', + 'opis', + 'image', + 'created_by', + 'created_at', + ] + read_only_fields = ['id', 'created_by', 'created_at'] + + +class VehicleServiceRecordSerializer(serializers.ModelSerializer): + crane = serializers.SerializerMethodField() + crane_registration = serializers.SerializerMethodField() + photos_count = serializers.SerializerMethodField() + attachments_count = serializers.SerializerMethodField() + files_count = serializers.SerializerMethodField() + performed_by_name = serializers.SerializerMethodField() + vehicle_registration = serializers.SerializerMethodField() + task_label = serializers.SerializerMethodField() + + class Meta: + model = VehicleServiceRecord + fields = [ + 'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration', + 'task', 'task_label', + 'performed_by', 'performed_by_name', + 'service_title', 'description', 'parts', 'cost', + 'service_date', 'mileage', 'next_service_due_at', + 'photos_count', 'attachments_count', 'files_count', + ] + read_only_fields = [ + 'id', 'service_date', 'next_service_due_at', + 'photos_count', 'attachments_count', 'files_count', + 'performed_by_name', 'vehicle_registration', 'crane', 'crane_registration', + 'task_label', + ] + extra_kwargs = { + 'performed_by': {'required': False}, + 'vehicle': {'required': False}, + 'mileage': {'required': False, 'allow_null': True}, + } + + def get_photos_count(self, obj): + # prefetch_related('photos') u viewsetu sprječava N+1 + return obj.photos.count() + + def get_crane(self, obj): + return obj.vehicle_id + + def get_attachments_count(self, obj): + return obj.attachments.count() + + def get_files_count(self, obj): + return self.get_photos_count(obj) + self.get_attachments_count(obj) + + def get_performed_by_name(self, obj): + if obj.performed_by_id: + return obj.performed_by.get_full_name() or obj.performed_by.username + return None + + def get_vehicle_registration(self, obj): + if obj.vehicle_id: + return obj.vehicle.registration_number + return None + + def get_crane_registration(self, obj): + return self.get_vehicle_registration(obj) + + def get_task_label(self, obj): + if not obj.task_id: + return None + return obj.task.title + + def create(self, validated_data): + request = self.context.get('request') + user = getattr(request, 'user', None) + task = validated_data.get('task') + if not task: + raise serializers.ValidationError({ + 'task': 'Odaberite radni zadatak prije kreiranja servisnog zapisa.' + }) + if not task.vehicle_id: + raise serializers.ValidationError({ + 'task': 'Odabrani radni zadatak nema dodijeljenu dizalicu.' + }) + validated_data['vehicle'] = task.vehicle + + if user and not getattr(user, 'is_staff', False): + validated_data['performed_by'] = user + elif user and 'performed_by' not in validated_data: + validated_data['performed_by'] = user + return services.VehicleService.create_service_record(validated_data) + +class VehicleServicePhotoSerializer(serializers.ModelSerializer): + image = serializers.ImageField(required=True) + + class Meta: + model = VehicleServicePhoto + fields = ['id', 'service_record', 'image', 'description', 'uploaded_by', 'created_at'] + read_only_fields = ['id', 'created_at', 'uploaded_by'] + + def create(self, validated_data): + # Ako postoji request user, postavi uploaded_by + request = self.context.get('request') if self.context else None + user = getattr(request, 'user', None) + if user and 'uploaded_by' not in validated_data: + validated_data['uploaded_by'] = user + # Kreiranje direktno kroz model je ok; može se delegirati u services ako želiš dodatnu logiku + return VehicleServicePhoto.objects.create(**validated_data) + +class VehicleServiceAttachmentSerializer(serializers.ModelSerializer): + file = serializers.FileField(required=True) + + class Meta: + model = VehicleServiceAttachment + fields = ['id', 'service_record', 'file', 'description', 'uploaded_by', 'created_at'] + read_only_fields = ['id', 'created_at', 'uploaded_by'] + + def create(self, validated_data): + request = self.context.get('request') if self.context else None + user = getattr(request, 'user', None) + if user and 'uploaded_by' not in validated_data: + validated_data['uploaded_by'] = user + return VehicleServiceAttachment.objects.create(**validated_data) \ No newline at end of file diff --git a/backend/modules/fleet/services.py b/backend/modules/fleet/services.py new file mode 100644 index 0000000..dfd32bf --- /dev/null +++ b/backend/modules/fleet/services.py @@ -0,0 +1,412 @@ +from django.db import transaction +from django.conf import settings +from django.contrib.auth import get_user_model +from rest_framework.exceptions import ValidationError +from modules.fleet.tasks import send_email_task +from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleNotification, VehicleServicePhoto +from infrastructure.pusher_service import PusherService +import logging + +logger = logging.getLogger(__name__) + +User = get_user_model() + +class NotificationService: + @staticmethod + def create_notification(recipient=None, title="", message="", level="info", send_email=False, metadata=None): + # 1. Kreiraj notifikaciju u DB-u + notif = VehicleNotification.objects.create( + recipient=recipient, + title=title[:255], + message=message, + metadata=metadata or {}, + level=level, + is_sent=False + ) + + # 2. Broadcast preko WebSocket (Pusher) tek nakon uspješnog commit-a + if recipient: + transaction.on_commit(lambda: PusherService.broadcast_notification( + user_id=recipient.id, + title=title, + message=message, + level=level, + notification_id=notif.id, + metadata=metadata + )) + + # 3. Slanje emaila ako je traženo + if send_email and recipient and getattr(recipient, 'email', None): + email_args = { + 'recipient_email': recipient.email, + 'subject': title, + 'message': message, + 'notif_id': notif.id + } + + if settings.DEBUG or getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False): + send_email_task(**email_args) + else: + transaction.on_commit(lambda: send_email_task.delay(**email_args)) + + return notif +class VehicleService: + @staticmethod + def create_vehicle(data: dict) -> Vehicle: + vehicle = Vehicle(**data) + try: + vehicle.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + + vehicle.save() + return vehicle + + @staticmethod + def update_vehicle(instance: Vehicle, data: dict) -> Vehicle: + for k, v in data.items(): + setattr(instance, k, v) + try: + instance.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + instance.save() + return instance + + @staticmethod + def create_work_order(data: dict) -> WorkOrder: + # expecting creator in data + wo = WorkOrder(**data) + try: + wo.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + + with transaction.atomic(): + wo.save() + + # obavijesti creatora i serviser-a vozila + try: + # Notify the creator + NotificationService.create_notification( + recipient=wo.creator, + title=f"Putni nalog kreiran: WO-{wo.pk}", + message=f"Putni nalog za dizalicu {wo.vehicle.registration_number} je kreiran.", + metadata={ + "entity_type": "work_order", + "work_order_id": str(wo.pk), + "vehicle_id": str(wo.vehicle_id), + "crane_id": str(wo.vehicle_id), + "section": "work-orders", + }, + level="info", + send_email=False + ) + # Notify assigned servicer if present + servicer = wo.vehicle.assigned_servicer + if servicer: + NotificationService.create_notification( + recipient=servicer, + title=f"Novi putni nalog za {wo.vehicle.registration_number}", + message=f"Imate novi putni nalog (WO-{wo.pk}) za dizalicu {wo.vehicle.registration_number}.", + metadata={ + "entity_type": "work_order", + "work_order_id": str(wo.pk), + "vehicle_id": str(wo.vehicle_id), + "crane_id": str(wo.vehicle_id), + "section": "work-orders", + }, + level="info", + send_email=False + ) + except Exception: + logger.exception("Greška pri slanju notifikacija za create_work_order") + + return wo + + @staticmethod + def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder: + for k, v in data.items(): + setattr(instance, k, v) + try: + instance.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + with transaction.atomic(): + instance.save() + # Ako je nalog zatvoren, obavijestimo kreatora/servisera + if instance.status == 'closed': + try: + NotificationService.create_notification( + recipient=instance.creator, + title=f"Putni nalog zatvoren: WO-{instance.pk}", + message=f"Putni nalog za dizalicu {instance.vehicle.registration_number} je zatvoren.", + metadata={ + "entity_type": "work_order", + "work_order_id": str(instance.pk), + "vehicle_id": str(instance.vehicle_id), + "crane_id": str(instance.vehicle_id), + "section": "work-orders", + }, + level="info", + send_email=False + ) + servicer = instance.vehicle.assigned_servicer + if servicer: + NotificationService.create_notification( + recipient=servicer, + title=f"Putni nalog zatvoren za {instance.vehicle.registration_number}", + message=f"Putni nalog (WO-{instance.pk}) je zatvoren.", + metadata={ + "entity_type": "work_order", + "work_order_id": str(instance.pk), + "vehicle_id": str(instance.vehicle_id), + "crane_id": str(instance.vehicle_id), + "section": "work-orders", + }, + level="info", + send_email=False + ) + except Exception: + logger.exception("Greška pri slanju notifikacija za update_work_order") + return instance + + @staticmethod + def delete_work_order(instance: WorkOrder, user=None) -> WorkOrder: + """ + Soft-delete putnog naloga. Samo creator ili staff može obrisati. + Vraća deaktivirani instance. + """ + # Provjera prava: samo creator ili staff + if not user: + raise ValidationError("Korisnik nije naveden za operaciju brisanja.") + if not (user.is_staff or instance.creator == user): + raise ValidationError("Nemate dozvolu za brisanje ovog putnog naloga.") + + instance.is_active = False + instance.save(update_fields=['is_active']) + + # Notifikacija o brisanju + try: + NotificationService.create_notification( + recipient=instance.creator, + title=f"Putni nalog obrisan: WO-{instance.pk}", + message=f"Putni nalog za dizalicu {instance.vehicle.registration_number} je obrisan od strane {user}.", + metadata={ + "entity_type": "work_order", + "work_order_id": str(instance.pk), + "vehicle_id": str(instance.vehicle_id), + "crane_id": str(instance.vehicle_id), + "section": "work-orders", + "deleted": True, + }, + level="warning", + send_email=False + ) + servicer = instance.vehicle.assigned_servicer + if servicer: + NotificationService.create_notification( + recipient=servicer, + title=f"Putni nalog obrisan za {instance.vehicle.registration_number}", + message=f"Putni nalog (WO-{instance.pk}) je obrisan.", + metadata={ + "entity_type": "work_order", + "work_order_id": str(instance.pk), + "vehicle_id": str(instance.vehicle_id), + "crane_id": str(instance.vehicle_id), + "section": "work-orders", + "deleted": True, + }, + level="warning", + send_email=False + ) + except Exception: + logger.exception("Greška pri slanju notifikacija za delete_work_order") + + logger.info(f"WorkOrder {instance.pk} soft-deleted by {user}") + return instance + + @staticmethod + def create_service_record(data: dict) -> VehicleServiceRecord: + rec = VehicleServiceRecord(**data) + try: + rec.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + with transaction.atomic(): + rec.save() + # update vehicle mileage if needed + v = rec.vehicle + if rec.mileage and rec.mileage > (v.current_mileage or 0): + v.current_mileage = rec.mileage + v.save() + + # notify assigned servicer / creator + try: + # Notify performed_by user + NotificationService.create_notification( + recipient=rec.performed_by, + title=f"Servis zabilježen: {rec.vehicle.registration_number}", + message=( + f"Zabilježen servis na dizalici {rec.vehicle.registration_number}" + + (f" pri kilometraži {rec.mileage}." if rec.mileage is not None else ".") + ), + metadata={ + "entity_type": "service_record", + "service_record_id": str(rec.pk), + "vehicle_id": str(rec.vehicle_id), + "crane_id": str(rec.vehicle_id), + "section": "service-records", + }, + level="info", + send_email=False + ) + # Notify vehicle assigned servicer if different + servicer = rec.vehicle.assigned_servicer + if servicer and servicer != rec.performed_by: + NotificationService.create_notification( + recipient=servicer, + title=f"Servis na vašem vozilu {rec.vehicle.registration_number}", + message=f"Servis je izvršen na dizalici {rec.vehicle.registration_number}.", + metadata={ + "entity_type": "service_record", + "service_record_id": str(rec.pk), + "vehicle_id": str(rec.vehicle_id), + "crane_id": str(rec.vehicle_id), + "section": "service-records", + }, + level="info", + send_email=False + ) + # If next service is soon (e.g., within 1000 km), create a warning notification + next_due = rec.next_service_due_at + if next_due is None and rec.mileage is not None: + next_due = rec.mileage + rec.vehicle.service_interval_km + if next_due is not None and rec.mileage is not None and (next_due - rec.mileage) <= 1000: + if servicer: + NotificationService.create_notification( + recipient=servicer, + title=f"Uskoro servis za {rec.vehicle.registration_number}", + message=f"Sljedeći servis za dizalicu {rec.vehicle.registration_number} očekuje se na ~{next_due} km.", + metadata={ + "entity_type": "service_record", + "service_record_id": str(rec.pk), + "vehicle_id": str(rec.vehicle_id), + "crane_id": str(rec.vehicle_id), + "section": "service-records", + "next_service_due_at": next_due, + }, + level="warning", + send_email=False + ) + except Exception: + logger.exception("Greška pri slanju notifikacija za create_service_record") + return rec + +class VehicleServicePhotoService: + """ + Servisna klasa za upravljanje VehicleServicePhoto zapisima. + Delegira upload, ažuriranje i brisanje s validacijom i sigurnosnim provjerama. + """ + + @staticmethod + def upload_photo(data: dict, uploaded_by=None) -> VehicleServicePhoto: + """ + Kreiraj VehicleServicePhoto s file upload-om. + data treba sadržavati: service_record (ID ili instancu), image (UploadedFile), description (opcionalno). + """ + if not uploaded_by: + raise ValidationError("Korisnik je obavezan za upload fotografije.") + + # Ako je service_record ID ili UUID, dohvati instancu + service_record = data.get('service_record') + + # Provjeri je li već VehicleServiceRecord instanca + if not isinstance(service_record, VehicleServiceRecord): + # Pokušaj dohvatiti po ID-u (int, str, UUID) + try: + service_record = VehicleServiceRecord.objects.get(pk=service_record) + except (VehicleServiceRecord.DoesNotExist, ValueError, TypeError): + raise ValidationError("Servisni zapis ne postoji ili ID nije valjan.") + + # Kreiraj novi dict s istim podacima ali sigurno postavljenim service_record + photo_data = { + 'service_record': service_record, + 'image': data.get('image'), + 'description': data.get('description', ''), + 'uploaded_by': uploaded_by + } + + photo = VehicleServicePhoto(**photo_data) + try: + photo.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + + photo.save() + logger.info(f"Fotografija uploadana od strane {uploaded_by} za servisni zapis {service_record.id}") + return photo + + @staticmethod + def update_photo(instance: VehicleServicePhoto, data: dict, user=None) -> VehicleServicePhoto: + """ + Ažuriraj VehicleServicePhoto. Samo uploader ili staff mogu ažurirati. + """ + # Sigurnosna provjera: samo uploader ili staff mogu ažurirati + if not (user and (user.is_staff or instance.uploaded_by == user)): + raise ValidationError("Nemate dozvolu za ažuriranje ove fotografije.") + + for attr, value in data.items(): + if attr != 'image': # ne dozvoli ponovno upload slike (opciono) + setattr(instance, attr, value) + + try: + instance.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + + instance.save() + logger.info(f"Fotografija {instance.id} ažurirana od strane {user}") + return instance + + @staticmethod + def delete_photo(instance: VehicleServicePhoto, user=None) -> VehicleServicePhoto: + """ + Soft-delete VehicleServicePhoto. Samo uploader ili staff mogu obrisati. + """ + if not (user and (user.is_staff or instance.uploaded_by == user)): + raise ValidationError("Nemate dozvolu za brisanje ove fotografije.") + + instance.is_active = False + instance.save(update_fields=['is_active']) + logger.info(f"Fotografija {instance.id} soft-deleted od strane {user}") + return instance + +class FleetService: + @staticmethod + def bulk_close_work_orders(work_orders_qs): + updated_vehicles = {} + + with transaction.atomic(): + for wo in work_orders_qs: + # 1. Preskoči ako je već zatvoren + if wo.status == 'closed': + continue + + # 2. NOVO: Preskoči ako nalog nije kompletan (nema kilometražu) + if wo.end_mileage is None: + continue + + # 3. Zatvori nalog + wo.status = 'closed' + wo.save() + + # 4. Ažuriraj vozilo + vehicle = wo.vehicle + current_max = updated_vehicles.get(vehicle.pk, vehicle.current_mileage) + if wo.end_mileage > current_max: + updated_vehicles[vehicle.pk] = wo.end_mileage + + # Masovno ažuriranje vozila + for v_id, new_mileage in updated_vehicles.items(): + Vehicle.objects.filter(pk=v_id).update(current_mileage=new_mileage) \ No newline at end of file diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py new file mode 100644 index 0000000..3cd4a5a --- /dev/null +++ b/backend/modules/fleet/tasks.py @@ -0,0 +1,165 @@ +# backend/modules/fleet/tasks.py +from celery import shared_task +from django.core.mail import send_mail +from django.conf import settings +import logging +from io import BytesIO +import base64 +from PIL import Image, UnidentifiedImageError +from reportlab.lib.pagesizes import A4 +from reportlab.lib.utils import ImageReader +from reportlab.pdfgen import canvas +from .models import VehicleNotification + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=60) +def send_notification_email(self, notification_id): + """ + Celery task koji pošalje e-mail za VehicleNotification i označi is_sent = True. + Prihvaća notification_id (PK) i radi safe-lookup. + U slučaju greške rekreira retry. + """ + try: + # Lokalni import kako bismo izbjegli kružne importove + from .models import VehicleNotification + notif = VehicleNotification.objects.select_related('recipient').get(pk=notification_id) + except Exception as exc: + logger.warning(f"Notification {notification_id} ne postoji ili nije dostupna: {exc}") + return + + recipient = notif.recipient + recipient_email = getattr(recipient, 'email', None) if recipient else None + + if not recipient_email: + logger.info(f"Notification {notification_id} nema email primatelja; preskačem slanje.") + return + + subject = notif.title + message = notif.message + from_email = getattr(settings, "DEFAULT_FROM_EMAIL", None) + + try: + send_mail( + subject=subject, + message=message, + from_email=from_email, + recipient_list=[recipient_email], + fail_silently=False, + ) + notif.is_sent = True + notif.save(update_fields=['is_sent']) + logger.info(f"Notification email poslan za Notification(id={notification_id}) na {recipient_email}") + except Exception as exc: + logger.exception(f"Greška pri slanju emaila za Notification(id={notification_id}): {exc}") + try: + # Retry with exponential backoff handled by celery config; here max_retries = 3 + raise self.retry(exc=exc) + except self.MaxRetriesExceededError: + logger.error(f"Max retries exceeded za Notification(id={notification_id})") + +@shared_task +def send_email_task(recipient_email, subject, message, notif_id): + try: + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[recipient_email], + fail_silently=False, + ) + VehicleNotification.objects.filter(id=notif_id).update(is_sent=True) + except Exception as exc: + logger.error(f"Async mail failed: {exc}") + + +def _build_work_order_invoices_pdf(work_order): + buffer = BytesIO() + pdf = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 + y = height - 50 + + pdf.setFont("Helvetica-Bold", 14) + pdf.drawString(40, y, f"Računi putnog naloga WO-{work_order.pk}") + y -= 22 + pdf.setFont("Helvetica", 10) + pdf.drawString(40, y, f"Dizalica: {work_order.vehicle.registration_number}") + y -= 16 + pdf.drawString(40, y, f"Datum naloga: {work_order.date}") + + invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at') + if not invoices.exists(): + y -= 24 + pdf.drawString(40, y, "Nema računa za ovaj putni nalog.") + pdf.save() + return buffer.getvalue() + + for index, invoice in enumerate(invoices, start=1): + pdf.showPage() + y = height - 50 + pdf.setFont("Helvetica-Bold", 13) + pdf.drawString(40, y, f"Račun #{index}") + y -= 22 + pdf.setFont("Helvetica", 10) + pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}") + y -= 16 + pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}") + y -= 16 + pdf.drawString(40, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}") + y -= 16 + pdf.drawString(40, y, f"opis: {(invoice.opis or '-')[:140]}") + y -= 20 + + if not invoice.image: + pdf.drawString(40, y, "slika: Nema slike.") + continue + + try: + invoice.image.open('rb') + with Image.open(invoice.image) as source: + image = source.convert('RGB') + max_width = width - 80 + max_height = y - 60 + if max_height < 120: + pdf.showPage() + y = height - 60 + max_height = y - 60 + + ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0) + draw_width = max(1, int(image.width * ratio)) + draw_height = max(1, int(image.height * ratio)) + image_reader = ImageReader(image) + pdf.drawImage( + image_reader, + 40, + y - draw_height, + width=draw_width, + height=draw_height, + preserveAspectRatio=True, + mask='auto', + ) + except (UnidentifiedImageError, OSError): + pdf.drawString(40, y, "slika: Slika nije dostupna ili je oštećena.") + finally: + invoice.image.close() + + pdf.save() + return buffer.getvalue() + + +@shared_task +def build_work_order_invoices_pdf_task(work_order_id): + from .models import WorkOrder + + work_order = WorkOrder.objects.select_related('vehicle').filter(pk=work_order_id).first() + if work_order is None: + return {"error": "Putni nalog nije pronađen."} + + pdf_bytes = _build_work_order_invoices_pdf(work_order) + pdf_b64 = base64.b64encode(pdf_bytes).decode('ascii') + filename = f"{work_order.pk}.work-order-invoices.pdf" + return { + "filename": filename, + "pdf_base64": pdf_b64, + } \ No newline at end of file diff --git a/backend/modules/fleet/tests/conftest.py b/backend/modules/fleet/tests/conftest.py new file mode 100644 index 0000000..e26769f --- /dev/null +++ b/backend/modules/fleet/tests/conftest.py @@ -0,0 +1,18 @@ +# conftest.py +import pytest +import uuid +from django.contrib.auth import get_user_model + +@pytest.fixture +def test_user(): + return get_user_model().objects.create_user( + username=f"user_{uuid.uuid4().hex[:8]}", + email=f"user+{uuid.uuid4().hex[:8]}@example.test", + password="pass" + ) + +@pytest.fixture(autouse=True) +def setup_test_celery_settings(settings): + settings.CELERY_TASK_ALWAYS_EAGER = True + settings.CELERY_TASK_EAGER_PROPAGATES=True # Ovo osigurava da greške u tasku odmah pucaju u testu + settings.BROKER_BACKEND='memory', # Koristi in-memory broker umjesto Redis-a diff --git a/backend/modules/fleet/tests/test_bulk_close_work_orders.py b/backend/modules/fleet/tests/test_bulk_close_work_orders.py new file mode 100644 index 0000000..e3dc022 --- /dev/null +++ b/backend/modules/fleet/tests/test_bulk_close_work_orders.py @@ -0,0 +1,72 @@ +import pytest +from modules.fleet.models import Vehicle, WorkOrder +from modules.fleet.services import FleetService +from django.contrib.auth import get_user_model + +@pytest.mark.django_db +def test_bulk_close_work_orders_updates_mileage(test_user): + + # 1. Kreiraj vozilo + vehicle = Vehicle.objects.create(registration_number="ZG-001", current_mileage=1000) + + # 2. Kreiraj više otvorenih naloga + wo1 = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1000, end_mileage=1200) + wo2 = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1200, end_mileage=1500) + + # 3. Pozovi bulk servis + queryset = WorkOrder.objects.filter(pk__in=[wo1.pk, wo2.pk]) + FleetService.bulk_close_work_orders(queryset) + + # 4. Provjeri rezultate + vehicle.refresh_from_db() + wo1.refresh_from_db() + wo2.refresh_from_db() + + # Statusi moraju biti zatvoreni + assert wo1.status == 'closed' + assert wo2.status == 'closed' + + # Kilometraža vozila mora biti najveća (1500) + assert vehicle.current_mileage == 1500 + +@pytest.mark.django_db +def test_bulk_close_ignores_already_closed_orders(test_user): + + vehicle = Vehicle.objects.create(registration_number="ZG-002", current_mileage=1000) + + # Već zatvoren nalog + wo = WorkOrder.objects.create(vehicle=vehicle, creator=test_user, start_mileage=1000, end_mileage=1100, status='closed') + + # Pozovi servis + FleetService.bulk_close_work_orders(WorkOrder.objects.filter(pk=wo.pk)) + + # Provjeri da nalog nije "pukao" niti promijenio stanje + wo.refresh_from_db() + assert wo.status == 'closed' + +@pytest.mark.django_db +def test_bulk_close_ignores_incomplete_work_order(test_user): + + vehicle = Vehicle.objects.create(registration_number="ZG-003", current_mileage=1000) + + # Nalog koji je otvoren i NEMA end_mileage + wo = WorkOrder.objects.create( + vehicle=vehicle, + creator=test_user, + start_mileage=1000, + end_mileage=None, # Incomplete + status='open' + ) + + # Pozovi servis + FleetService.bulk_close_work_orders(WorkOrder.objects.filter(pk=wo.pk)) + + # Provjeri: Nalog bi trebao ostati otvoren ili se možda ne bi smio zatvoriti + # jer podaci nisu kompletni + wo.refresh_from_db() + vehicle.refresh_from_db() + + # Ovisno o tvojoj poslovnoj logici: + # Ako nalog nije kompletan, ne bi ga se smjelo zatvoriti. + assert wo.status == 'open' + assert vehicle.current_mileage == 1000 \ No newline at end of file diff --git a/backend/modules/fleet/tests/test_crane_serial_number.py b/backend/modules/fleet/tests/test_crane_serial_number.py new file mode 100644 index 0000000..0c4b014 --- /dev/null +++ b/backend/modules/fleet/tests/test_crane_serial_number.py @@ -0,0 +1,67 @@ +import uuid + +from django.test import TestCase + +from modules.fleet.serializers import CraneSerializer + + +class CraneSerialNumberTests(TestCase): + def _base_payload(self): + suffix = uuid.uuid4().hex[:6].upper() + return { + "registration_number": f"ZG-CRN-{suffix}", + "make": "Liebherr", + "model": "LTM", + "year": 2024, + "current_mileage": 12000, + "service_interval_km": 8000, + "is_company_vehicle": True, + "is_active": True, + } + + def test_create_crane_with_serial_number_succeeds(self): + payload = { + **self._base_payload(), + "crane_serial_number": "123456", + } + serializer = CraneSerializer(data=payload) + self.assertTrue(serializer.is_valid(), serializer.errors) + + crane = serializer.save() + self.assertEqual(crane.asset_type, "crane") + self.assertEqual(crane.crane_serial_number, "123456") + + def test_create_crane_without_serial_number_fails(self): + payload = self._base_payload() + serializer = CraneSerializer(data=payload) + self.assertFalse(serializer.is_valid()) + self.assertIn("crane_serial_number", serializer.errors) + + def test_crane_serial_number_industry_standard_validation(self): + valid_payload = { + **self._base_payload(), + "registration_number": f"ZG-CRN-{uuid.uuid4().hex[:6].upper()}", + "crane_serial_number": "000123", + } + valid_serializer = CraneSerializer(data=valid_payload) + self.assertTrue(valid_serializer.is_valid(), valid_serializer.errors) + + invalid_serials = [ + "12345", # prekratko + "1234567", # predugo + "12A456", # slova nisu dozvoljena + "12 456", # razmaci nisu dozvoljeni + "12-456", # specijalni znakovi nisu dozvoljeni + "123456", # unicode znamenke nisu dozvoljene (samo ASCII 0-9) + ] + + for value in invalid_serials: + with self.subTest(serial=value): + payload = { + **self._base_payload(), + "registration_number": f"ZG-CRN-{uuid.uuid4().hex[:6].upper()}", + "crane_serial_number": value, + } + serializer = CraneSerializer(data=payload) + self.assertFalse(serializer.is_valid()) + self.assertIn("crane_serial_number", serializer.errors) diff --git a/backend/modules/fleet/tests/test_delete_work_order.py b/backend/modules/fleet/tests/test_delete_work_order.py new file mode 100644 index 0000000..59ada02 --- /dev/null +++ b/backend/modules/fleet/tests/test_delete_work_order.py @@ -0,0 +1,24 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from modules.fleet.models import Vehicle, WorkOrder, VehicleNotification +from modules.fleet.services import VehicleService +import uuid + +class WorkOrderDeleteTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username="u1", email=f"u1+{uuid.uuid4()}@example.test", password="pass") + self.other = User.objects.create_user(username="u2", email=f"u2+{uuid.uuid4()}@example.test", password="pass") + self.vehicle = Vehicle.objects.create(registration_number=f"V-{uuid.uuid4().hex[:6]}", current_mileage=1000) + self.wo = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, start_mileage=1000) + + def test_delete_by_creator_creates_notification(self): + VehicleService.delete_work_order(self.wo, user=self.user) + self.wo.refresh_from_db() + self.assertFalse(self.wo.is_active) + notif = VehicleNotification.objects.filter(recipient=self.user, title__icontains="obrisan").exists() + self.assertTrue(notif) + + def test_delete_by_non_creator_denied(self): + with self.assertRaises(Exception): + VehicleService.delete_work_order(self.wo, user=self.other) \ No newline at end of file diff --git a/backend/modules/fleet/tests/test_integration.py b/backend/modules/fleet/tests/test_integration.py new file mode 100644 index 0000000..76d20b5 --- /dev/null +++ b/backend/modules/fleet/tests/test_integration.py @@ -0,0 +1,28 @@ +import pytest +from django.core.exceptions import ValidationError +from modules.fleet.models import Vehicle +from django.contrib.auth import get_user_model + +@pytest.mark.django_db +def test_vehicle_servicer_one_to_one_constraint(test_user): + + # Prvo vozilo prolazi + Vehicle.objects.create(registration_number="ZG-111-AA", assigned_servicer=test_user) + + # Drugo vozilo za istog servisera mora baciti IntegrityError + with pytest.raises(Exception): # Django IntegrityError + Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user) + +@pytest.mark.django_db +def test_vehicle_next_service_logic(): + # current=12000, interval=15000 -> next_service_at = 15000 + vehicle = Vehicle.objects.create( + registration_number="ZG-001", + current_mileage=12000, + service_interval_km=15000 + ) + assert vehicle.next_service_at() == 15000 + + # current=16000, interval=15000 -> next_service_at = 30000 + vehicle.current_mileage = 16000 + assert vehicle.next_service_at() == 30000 \ No newline at end of file diff --git a/backend/modules/fleet/tests/test_models.py b/backend/modules/fleet/tests/test_models.py new file mode 100644 index 0000000..fede85c --- /dev/null +++ b/backend/modules/fleet/tests/test_models.py @@ -0,0 +1,33 @@ +import uuid +from django.test import TestCase +from django.contrib.auth import get_user_model +from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord + + +class FleetModelTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username="tech", email=f"tech+{uuid.uuid4()}@example.test", password="pass") + self.vehicle = Vehicle.objects.create( + registration_number=f"ZG-{uuid.uuid4().hex[:6].upper()}", + make="Toyota", + model="Yaris", + current_mileage=12000, + service_interval_km=15000, + assigned_servicer=self.user + ) + + def test_next_service_at(self): + self.assertEqual(self.vehicle.next_service_at(), 15000) + + def test_work_order_updates_vehicle_mileage(self): + wo = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, start_mileage=12000, end_mileage=12500) + self.vehicle.refresh_from_db() + self.assertEqual(self.vehicle.current_mileage, 12500) + self.assertEqual(wo.distance, 500) + + def test_service_record_sets_next_service_due_and_updates_vehicle(self): + rec = VehicleServiceRecord.objects.create(vehicle=self.vehicle, performed_by=self.user, description="Oil change", mileage=13000, parts="oil;filter", cost="50.00") + self.vehicle.refresh_from_db() + self.assertEqual(rec.next_service_due_at, 13000 + self.vehicle.service_interval_km) + self.assertEqual(self.vehicle.current_mileage, 13000) \ No newline at end of file diff --git a/backend/modules/fleet/tests/test_notifications.py b/backend/modules/fleet/tests/test_notifications.py new file mode 100644 index 0000000..a1a7a0a --- /dev/null +++ b/backend/modules/fleet/tests/test_notifications.py @@ -0,0 +1,201 @@ +import uuid +from django.test import TestCase, override_settings +from django.core import mail +from rest_framework.exceptions import ValidationError as DRFValidationError +from django.contrib.auth import get_user_model +from unittest.mock import patch + +from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleNotification +from modules.fleet.services import VehicleService, NotificationService + + +class NotificationEmailTests(TestCase): + @override_settings( + EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend', + DEFAULT_FROM_EMAIL='no-reply@example.test' + ) + def test_create_notification_sends_email_and_records_is_sent(self): + User = get_user_model() + user = User.objects.create_user(username=f"user_{uuid.uuid4().hex[:6]}", email=f"user+{uuid.uuid4()}@example.test", password="pass") + + # Ensure outbox is empty + mail.outbox.clear() + + title = "Test email notification" + message = "This is a test message." + + with patch('django.db.transaction.on_commit', lambda f: f()): + notif = NotificationService.create_notification( + recipient=user, + title=title, + message=message, + level="info", + send_email=True + ) + + # One email should have been sent + self.assertEqual(len(mail.outbox), 1) + sent = mail.outbox[0] + self.assertIn(title, sent.subject) + self.assertIn(message, sent.body) + # The notification record should exist and flagged as sent + notif.refresh_from_db() + self.assertTrue(notif.is_sent) + self.assertEqual(notif.recipient, user) + self.assertEqual(notif.title, title) + + +class NotificationRealtimeTests(TestCase): + @patch('modules.fleet.services.PusherService.broadcast_notification') + def test_create_notification_broadcasts_after_on_commit(self, mock_broadcast): + User = get_user_model() + user = User.objects.create_user( + username=f"ws_user_{uuid.uuid4().hex[:6]}", + email=f"ws+{uuid.uuid4()}@example.test", + password="pass" + ) + + with patch('django.db.transaction.on_commit', lambda fn: fn()): + notif = NotificationService.create_notification( + recipient=user, + title="Realtime test", + message="Provjera pusher poruke", + level="warning", + send_email=False, + metadata={"source": "unit-test"} + ) + + self.assertEqual(notif.recipient, user) + mock_broadcast.assert_called_once_with( + user_id=user.id, + title="Realtime test", + message="Provjera pusher poruke", + level="warning", + notification_id=notif.id, + metadata={"source": "unit-test"} + ) + + @patch('modules.fleet.services.PusherService.broadcast_notification') + def test_create_notification_without_recipient_does_not_broadcast(self, mock_broadcast): + NotificationService.create_notification( + recipient=None, + title="No recipient", + message="Broadcast se ne smije pozvati", + level="info", + send_email=False + ) + + mock_broadcast.assert_not_called() + + +class WorkOrderAndServiceRecordNotificationTests(TestCase): + def setUp(self): + User = get_user_model() + # creator who will create work order + self.creator = User.objects.create_user(username=f"creator_{uuid.uuid4().hex[:6]}", email=f"creator+{uuid.uuid4()}@example.test", password="pass") + # assigned servicer (different user) + self.servicer = User.objects.create_user(username=f"servicer_{uuid.uuid4().hex[:6]}", email=f"servicer+{uuid.uuid4()}@example.test", password="pass") + # another technician who will perform the service + self.technician = User.objects.create_user(username=f"tech_{uuid.uuid4().hex[:6]}", email=f"tech+{uuid.uuid4()}@example.test", password="pass") + + self.vehicle = Vehicle.objects.create( + registration_number=f"REG-{uuid.uuid4().hex[:6].upper()}", + make="TestMake", + model="TestModel", + current_mileage=10000, + service_interval_km=15000, + assigned_servicer=self.servicer + ) + + def test_create_work_order_creates_notifications_for_creator_and_servicer(self): + # Precondition: no notifications + VehicleNotification.objects.all().delete() + + data = { + "vehicle": self.vehicle, + "creator": self.creator, + "start_mileage": 10000, + "end_mileage": 10120, + "purpose": "Routine visit" + } + + wo = VehicleService.create_work_order(data) + + # Ensure work order created + self.assertIsNotNone(wo.pk) + + # Notifications: one for creator, one for assigned servicer + creator_notifs = VehicleNotification.objects.filter(recipient=self.creator) + servicer_notifs = VehicleNotification.objects.filter(recipient=self.servicer) + + self.assertTrue(creator_notifs.exists(), "Creator should receive a notification") + self.assertTrue(servicer_notifs.exists(), "Assigned servicer should receive a notification") + + def test_create_service_record_creates_notifications_for_performer_and_assigned_servicer(self): + # Precondition: no notifications + VehicleNotification.objects.all().delete() + + data = { + "vehicle": self.vehicle, + "performed_by": self.technician, + "description": "Brake replacement", + "parts": "pads;discs", + "cost": "200.00", + "mileage": 10200 + } + + rec = VehicleService.create_service_record(data) + + # Ensure service record created + self.assertIsNotNone(rec.pk) + + # There should be a notification for the performer + perf_notifs = VehicleNotification.objects.filter(recipient=self.technician) + self.assertTrue(perf_notifs.exists(), "Performer should receive a notification") + + # There should be a notification for the assigned servicer (different from performer) + servicer_notifs = VehicleNotification.objects.filter(recipient=self.servicer) + self.assertTrue(servicer_notifs.exists(), "Assigned servicer should receive a notification when different from performer") + + +class DeleteWorkOrderPermissionTests(TestCase): + def setUp(self): + User = get_user_model() + self.creator = User.objects.create_user(username=f"creator_{uuid.uuid4().hex[:6]}", email=f"creator+{uuid.uuid4()}@example.test", password="pass") + self.other = User.objects.create_user(username=f"other_{uuid.uuid4().hex[:6]}", email=f"other+{uuid.uuid4()}@example.test", password="pass") + self.staff = User.objects.create_user(username=f"admin_{uuid.uuid4().hex[:6]}", email=f"admin+{uuid.uuid4()}@example.test", password="pass", is_staff=True) + + self.vehicle = Vehicle.objects.create( + registration_number=f"DEL-{uuid.uuid4().hex[:6].upper()}", + current_mileage=5000 + ) + + self.wo = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.creator, start_mileage=5000) + + def test_creator_can_delete_work_order_and_notification_created(self): + # Ensure active initially + self.wo.refresh_from_db() + self.assertTrue(self.wo.is_active) + + # Delete by creator + VehicleService.delete_work_order(self.wo, user=self.creator) + + # Refresh and assert deactivated + self.wo.refresh_from_db() + self.assertFalse(self.wo.is_active) + + # Notification exists for creator + notif_exists = VehicleNotification.objects.filter(recipient=self.creator, title__icontains="obrisan").exists() + self.assertTrue(notif_exists) + + def test_non_creator_cannot_delete_work_order(self): + with self.assertRaises(DRFValidationError): + VehicleService.delete_work_order(self.wo, user=self.other) + + def test_staff_can_delete_work_order(self): + # staff should be allowed + VehicleService.delete_work_order(self.wo, user=self.staff) + self.wo.refresh_from_db() + self.assertFalse(self.wo.is_active) + # Notification exists for creator as well (as per implementation) + self.assertTrue(VehicleNotification.objects.filter(recipient=self.creator, title__icontains="obrisan").exists()) \ No newline at end of file diff --git a/backend/modules/fleet/tests/test_photo_services.py b/backend/modules/fleet/tests/test_photo_services.py new file mode 100644 index 0000000..3bc340c --- /dev/null +++ b/backend/modules/fleet/tests/test_photo_services.py @@ -0,0 +1,362 @@ +import uuid +from io import BytesIO +from django.test import TestCase +from django.core.files.uploadedfile import SimpleUploadedFile +from django.contrib.auth import get_user_model +from rest_framework.exceptions import ValidationError as DRFValidationError +from PIL import Image + +from modules.fleet.models import Vehicle, VehicleServiceRecord, VehicleServicePhoto +from modules.fleet.services import VehicleServicePhotoService + + +User = get_user_model() + + +def create_test_image(filename="test.jpg", size=(100, 100), color='red'): + """ + Stvori test sliku u memoriji koristeći Pillow. + Vraća SimpleUploadedFile spreman za upload. + """ + file = BytesIO() + image = Image.new('RGB', size=size, color=color) + image.save(file, 'JPEG') + file.seek(0) + return SimpleUploadedFile( + name=filename, + content=file.getvalue(), + content_type='image/jpeg' + ) + + +class VehicleServicePhotoServiceTests(TestCase): + def setUp(self): + # Create users + self.uploader = User.objects.create_user( + username=f"uploader_{uuid.uuid4().hex[:6]}", + email=f"uploader+{uuid.uuid4()}@example.test", + password="pass" + ) + self.other_user = User.objects.create_user( + username=f"other_{uuid.uuid4().hex[:6]}", + email=f"other+{uuid.uuid4()}@example.test", + password="pass" + ) + self.staff_user = User.objects.create_user( + username=f"admin_{uuid.uuid4().hex[:6]}", + email=f"admin+{uuid.uuid4()}@example.test", + password="pass", + is_staff=True + ) + + # Create vehicle and service record + self.vehicle = Vehicle.objects.create( + registration_number=f"SRVPHOTO-{uuid.uuid4().hex[:6].upper()}", + make="TestMake", + model="TestModel", + current_mileage=5000, + service_interval_km=10000 + ) + + self.service_record = VehicleServiceRecord.objects.create( + vehicle=self.vehicle, + performed_by=self.uploader, + description="Test service", + mileage=5000, + parts="part1;part2" + ) + + def test_upload_photo_success(self): + """ + Test uspješnog uploada fotografije s valjanim podacima. + """ + image = create_test_image(filename="success.jpg") + + data = { + 'service_record': self.service_record, + 'image': image, + 'description': 'Test brake replacement' + } + + photo = VehicleServicePhotoService.upload_photo(data=data, uploaded_by=self.uploader) + + self.assertIsNotNone(photo.pk) + self.assertEqual(photo.uploaded_by, self.uploader) + self.assertEqual(photo.service_record, self.service_record) + self.assertEqual(photo.description, 'Test brake replacement') + self.assertTrue(photo.image) + + def test_upload_photo_with_service_record_id(self): + """ + Test uploada koristeći service_record ID umjesto instancije. + """ + image = create_test_image(filename="by_id.jpg") + + data = { + 'service_record': self.service_record.id, # ID, ne instanca + 'image': image, + 'description': 'Upload by ID' + } + + photo = VehicleServicePhotoService.upload_photo(data=data, uploaded_by=self.uploader) + + self.assertIsNotNone(photo.pk) + self.assertEqual(photo.service_record, self.service_record) + + def test_upload_photo_without_user_raises(self): + """ + Test — upload bez korisnika (uploaded_by) baca ValidationError. + """ + image = create_test_image(filename="no_user.jpg") + + data = { + 'service_record': self.service_record, + 'image': image, + 'description': 'No user' + } + + with self.assertRaises(DRFValidationError): + VehicleServicePhotoService.upload_photo(data=data, uploaded_by=None) + + def test_upload_photo_invalid_service_record_raises(self): + """ + Test — upload s nepostojeću service_record ID baca ValidationError. + """ + image = create_test_image(filename="invalid_sr.jpg") + + data = { + 'service_record': 99999, # ne postoji + 'image': image, + 'description': 'Invalid SR' + } + + with self.assertRaises(DRFValidationError): + VehicleServicePhotoService.upload_photo(data=data, uploaded_by=self.uploader) + + def test_update_photo_by_uploader_success(self): + """ + Test ažuriranja fotografije od strane korisnika koji ju je uploadao. + """ + image = create_test_image(filename="original.jpg") + photo = VehicleServicePhoto.objects.create( + service_record=self.service_record, + image=image, + description="Original description", + uploaded_by=self.uploader + ) + + update_data = {'description': 'Updated description'} + updated_photo = VehicleServicePhotoService.update_photo( + instance=photo, + data=update_data, + user=self.uploader + ) + + self.assertEqual(updated_photo.description, 'Updated description') + photo.refresh_from_db() + self.assertEqual(photo.description, 'Updated description') + + def test_update_photo_by_staff_success(self): + """ + Test ažuriranja fotografije od strane staff korisnika (može ažurirati bilo koju). + """ + image = create_test_image(filename="staff_update.jpg") + photo = VehicleServicePhoto.objects.create( + service_record=self.service_record, + image=image, + description="Original", + uploaded_by=self.uploader + ) + + update_data = {'description': 'Staff updated'} + updated_photo = VehicleServicePhotoService.update_photo( + instance=photo, + data=update_data, + user=self.staff_user + ) + + self.assertEqual(updated_photo.description, 'Staff updated') + + def test_update_photo_by_non_uploader_denied(self): + """ + Test — korisnik koji nije uploader niti staff ne može ažurirati. + """ + image = create_test_image(filename="denied_update.jpg") + photo = VehicleServicePhoto.objects.create( + service_record=self.service_record, + image=image, + description="Original", + uploaded_by=self.uploader + ) + + update_data = {'description': 'Hacker attempt'} + + with self.assertRaises(DRFValidationError): + VehicleServicePhotoService.update_photo( + instance=photo, + data=update_data, + user=self.other_user + ) + + def test_update_photo_without_user_denied(self): + """ + Test — ažuriranje bez korisnika (user=None) baca ValidationError. + """ + image = create_test_image(filename="no_user_update.jpg") + photo = VehicleServicePhoto.objects.create( + service_record=self.service_record, + image=image, + description="Original", + uploaded_by=self.uploader + ) + + update_data = {'description': 'No auth'} + + with self.assertRaises(DRFValidationError): + VehicleServicePhotoService.update_photo( + instance=photo, + data=update_data, + user=None + ) + + def test_delete_photo_by_uploader_success(self): + """ + Test soft-delete fotografije od strane korisnika koji ju je uploadao. + """ + image = create_test_image(filename="delete.jpg") + photo = VehicleServicePhoto.objects.create( + service_record=self.service_record, + image=image, + description="To delete", + uploaded_by=self.uploader + ) + + self.assertTrue(photo.is_active) + + deleted_photo = VehicleServicePhotoService.delete_photo( + instance=photo, + user=self.uploader + ) + + self.assertFalse(deleted_photo.is_active) + photo.refresh_from_db() + self.assertFalse(photo.is_active) + + def test_delete_photo_by_staff_success(self): + """ + Test soft-delete od strane staff korisnika. + """ + image = create_test_image(filename="staff_delete.jpg") + photo = VehicleServicePhoto.objects.create( + service_record=self.service_record, + image=image, + description="To delete", + uploaded_by=self.uploader + ) + + VehicleServicePhotoService.delete_photo( + instance=photo, + user=self.staff_user + ) + + photo.refresh_from_db() + self.assertFalse(photo.is_active) + + def test_delete_photo_by_non_uploader_denied(self): + """ + Test — korisnik koji nije uploader niti staff ne može obrisati. + """ + image = create_test_image(filename="denied_delete.jpg") + photo = VehicleServicePhoto.objects.create( + service_record=self.service_record, + image=image, + description="Original", + uploaded_by=self.uploader + ) + + with self.assertRaises(DRFValidationError): + VehicleServicePhotoService.delete_photo( + instance=photo, + user=self.other_user + ) + + # Fotografija je i dalje aktivna + photo.refresh_from_db() + self.assertTrue(photo.is_active) + + def test_delete_photo_without_user_denied(self): + """ + Test — brisanje bez korisnika (user=None) baca ValidationError. + """ + image = create_test_image(filename="no_user_delete.jpg") + photo = VehicleServicePhoto.objects.create( + service_record=self.service_record, + image=image, + description="Original", + uploaded_by=self.uploader + ) + + with self.assertRaises(DRFValidationError): + VehicleServicePhotoService.delete_photo( + instance=photo, + user=None + ) + + photo.refresh_from_db() + self.assertTrue(photo.is_active) + + def test_update_photo_does_not_allow_image_field_update(self): + """ + Test — update ne dozvoljava ponovno upload slike (ili je ignorirano). + Sadržaj image polja se ne mijenja ako je proslijeđen u update_data. + """ + image1 = create_test_image(filename="original.jpg", color='red') + photo = VehicleServicePhoto.objects.create( + service_record=self.service_record, + image=image1, + description="Original", + uploaded_by=self.uploader + ) + + original_image_name = photo.image.name + + # Pokušaj update s novom slikom + image2 = create_test_image(filename="new.jpg", color='blue') + update_data = { + 'description': 'Updated', + 'image': image2 + } + + VehicleServicePhotoService.update_photo( + instance=photo, + data=update_data, + user=self.uploader + ) + + photo.refresh_from_db() + # image bi trebao biti preskočen (ne ažurira se) + self.assertEqual(photo.image.name, original_image_name) + self.assertEqual(photo.description, 'Updated') + + def test_multiple_photos_for_same_service_record(self): + """ + Test — isti servisni zapis može imati više fotografija. + """ + image1 = create_test_image(filename="photo1.jpg", color='red') + image2 = create_test_image(filename="photo2.jpg", color='blue') + image3 = create_test_image(filename="photo3.jpg", color='green') + + data1 = {'service_record': self.service_record, 'image': image1, 'description': 'Photo 1'} + data2 = {'service_record': self.service_record, 'image': image2, 'description': 'Photo 2'} + data3 = {'service_record': self.service_record, 'image': image3, 'description': 'Photo 3'} + + photo1 = VehicleServicePhotoService.upload_photo(data=data1, uploaded_by=self.uploader) + photo2 = VehicleServicePhotoService.upload_photo(data=data2, uploaded_by=self.uploader) + photo3 = VehicleServicePhotoService.upload_photo(data=data3, uploaded_by=self.other_user) + + photos = VehicleServicePhoto.objects.filter(service_record=self.service_record, is_active=True) + self.assertEqual(photos.count(), 3) + + # Provjeri da su sve aktivne + for photo in [photo1, photo2, photo3]: + self.assertTrue(photo.is_active) \ No newline at end of file diff --git a/backend/modules/fleet/tests/test_pusher_service.py b/backend/modules/fleet/tests/test_pusher_service.py new file mode 100644 index 0000000..c1755fc --- /dev/null +++ b/backend/modules/fleet/tests/test_pusher_service.py @@ -0,0 +1,88 @@ +# backend/modules/fleet/tests/test_pusher_service.py +from django.test import TestCase +from unittest.mock import Mock, patch +import uuid + +from infrastructure.pusher_service import PusherService + + +class PusherServiceTests(TestCase): + @patch("infrastructure.pusher_service.PusherService.get_client") + @patch("infrastructure.pusher_service.PusherService.is_available", return_value=True) + def test_broadcast_notification_success(self, _is_available, mock_get_client): + mock_client = Mock() + mock_get_client.return_value = mock_client + mock_client.trigger.return_value = {"message": "ok"} + + ok = PusherService.broadcast_notification( + user_id=42, + title="Test naslov", + message="Test poruka", + level="info", + notification_id=123, + ) + + self.assertTrue(ok) + mock_client.trigger.assert_called_once() + _, kwargs = mock_client.trigger.call_args + self.assertEqual(kwargs["channels"], "private-user-42") + self.assertEqual(kwargs["event_name"], "vehicle.notification.created") + self.assertEqual(kwargs["data"]["id"], 123) + + @patch("infrastructure.pusher_service.PusherService.get_client") + @patch("infrastructure.pusher_service.PusherService.is_available", return_value=True) + def test_authenticate_channel_allows_own_private_channel(self, _is_available, mock_get_client): + mock_client = Mock() + mock_get_client.return_value = mock_client + mock_client.authenticate.return_value = {"auth": "signed-token"} + + auth_data = PusherService.authenticate_channel( + socket_id="123.456", + channel_name="private-user-7", + user_id=7, + ) + + self.assertEqual(auth_data, {"auth": "signed-token"}) + mock_client.authenticate.assert_called_once_with( + channel="private-user-7", + socket_id="123.456", + ) + + @patch("infrastructure.pusher_service.PusherService.get_client") + @patch("infrastructure.pusher_service.PusherService.is_available", return_value=True) + def test_authenticate_channel_rejects_other_users_channel(self, _is_available, mock_get_client): + mock_client = Mock() + mock_get_client.return_value = mock_client + + auth_data = PusherService.authenticate_channel( + socket_id="123.456", + channel_name="private-user-999", + user_id=7, + ) + + self.assertIsNone(auth_data) + mock_client.authenticate.assert_not_called() + + @patch("infrastructure.pusher_service.PusherService.get_client") + @patch("infrastructure.pusher_service.PusherService.is_available", return_value=True) + def test_broadcast_notification_serializes_uuid_values(self, _is_available, mock_get_client): + mock_client = Mock() + mock_get_client.return_value = mock_client + mock_client.trigger.return_value = {"message": "ok"} + + notification_id = uuid.uuid4() + metadata_uuid = uuid.uuid4() + + ok = PusherService.broadcast_notification( + user_id=10, + title="UUID test", + message="UUID payload", + level="info", + notification_id=notification_id, + metadata={"entity_id": metadata_uuid}, + ) + + self.assertTrue(ok) + _, kwargs = mock_client.trigger.call_args + self.assertEqual(kwargs["data"]["id"], str(notification_id)) + self.assertEqual(kwargs["data"]["metadata"]["entity_id"], str(metadata_uuid)) \ No newline at end of file diff --git a/backend/modules/fleet/tests/test_service_history_and_expenses.py b/backend/modules/fleet/tests/test_service_history_and_expenses.py new file mode 100644 index 0000000..cc1aed5 --- /dev/null +++ b/backend/modules/fleet/tests/test_service_history_and_expenses.py @@ -0,0 +1,17 @@ +import pytest +from django.db import models +from modules.fleet.models import Vehicle, VehicleServiceRecord + +@pytest.mark.django_db +def test_vehicle_total_cost_calculation(test_user): + # Kreiraj vozilo prema tvom modelu + vehicle = Vehicle.objects.create(registration_number="ZG-001") + + VehicleServiceRecord.objects.create(vehicle=vehicle, performed_by=test_user, cost=100.00, mileage=5000, description="Servis 1") + VehicleServiceRecord.objects.create(vehicle=vehicle, performed_by=test_user, cost=50.50, mileage=5200, description="Servis 2") + + # Agregacija kroz tvoj related_name='service_records' + total = vehicle.service_records.aggregate(models.Sum('cost'))['cost__sum'] + + # Decimal field vraća Decimal, pa uspoređujemo s float-om ili Decimal-om + assert float(total) == 150.50 \ No newline at end of file diff --git a/backend/modules/fleet/tests/test_service_photo_upload_to.py b/backend/modules/fleet/tests/test_service_photo_upload_to.py new file mode 100644 index 0000000..0d82265 --- /dev/null +++ b/backend/modules/fleet/tests/test_service_photo_upload_to.py @@ -0,0 +1,22 @@ +import pytest +from unittest.mock import MagicMock +from modules.fleet.models import _service_photo_upload_to + +@pytest.mark.django_db +def test_service_photo_upload_path_fallback(): + """ + Testira fallback mehanizam: ako instanca nema service_record, + putanja mora sadržavati 'unknown'. + """ + # Simuliramo instancu koja nema 'service_record' ili baca grešku + instance = MagicMock() + del instance.service_record # Simuliramo nedostajući atribut + + filename = "test_kvar.jpg" + + # Pozivamo funkciju + path = _service_photo_upload_to(instance, filename) + + # Očekujemo da putanja sadrži 'unknown' kao fallback + assert "fleet/unknown/service_photos/" in path + assert filename in path # uuid je prefiks, pa provjeravamo da li je filename na kraju \ No newline at end of file diff --git a/backend/modules/fleet/tests/test_services.py b/backend/modules/fleet/tests/test_services.py new file mode 100644 index 0000000..30b4756 --- /dev/null +++ b/backend/modules/fleet/tests/test_services.py @@ -0,0 +1,52 @@ +import uuid +from django.test import TestCase +from django.contrib.auth import get_user_model +from modules.fleet.services import VehicleService +from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord + + +class FleetServiceTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username="tech2", email=f"tech2+{uuid.uuid4()}@example.test", password="pass") + self.vehicle = Vehicle.objects.create( + registration_number=f"RI-{uuid.uuid4().hex[:6].upper()}", + make="VW", + model="Polo", + current_mileage=5000, + service_interval_km=10000, + assigned_servicer=self.user + ) + + def test_create_work_order_via_service(self): + data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "Delivery"} + wo = VehicleService.create_work_order(data) + self.vehicle.refresh_from_db() + self.assertEqual(self.vehicle.current_mileage, 5200) + self.assertEqual(wo.distance, 200) + + def test_create_service_record_via_service(self): + data = {"vehicle": self.vehicle, "performed_by": self.user, "description": "Brake pads", "mileage": 5200, "cost": "120.00"} + rec = VehicleService.create_service_record(data) + self.vehicle.refresh_from_db() + self.assertEqual(rec.next_service_due_at, 5200 + self.vehicle.service_interval_km) + self.assertEqual(self.vehicle.current_mileage, 5200) + + def test_vehicle_next_service_at_logic(self): + # current=12000, interval=15000 -> blocks=(12000//15000)+1 = 1 -> 1*15000 = 15000 + v = Vehicle.objects.create(current_mileage=12000, service_interval_km=15000) + assert v.next_service_at() == 15000 + + # current=16000, interval=15000 -> blocks=(16000//15000)+1 = 2 -> 2*15000 = 30000 + v.current_mileage = 16000 + assert v.next_service_at() == 30000 + + def test_work_order_distance_property(self): + vehicle = Vehicle.objects.create(registration_number="ZG-004") + # Nalog bez završne kilometraže + wo = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, start_mileage=1000) + assert wo.distance is None + + # Nalog sa završnom kilometražom + wo.end_mileage = 1100 + assert wo.distance == 100 \ No newline at end of file diff --git a/backend/modules/fleet/tests/trest_travel_expenses.py b/backend/modules/fleet/tests/trest_travel_expenses.py new file mode 100644 index 0000000..7193d8a --- /dev/null +++ b/backend/modules/fleet/tests/trest_travel_expenses.py @@ -0,0 +1,30 @@ +import pytest +from django.core.exceptions import ValidationError +from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord + +@pytest.mark.django_db +def test_work_order_updates_vehicle_mileage(): + # 1. Kreiraj vozilo (koristeći tvoj model 'Vehicle') + vehicle = Vehicle.objects.create( + registration_number="ZG-123-ABC", + current_mileage=5000 + ) + + # 2. Kreiraj radni/putni nalog + # Pretpostavljam da ti treba i 'creator' zbog ForeignKey relacije + from django.contrib.auth import get_user_model + User = get_user_model() + creator = User.objects.create_user(username="testuser") + + order = WorkOrder.objects.create( + vehicle=vehicle, + creator=creator, + start_mileage=5000, + end_mileage=5500, + purpose="Delivery" + ) + + # 3. Nalog je već pozvao save() pri kreiranju, + # pa bi vozilo trebalo biti ažurirano + vehicle.refresh_from_db() + assert vehicle.current_mileage == 5500 \ No newline at end of file diff --git a/backend/modules/fleet/urls.py b/backend/modules/fleet/urls.py new file mode 100644 index 0000000..dfaefc9 --- /dev/null +++ b/backend/modules/fleet/urls.py @@ -0,0 +1,22 @@ +from rest_framework.routers import DefaultRouter +from django.urls import path +from .views import ( + CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet, + WorkOrderInvoiceViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth, +) + +router = DefaultRouter() +router.register(r'vehicles', VehicleViewSet, basename='vehicle') +router.register(r'cranes', CraneViewSet, basename='crane') +router.register(r'work-orders', WorkOrderViewSet, basename='workorder') +router.register(r'work-order-invoices', WorkOrderInvoiceViewSet, basename='work-order-invoice') +router.register(r'service-records', VehicleServiceRecordViewSet, basename='service-record') +router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification') +router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo') +router.register(r'service-attachments', VehicleServiceAttachmentViewSet, basename='service-attachment') + +urlpatterns = router.urls + +urlpatterns += [ + path('pusher-auth/', pusher_auth, name='pusher-auth'), +] \ No newline at end of file diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py new file mode 100644 index 0000000..3e22aba --- /dev/null +++ b/backend/modules/fleet/views.py @@ -0,0 +1,1563 @@ +# /backend/modules/fleet/views.py + +from io import BytesIO +import base64 + +from PIL import Image, UnidentifiedImageError +from django.conf import settings +from django.core.mail import EmailMessage +from django.http import HttpResponse +from django.urls import reverse +from django.utils import timezone +from django.utils.html import escape +from django.db.models import Q +import os +from reportlab.lib.pagesizes import A4 +from reportlab.lib import colors +from reportlab.lib.utils import ImageReader +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.pdfgen import canvas +from reportlab.platypus import Table, TableStyle, Paragraph +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + +# Register Unicode-capable fonts (Vera supports HR characters: č, ć, đ, š, ž) +_RL_FONTS_DIR = os.path.join(os.path.dirname(__import__('reportlab').__file__), 'fonts') +pdfmetrics.registerFont(TTFont('Vera', os.path.join(_RL_FONTS_DIR, 'Vera.ttf'))) +pdfmetrics.registerFont(TTFont('Vera-Bold', os.path.join(_RL_FONTS_DIR, 'VeraBd.ttf'))) +pdfmetrics.registerFont(TTFont('Vera-Italic', os.path.join(_RL_FONTS_DIR, 'VeraIt.ttf'))) +from rest_framework import viewsets, permissions, status +from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.response import Response +from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError +from rest_framework.parsers import MultiPartParser, FormParser +from celery.exceptions import TimeoutError as CeleryTimeoutError +from kombu.exceptions import OperationalError as KombuOperationalError +from .models import ( + Crane, + Vehicle, + VehicleNotification, + WorkOrder, + WorkOrderInvoice, + VehicleServiceRecord, + VehicleServicePhoto, + VehicleServiceAttachment, +) +from .serializers import ( + CraneSerializer, + VehicleSerializer, + WorkOrderSerializer, + WorkOrderInvoiceSerializer, + VehicleServiceRecordSerializer, + VehicleNotificationSerializer, + VehicleServicePhotoSerializer, + VehicleServiceAttachmentSerializer, +) +from infrastructure.pusher_service import PusherService +from .services import ( + VehicleService, + VehicleServicePhotoService, + NotificationService, +) +from .tasks import build_work_order_invoices_pdf_task + +def _fleet_assets_queryset_for_user(user, model, *, asset_type=None): + queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True) + if asset_type: + queryset = queryset.filter(asset_type=asset_type) + if user.is_staff or getattr(user, 'is_serviser', False): + return queryset + return queryset + +def _service_records_queryset_for_user(user): + queryset = ( + VehicleServiceRecord.objects + .select_related('vehicle', 'vehicle__client', 'performed_by', 'task', 'task__vehicle') + .prefetch_related('photos', 'attachments') + .all() + ) + if user.is_staff: + return queryset + return queryset.filter(performed_by=user) + + +def _work_orders_queryset_for_user(user): + queryset = WorkOrder.objects.select_related('vehicle', 'vehicle__client', 'creator').all() + if user.is_staff: + return queryset + if getattr(user, 'is_serviser', False): + return queryset.filter( + Q(creator=user) | Q(vehicle__assigned_servicer=user) + ).distinct() + return queryset.filter(creator=user) + + +def _work_order_related_tasks_queryset(work_order): + from modules.task_management.models import Task + + direct_task_ids = list( + work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True) + ) + inferred_task_ids = list( + VehicleServiceRecord.objects.filter( + is_active=True, + vehicle_id=work_order.vehicle_id, + task_id__isnull=False, + task__is_active=True, + ).values_list('task_id', flat=True) + ) + task_ids = list({*direct_task_ids, *inferred_task_ids}) + if not task_ids: + return Task.objects.none() + return Task.objects.filter(id__in=task_ids, is_active=True).select_related('assigned_to', 'vehicle', 'work_order').order_by('-created_at') + +def _can_access_service_record(user, service_record): + service_record_id = getattr(service_record, 'pk', service_record) + return _service_records_queryset_for_user(user).filter(pk=service_record_id).exists() + +def _parse_positive_int(value, *, field_name, default, min_value, max_value): + if value in (None, ''): + return default + try: + parsed = int(value) + except (TypeError, ValueError): + raise DRFValidationError({field_name: f"Neispravna vrijednost za {field_name}."}) + if parsed < min_value or parsed > max_value: + raise DRFValidationError({field_name: f"{field_name} mora biti između {min_value} i {max_value}."}) + return parsed + +def _parse_format(value): + selected = (value or 'webp').lower() + supported = {'webp': 'WEBP', 'jpeg': 'JPEG', 'jpg': 'JPEG', 'png': 'PNG'} + if selected not in supported: + raise DRFValidationError({"fmt": "Podržani formati su: webp, jpeg, png."}) + extension = 'jpg' if selected in ('jpeg', 'jpg') else selected + return supported[selected], extension + +def _build_simple_pdf(title, rows): + buffer = BytesIO() + pdf = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 + y = height - 50 + + pdf.setFont("Vera-Bold", 14) + pdf.drawString(40, y, title) + y -= 24 + + pdf.setFont("Vera", 10) + for row in rows: + text = f"{row[0]}: {row[1]}" + if y < 60: + pdf.showPage() + pdf.setFont("Vera", 10) + y = height - 50 + pdf.drawString(40, y, text[:140]) + y -= 16 + + pdf.save() + return buffer.getvalue() + + +def _draw_pdf_row(pdf, *, label, value, y, width, height): + if y < 60: + pdf.showPage() + pdf.setFont("Vera", 10) + y = height - 50 + text = f"{label}: {value}" + pdf.drawString(40, y, text[:180]) + return y - 16 + + +def _build_work_order_pdf(work_order): + vehicle = work_order.vehicle + creator = work_order.creator + invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')) + now_local = timezone.localtime(timezone.now()) + tz = timezone.get_current_timezone() + + def _fmt_date(value): + if not value: + return '-' + if hasattr(value, 'strftime'): + return value.strftime('%d.%m.%Y.') + return str(value) + + def _fmt_time(value): + if not value: + return '-' + return value.astimezone(tz).strftime('%H:%M') + + def _fmt_eur(value): + try: + return f"{float(value):.2f} €" + except (TypeError, ValueError): + return "0,00 €" + + def _paragraph(text, *, bold=False, size=9, align=0): + styles = getSampleStyleSheet() + base = styles['BodyText'].clone('wo-p') + base.fontName = 'Vera-Bold' if bold else 'Vera' + base.fontSize = size + base.leading = size + 1.5 + base.alignment = align + return Paragraph(text, base) + + def _hours_between(start_at, end_at): + if not start_at or not end_at: + return 0.0 + delta = end_at - start_at + return max(0.0, round(delta.total_seconds() / 3600.0, 2)) + + company_name = getattr(getattr(vehicle, 'client', None), 'name', None) or "KNEZ LJUBO d.o.o." + creator_name = _user_display_name(creator) or creator.email + creator_occupation = (getattr(creator, 'occupation', None) or '').strip() or ( + "Servisni inženjer" if getattr(creator, 'is_serviser', False) else "Djelatnik" + ) + creator_residence = (getattr(creator, 'residence', None) or '').strip() or "-" + creator_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation + travel_start = work_order.travel_start_at + travel_end = work_order.travel_end_at + travel_hours = _hours_between(travel_start, travel_end) + daily_qty = round(travel_hours / 8.0, 1) if travel_hours > 0 else 0.0 + daily_rate = 0.0 + daily_total = daily_qty * daily_rate + transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0) + grand_total = daily_total + transport_total + place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb' + invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna] + attachments_text = ', '.join(invoice_names) if invoice_names else '-' + + buffer = BytesIO() + pdf = canvas.Canvas(buffer, pagesize=A4) + page_w, page_h = A4 + margin = 16 + content_w = page_w - (2 * margin) + y = page_h - 18 + + def draw_table(y_pos, data, widths, styles, row_heights=None): + table = Table(data, colWidths=widths, rowHeights=row_heights) + table.setStyle(TableStyle(styles)) + _, table_h = table.wrap(content_w, page_h) + if y_pos - table_h < 28: + pdf.showPage() + y_pos = page_h - 18 + table.drawOn(pdf, margin, y_pos - table_h) + return y_pos - table_h - 4 + + # 1) Zaglavlje + određujem da + section_1_top = [[ + _paragraph(f"Trgovačko društvo
{company_name}", size=8.8), + _paragraph("N A L O G
ZA SLUŽBENO PUTOVANJE", bold=True, size=11, align=1), + _paragraph( + f"Mjesto i datum:
{place_label}    {_fmt_date(now_local.date())}
" + f"Broj naloga:
{work_order.pk}", + size=8.8, + ), + ]] + y = draw_table( + y, + section_1_top, + [content_w * 0.305, content_w * 0.35, content_w * 0.345], + [ + ('GRID', (0, 0), (-1, -1), 0.9, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 3), + ('BOTTOMPADDING', (0, 0), (-1, -1), 3), + ], + row_heights=[44], + ) + y = draw_table( + y, + [[_paragraph("Određujem da:", size=11)]], + [content_w], + [ + ('GRID', (0, 0), (-1, -1), 0.9, colors.black), + ('LEFTPADDING', (0, 0), (-1, -1), 3), + ('RIGHTPADDING', (0, 0), (-1, -1), 3), + ('TOPPADDING', (0, 0), (-1, -1), 2), + ('BOTTOMPADDING', (0, 0), (-1, -1), 2), + ], + row_heights=[14], + ) + y = draw_table( + y, + [[ + _paragraph(f"Ime i prezime:
{creator_name}

Prebivalište:
{creator_residence}", size=8.7), + _paragraph(f"Zanimanje:
{creator_occupation}

Na radnom mjestu:
{creator_work_position}", size=8.7), + ]], + [content_w * 0.66, content_w * 0.34], + [ + ('GRID', (0, 0), (-1, -1), 0.9, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 2), + ('BOTTOMPADDING', (0, 0), (-1, -1), 2), + ], + row_heights=[58], + ) + + # 2) Putuje dana / mjesto / zadatak + y = draw_table( + y, + [ + ["Službeno otputuje dana:", "U mjesto:", "Sa zadaćom:"], + [ + "1 dana" if travel_start else "-", + work_order.location or '-', + work_order.purpose or '-', + ], + ], + [content_w / 3, content_w / 3, content_w / 3], + [ + ('GRID', (0, 0), (-1, -1), 0.9, colors.black), + ('FONTNAME', (0, 0), (-1, 0), 'Vera'), + ('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 8.6), + ('LEFTPADDING', (0, 0), (-1, -1), 3), + ('RIGHTPADDING', (0, 0), (-1, -1), 3), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ], + row_heights=[14, 19], + ) + + # 3) Trajanje + kilometraže + y = draw_table( + y, + [ + ["Putovanje može trajati:", "Datum polaska:", "Odobravamo upotrebu vozila:"], + [ + "1 dana" if travel_start else "-", + _fmt_date(travel_start.date() if travel_start else work_order.date), + f"{work_order.servicer_vehicle_make_model or '-'}, {work_order.servicer_vehicle_registration or '-'}", + ], + ["Troškovi putovanja terete:", "Posebni dodaci (predujam):", "Početno stanje kilometara:"], + [ + company_name, + "-", + str(work_order.servicer_vehicle_start_mileage or work_order.start_mileage or '-'), + ], + ["", "", "Završno stanje kilometara:"], + ["", "", str(work_order.servicer_vehicle_end_mileage or work_order.end_mileage or '-')], + ], + [content_w / 3, content_w / 3, content_w / 3], + [ + ('GRID', (0, 0), (-1, -1), 0.9, colors.black), + ('FONTNAME', (0, 0), (-1, -1), 'Vera'), + ('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'), + ('FONTNAME', (0, 3), (2, 3), 'Vera-Bold'), + ('FONTNAME', (2, 5), (2, 5), 'Vera-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 8.5), + ('LEFTPADDING', (0, 0), (-1, -1), 3), + ('RIGHTPADDING', (0, 0), (-1, -1), 3), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ], + row_heights=[14, 18, 14, 18, 12, 14], + ) + + # 4) Obračun putnih troškova + section4_top = [ + ["OBRAČUN PUTNIH TROŠKOVA", "", "", "", "", "", "", ""], + ["ODLAZAK Datum", "ODLAZAK Vrijeme", "POVRATAK Datum", "POVRATAK Vrijeme", "Broj sati", "Količina dnevnica", "Iznos dnevnice", "Ukupan iznos"], + [ + _fmt_date(travel_start.date() if travel_start else work_order.date), + _fmt_time(travel_start), + _fmt_date(travel_end.date() if travel_end else work_order.date), + _fmt_time(travel_end), + str(travel_hours).replace('.', ','), + str(daily_qty).replace('.', ','), + _fmt_eur(daily_rate), + _fmt_eur(daily_total), + ], + ] + for _ in range(3): + section4_top.append(["", "", "", "", "", "", "", ""]) + y = draw_table( + y, + section4_top, + [content_w * 0.12, content_w * 0.09, content_w * 0.12, content_w * 0.09, content_w * 0.11, content_w * 0.13, content_w * 0.17, content_w * 0.17], + [ + ('SPAN', (0, 0), (7, 0)), + ('GRID', (0, 0), (-1, -1), 0.9, colors.black), + ('FONTNAME', (0, 0), (7, 0), 'Vera-Bold'), + ('FONTNAME', (0, 1), (7, 1), 'Vera'), + ('FONTNAME', (0, 2), (7, 2), 'Vera-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 8.2), + ('ALIGN', (0, 0), (7, 0), 'CENTER'), + ('ALIGN', (0, 1), (7, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING', (0, 0), (-1, -1), 2), + ('RIGHTPADDING', (0, 0), (-1, -1), 2), + ], + row_heights=[15, 14, 14, 12, 12, 12], + ) + y = draw_table( + y, + [ + ["RELACIJA od", "RELACIJA do", "Vrsta prijevoznog sredstva", "Razred [km]", "Iznos za prijevoz", "Ukupan iznos"], + [place_label or '-', work_order.location or '-', work_order.servicer_vehicle_make_model or '-', str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)], + ["", "", "", "", "", ""], + ["", "", "", "", "", ""], + ], + [content_w * 0.18, content_w * 0.18, content_w * 0.20, content_w * 0.10, content_w * 0.17, content_w * 0.17], + [ + ('GRID', (0, 0), (-1, -1), 0.9, colors.black), + ('FONTNAME', (0, 0), (-1, 0), 'Vera'), + ('FONTNAME', (0, 1), (-1, 1), 'Vera-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 8.2), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING', (0, 0), (-1, -1), 2), + ('RIGHTPADDING', (0, 0), (-1, -1), 2), + ], + row_heights=[14, 14, 12, 12], + ) + + # 5) Dodatni troškovi + additional_rows = [["DODATNI TROŠKOVI", "", ""], ["NAZIV", "Broj računa", "Ukupan iznos"]] + for inv in invoices[:5]: + additional_rows.append([inv.naziv_racuna or '-', str(inv.pk), "-"]) + while len(additional_rows) < 7: + additional_rows.append(["", "", ""]) + y = draw_table( + y, + additional_rows, + [content_w * 0.53, content_w * 0.30, content_w * 0.17], + [ + ('SPAN', (0, 0), (2, 0)), + ('GRID', (0, 0), (-1, -1), 0.9, colors.black), + ('FONTNAME', (0, 0), (2, 1), 'Vera-Bold'), + ('FONTNAME', (0, 2), (-1, -1), 'Vera'), + ('FONTSIZE', (0, 0), (-1, -1), 8.4), + ('ALIGN', (0, 0), (2, 0), 'CENTER'), + ('ALIGN', (2, 1), (2, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING', (0, 0), (-1, -1), 3), + ('RIGHTPADDING', (0, 0), (-1, -1), 3), + ], + row_heights=[14, 14, 12, 12, 12, 12, 12], + ) + + # 6) Prilozi + ukupna isplata + y = draw_table( + y, + [[ + _paragraph(f"Prilozi:
{attachments_text}", size=8.5), + _paragraph( + f"Ukupno za isplatu: {_fmt_eur(grand_total)}
" + f"Isplaćeno: {_fmt_eur(0)}
" + f"Ostaje za isplatu: {_fmt_eur(grand_total)}", + size=9, + ), + ]], + [content_w * 0.53, content_w * 0.47], + [ + ('GRID', (0, 0), (-1, -1), 0.9, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 3), + ('RIGHTPADDING', (0, 0), (-1, -1), 3), + ('TOPPADDING', (0, 0), (-1, -1), 3), + ('BOTTOMPADDING', (0, 0), (-1, -1), 3), + ], + row_heights=[48], + ) + + y -= 16 + + # Potpisi + if y < 64: + pdf.showPage() + y = page_h - 64 + pdf.setFont("Vera", 9) + pdf.drawString(margin + 48, y, f"U {place_label} dana {_fmt_date(now_local.date())}") + y -= 20 + sig_w = (content_w - 30) / 3 + sig_labels = ["(Likvidator)", "(Podnositelj računa)", "(Isplatio blagajnik)"] + for i, label in enumerate(sig_labels): + x = margin + 10 + (i * sig_w) + pdf.line(x, y, x + sig_w - 24, y) + pdf.drawCentredString(x + (sig_w - 24) / 2, y - 12, label) + y -= 26 + pdf.line(margin + 10, y, margin + sig_w - 14, y) + pdf.drawCentredString(margin + (sig_w - 4) / 2, y - 12, "(Primalac)") + pdf.line(margin + sig_w + 10, y, margin + 2 * sig_w - 14, y) + pdf.drawCentredString(margin + sig_w + (sig_w - 4) / 2, y - 12, "(Potpis direktora)") + pdf.drawString(margin + (2 * sig_w) + 34, y + 4, "M.P.") + + pdf.save() + return buffer.getvalue() + + +def _build_work_order_service_records_pdf(work_order): + vehicle = work_order.vehicle + client_name = getattr(vehicle.client, 'name', None) or '-' + model_str = ' '.join(filter(None, [vehicle.make, vehicle.model])) or '-' + serial_str = str(vehicle.crane_serial_number or '-') + upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-') + chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-') + mileage_str = str(vehicle.current_mileage or '-') + nalog_str = str(work_order.pk) + generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y') + + HEADER_H = 86 + FOOTER_H = 72 + MARGIN = 28 + DAY_MAP = { + 0: 'PO', + 1: 'UT', + 2: 'SR', + 3: 'ČET', + 4: 'PET', + 5: 'SUB', + 6: 'NED', + } + + def _fmt_date(value): + if not value: + return '-' + return value.strftime('%d.%m.%Y') + + def _fmt_time(value): + if not value: + return '-' + return value.astimezone(timezone.get_current_timezone()).strftime('%H:%M') + + def _hours_between(start_at, end_at): + if not start_at or not end_at: + return 0.0 + delta = end_at - start_at + return max(0.0, round(delta.total_seconds() / 3600.0, 2)) + + service_rows = list( + VehicleServiceRecord.objects.filter( + is_active=True, + vehicle_id=work_order.vehicle_id, + ) + .select_related('performed_by', 'task') + .prefetch_related('photos') + .order_by('service_date', 'created_at') + ) + related_tasks = list(_work_order_related_tasks_queryset(work_order)) + travel_hours = _hours_between(work_order.travel_start_at, work_order.travel_end_at) + travel_km = work_order.distance if work_order.distance is not None else 0 + task_titles = [row.task.title for row in service_rows if getattr(row, 'task', None) and row.task.title] + notes_text = ' | '.join(task_titles[:2]) if task_titles else (work_order.notes or '') + + buffer = BytesIO() + + class PageCanvas(canvas.Canvas): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._current_page = 0 + + def showPage(self): + self._current_page += 1 + self._draw_header_footer() + super().showPage() + + def save(self): + self._current_page += 1 + self._draw_header_footer() + super().save() + + def _draw_header_footer(self): + w, h = A4 + page_num = self._current_page + + self.setStrokeColor(colors.black) + self.setLineWidth(0.7) + self.rect(MARGIN, h - HEADER_H, w - 2 * MARGIN, HEADER_H - 4, stroke=1, fill=0) + + self.setFont("Vera-Bold", 11) + self.drawString(MARGIN + 6, h - 22, client_name[:44]) + self.drawCentredString(w / 2, h - 22, "Izvještaj servisera") + self.setFont("Vera", 9) + self.drawRightString(w - MARGIN - 6, h - 22, f"Stranica {page_num}") + + self.setLineWidth(0.5) + self.line(MARGIN, h - 36, w - MARGIN, h - 36) + + labels = [ + "Model:", + "Serijski broj:", + "Radni sati nadogradnje:", + "Radni sati podvozja:", + "Km:", + "Broj naloga:", + ] + values = [ + model_str, + serial_str, + upgrade_hours_str, + chassis_hours_str, + mileage_str, + nalog_str, + ] + col_w = (w - 2 * MARGIN) / len(labels) + self.setFont("Vera", 7.2) + for i, (lbl, val) in enumerate(zip(labels, values)): + x = MARGIN + i * col_w + 3 + self.drawString(x, h - 49, lbl) + value_text = str(val or '-') + value_font_size = 9 + if i == len(labels) - 1: + value_font_size = 7 + max_value_width = col_w - 8 + while ( + self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width + and value_font_size > 5.5 + ): + value_font_size -= 0.3 + if self.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width: + trimmed = value_text + while ( + len(trimmed) > 3 + and self.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width + ): + trimmed = trimmed[:-1] + value_text = f"{trimmed}..." + else: + value_text = value_text[:24] + self.setFont("Vera-Bold", value_font_size) + self.drawString(x, h - 61, value_text) + self.setFont("Vera", 7.2) + if i > 0: + self.line(MARGIN + i * col_w, h - 36, MARGIN + i * col_w, h - HEADER_H + 4) + + footer_y = FOOTER_H + self.setLineWidth(0.5) + self.rect(MARGIN, footer_y + 42, w - 2 * MARGIN, 18, stroke=1, fill=0) + sig_labels = [f"Datum: {generated_date_str}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"] + sig_w = (w - 2 * MARGIN) / 4 + self.setFont("Vera", 7.2) + for i, lbl in enumerate(sig_labels): + x = MARGIN + i * sig_w + self.drawString(x + 4, footer_y + 45, lbl) + if i > 0: + self.line(x, footer_y + 42, x, footer_y + 60) + self.setFont("Vera", 6.5) + self.drawString(MARGIN, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu") + self.drawCentredString( + w / 2, + footer_y + 14, + "Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH", + ) + + pdf = PageCanvas(buffer, pagesize=A4) + page_w, page_h = A4 + content_top = page_h - HEADER_H - 10 + content_bottom = FOOTER_H + 52 + content_width = page_w - 2 * MARGIN + + def new_page(): + pdf.showPage() + return content_top + + def draw_table(y, data, col_widths, style, row_heights=None): + table = Table(data, colWidths=col_widths, rowHeights=row_heights) + table.setStyle(style) + _, h = table.wrap(content_width, page_h) + if y - h < content_bottom: + y = new_page() + table.drawOn(pdf, MARGIN, y - h) + return y - h - 10 + + paragraph_styles = getSampleStyleSheet() + description_style = paragraph_styles['BodyText'].clone('service-description') + description_style.fontName = 'Vera' + description_style.fontSize = 8.2 + description_style.leading = 10.4 + + y = content_top + + table1 = [ + ["Naziv Tvrtke:", client_name, "Lokacija intervencije:", work_order.location or '-'], + ["Serviser:", _user_display_name(work_order.creator) or '-', "Asistirao:", '-'], + ] + y = draw_table( + y, + table1, + [95, 165, 110, content_width - 370], + TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), 'Vera'), + ('FONTSIZE', (0, 0), (-1, -1), 8.5), + ('FONTNAME', (0, 0), (0, -1), 'Vera-Bold'), + ('FONTNAME', (2, 0), (2, -1), 'Vera-Bold'), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ]), + ) + + completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒" + table2 = [ + ["Prijevozno sredstvo:", "Registracija:", "Broj narudžbe klijenta:", "Posao završen"], + [work_order.servicer_vehicle_make_model or '-', work_order.servicer_vehicle_registration or '-', "-", completion_label], + ] + y = draw_table( + y, + table2, + [152, 100, 147, content_width - 399], + TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), 'Vera'), + ('FONTSIZE', (0, 0), (-1, -1), 8), + ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('LEFTPADDING', (0, 0), (-1, -1), 3), + ('RIGHTPADDING', (0, 0), (-1, -1), 3), + ]), + row_heights=[17, 17], + ) + + table3_headers = [ + "Dan", + "Datum", + "Vrijeme rada\nod-do", + "Vrijeme putovanja\nod-do", + "Pauza\nh", + "Sati\nrada", + "Sati\nputa", + "Mjesto polaska / dolaska", + "Kilometri\nvozila", + ] + table3_rows = [table3_headers] + + if work_order.travel_start_at or work_order.travel_end_at: + day_idx = work_order.travel_start_at.weekday() if work_order.travel_start_at else None + table3_rows.append([ + DAY_MAP.get(day_idx, '-'), + _fmt_date(work_order.travel_start_at.date() if work_order.travel_start_at else work_order.date), + "-", + f"{_fmt_time(work_order.travel_start_at)} - {_fmt_time(work_order.travel_end_at)}", + "-", + "-", + str(travel_hours).replace('.', ','), + work_order.location or '-', + str(travel_km or '-'), + ]) + + for record in service_rows[:6]: + day_idx = record.service_date.weekday() if record.service_date else None + table3_rows.append([ + DAY_MAP.get(day_idx, '-'), + _fmt_date(record.service_date), + "-", + "-", + "-", + "-", + "-", + getattr(record.vehicle, 'registration_number', '-') or '-', + str(record.mileage or '-'), + ]) + + if len(table3_rows) == 1: + table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "-", "-"]) + + total_hours_travel = str(travel_hours).replace('.', ',') + for _ in range(6): + table3_rows.append(["", "", "", "", "", "", "", "", ""]) + table3_rows.append(["UKUPNO", "", "", "", "", "", total_hours_travel, "", str(travel_km or 0)]) + y = draw_table( + y, + table3_rows, + [28, 54, 72, 72, 36, 40, 40, 97, content_width - 439], + TableStyle([ + ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), + ('FONTNAME', (0, 1), (-1, -1), 'Vera'), + ('FONTSIZE', (0, 0), (-1, -1), 7.8), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('ALIGN', (0, 0), (0, -1), 'CENTER'), + ('ALIGN', (1, 0), (6, -1), 'CENTER'), + ('SPAN', (0, -1), (5, -1)), + ('ALIGN', (0, -1), (5, -1), 'LEFT'), + ('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'), + ('FONTNAME', (6, -1), (6, -1), 'Vera-Bold'), + ('FONTNAME', (8, -1), (8, -1), 'Vera-Bold'), + ('LEFTPADDING', (0, 0), (-1, -1), 3), + ('RIGHTPADDING', (0, 0), (-1, -1), 3), + ('LEFTPADDING', (0, -1), (5, -1), 12), + ]), + ) + + if y < content_bottom + 24: + y = new_page() + pdf.setFont("Vera", 9) + pdf.drawString(MARGIN, y, f"Napomene: {notes_text or '-'}") + + # Sljedeća stranica: servisni zapisi. + y = new_page() + y -= 14 + pdf.setFont("Vera-Bold", 12) + pdf.drawString(MARGIN, y, "Servisni zapisi") + y -= 18 + + if not related_tasks: + pdf.setFont("Vera", 9) + pdf.drawString(MARGIN, y, "Nema povezanih taskova za ovaj putni nalog.") + else: + for task in related_tasks: + task_records = [ + row for row in service_rows + if getattr(row, 'task_id', None) == task.id + ] + if not task_records: + continue + + if y < content_bottom + 60: + y = new_page() + pdf.setFont("Vera-Bold", 10) + pdf.drawString(MARGIN, y, (task.title or f"Task #{task.id}")[:95]) + y -= 14 + + for record in task_records: + if y < content_bottom + 64: + y = new_page() + + description_table = [[Paragraph(record.description or '-', description_style)]] + y = draw_table( + y, + [["Servisni opis"], *description_table], + [content_width], + TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), 'Vera'), + ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 8.2), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ]), + ) + + parts_table = [[Paragraph(record.parts or '-', description_style)]] + y = draw_table( + y, + [["Korišteni dijelovi"], *parts_table], + [content_width], + TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), 'Vera'), + ('FONTNAME', (0, 0), (-1, 0), 'Vera-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 8.2), + ('GRID', (0, 0), (-1, -1), 0.5, colors.black), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('LEFTPADDING', (0, 0), (-1, -1), 4), + ('RIGHTPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ]), + ) + + photos = [photo for photo in record.photos.filter(is_active=True).all() if photo.image] + if not photos: + continue + if y < content_bottom + 30: + y = new_page() + y -= 8 + pdf.setFont("Vera", 8) + pdf.drawString(MARGIN, y, "Fotografije:") + y -= 10 + + image_gap = 10 + max_image_height = 110 + image_width = (content_width - image_gap) / 2 + for index in range(0, len(photos), 2): + row_photos = photos[index:index + 2] + prepared = [] + for photo in row_photos: + try: + image_reader = ImageReader(photo.image) + source_w, source_h = image_reader.getSize() + if not source_w or not source_h: + continue + scaled_h = min(max_image_height, image_width * (float(source_h) / float(source_w))) + prepared.append((photo, image_reader, scaled_h)) + except Exception: + continue + if not prepared: + continue + + caption_height = 12 + row_height = max(item[2] for item in prepared) + caption_height + if y - row_height < content_bottom: + y = new_page() + + base_y = y - caption_height + for photo_index, (photo, image_reader, scaled_h) in enumerate(prepared): + image_x = MARGIN + photo_index * (image_width + image_gap) + image_y = base_y - scaled_h + pdf.drawImage( + image_reader, + image_x, + image_y, + width=image_width, + height=scaled_h, + preserveAspectRatio=True, + anchor='c', + mask='auto', + ) + pdf.setFont("Vera", 7) + caption = (photo.description or '').strip() or f"Slika {index + photo_index + 1}" + pdf.drawString(image_x, image_y - 10, caption[:52]) + + y = base_y - max(item[2] for item in prepared) - 14 + + pdf.save() + return buffer.getvalue() + +def _first_non_empty(*values): + for value in values: + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _user_display_name(user): + if not user: + return None + full_name = user.get_full_name() + if full_name: + return full_name + if getattr(user, 'email', None): + return user.email + if getattr(user, 'username', None): + return user.username + return str(getattr(user, 'pk', '')) + +def _send_document_email(*, recipient, subject, body, filename, pdf_bytes): + if not recipient: + raise DRFValidationError({"recipient": "Email primatelja je obavezan."}) + message = EmailMessage( + subject=subject, + body=body, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', None), + to=[recipient], + ) + message.attach(filename, pdf_bytes, 'application/pdf') + message.send(fail_silently=False) + + +def _notify_pdf_request(*, user, work_order, doc_type, stage): + if user is None: + return + if doc_type == 'work_order': + doc_label = "putnog naloga" + elif doc_type == 'service_records': + doc_label = "servisnih zapisa putnog naloga" + else: + doc_label = "računa putnog naloga" + + if stage == 'requested': + title = f"Zahtjev za PDF {doc_label}" + message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}." + level = "info" + else: + title = f"PDF spreman ({doc_label})" + message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}." + level = "success" + + NotificationService.create_notification( + recipient=user, + title=title, + message=message, + level=level, + send_email=False, + metadata={ + "entity_type": "work_order_pdf", + "work_order_id": str(work_order.pk), + "vehicle_id": str(work_order.vehicle_id), + "section": "work-orders", + "pdf_type": doc_type, + "stage": stage, + }, + ) + +@api_view(['POST']) +@permission_classes([permissions.IsAuthenticated]) +def pusher_auth(request): + """ + Authenticate user for private Pusher channel. + Endpoint koji frontend poziva za auth na privatnom kanalu. + """ + socket_id = request.data.get('socket_id') + channel_name = request.data.get('channel_name') + + if not socket_id or not channel_name: + return Response({'error': 'Missing socket_id or channel_name'}, status=400) + + auth_data = PusherService.authenticate_channel( + socket_id=socket_id, + channel_name=channel_name, + user_id=request.user.id + ) + + if not auth_data: + return Response({'error': 'Authentication failed'}, status=403) + + return Response(auth_data) + +class VehicleViewSet(viewsets.ModelViewSet): + serializer_class = VehicleSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return _fleet_assets_queryset_for_user(self.request.user, Vehicle, asset_type='vehicle') + + +class CraneViewSet(viewsets.ModelViewSet): + serializer_class = CraneSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return _fleet_assets_queryset_for_user(self.request.user, Crane, asset_type='crane') + + +class WorkOrderViewSet(viewsets.ModelViewSet): + serializer_class = WorkOrderSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return _work_orders_queryset_for_user(self.request.user) + + @action(detail=True, methods=['get'], url_path='images') + def images(self, request, pk=None): + work_order = self.get_object() + service_record_id = request.query_params.get('service_record_id') + quality = _parse_positive_int( + request.query_params.get('q'), + field_name='q', + default=75, + min_value=30, + max_value=95, + ) + width = _parse_positive_int( + request.query_params.get('w'), + field_name='w', + default=1280, + min_value=160, + max_value=3840, + ) + image_format, _ = _parse_format(request.query_params.get('fmt')) + work_order_task_ids = list( + work_order.work_order_tasks.filter(is_active=True).values_list('id', flat=True) + ) + + if not work_order_task_ids: + return Response({ + "work_order_id": work_order.pk, + "vehicle_id": work_order.vehicle_id, + "service_record_id": service_record_id or None, + "images": [], + }, status=status.HTTP_200_OK) + + photos_qs = VehicleServicePhoto.objects.filter( + is_active=True, + service_record__is_active=True, + service_record__vehicle_id=work_order.vehicle_id, + service_record__task_id__in=work_order_task_ids, + ).select_related('service_record', 'uploaded_by').order_by('-created_at') + + if not request.user.is_staff: + photos_qs = photos_qs.filter(service_record__performed_by=request.user) + + if service_record_id: + record = _service_records_queryset_for_user(request.user).filter( + pk=service_record_id, + vehicle_id=work_order.vehicle_id, + task_id__in=work_order_task_ids, + ).first() + if record is None: + raise DRFValidationError({ + "service_record_id": "Servisni zapis nije pronađen ili ne pripada ovom nalogu." + }) + photos_qs = photos_qs.filter(service_record_id=record.pk) + + images = [] + for photo in photos_qs: + optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk}) + optimized_url = request.build_absolute_uri( + f"{optimized_path}?w={width}&q={quality}&fmt={image_format.lower()}" + ) + original_url = request.build_absolute_uri(photo.image.url) if photo.image else None + images.append({ + "id": photo.pk, + "service_record_id": photo.service_record_id, + "description": photo.description, + "original_url": original_url, + "optimized_url": optimized_url, + "created_at": photo.created_at, + }) + + return Response({ + "work_order_id": work_order.pk, + "vehicle_id": work_order.vehicle_id, + "service_record_id": service_record_id or None, + "images": images, + }, status=status.HTTP_200_OK) + + @action(detail=True, methods=['get'], url_path='pdf') + def pdf(self, request, pk=None): + work_order = self.get_object() + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='requested') + pdf_bytes = _build_work_order_pdf(work_order) + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='completed') + response = HttpResponse(pdf_bytes, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order.pdf"' + return response + + @action(detail=True, methods=['get'], url_path='service-records-pdf') + def service_records_pdf(self, request, pk=None): + work_order = self.get_object() + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested') + pdf_bytes = _build_work_order_service_records_pdf(work_order) + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed') + response = HttpResponse(pdf_bytes, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order-service-records.pdf"' + return response + + @action(detail=True, methods=['get'], url_path='invoices-pdf') + def invoices_pdf(self, request, pk=None): + work_order = self.get_object() + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='requested') + try: + task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk)) + payload = task_result.get(timeout=45) + except KombuOperationalError: + payload = build_work_order_invoices_pdf_task.apply(args=[str(work_order.pk)]).get() + except CeleryTimeoutError: + raise DRFValidationError({"detail": "Generiranje PDF računa traje predugo. Pokušajte ponovno."}) + + if not isinstance(payload, dict) or payload.get('error'): + raise DRFValidationError({"detail": payload.get('error') if isinstance(payload, dict) else "Neuspješno generiranje PDF računa."}) + pdf_b64 = payload.get('pdf_base64') + if not pdf_b64: + raise DRFValidationError({"detail": "PDF sadržaj računa nije dostupan."}) + + try: + pdf_bytes = base64.b64decode(pdf_b64) + except (ValueError, TypeError): + raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."}) + + filename = payload.get('filename') or f"{work_order.pk}.work-order-invoices.pdf" + _notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='completed') + response = HttpResponse(pdf_bytes, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + @action(detail=True, methods=['get'], url_path='pdf-preview') + def pdf_preview(self, request, pk=None): + work_order = self.get_object() + invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at') + invoice_rows = [] + for invoice in invoices: + image_url = request.build_absolute_uri(invoice.image.url) if invoice.image else None + datum = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-' + invoice_rows.append( + f""" + + {escape(invoice.naziv_racuna)} + {escape(invoice.lokacija or '-')} + {escape(datum)} + {escape(invoice.opis or '-')} + {f'Otvori sliku' if image_url else '-'} + + """ + ) + + pdf_url = request.build_absolute_uri(reverse('workorder-pdf', kwargs={'pk': work_order.pk})) + invoices_pdf_url = request.build_absolute_uri(reverse('workorder-invoices-pdf', kwargs={'pk': work_order.pk})) + html = f""" + + + + + + Računi putnog naloga WO-{work_order.pk} + + + +
+

Putni nalog WO-{work_order.pk} — Računi

+

Dizalica: {escape(work_order.vehicle.registration_number)} | Datum naloga: {escape(str(work_order.date))}

+ +
+
+

Popis računa

+ {('' + ''.join(invoice_rows) + '
naziv_računalokacijadatumopisslika
') if invoice_rows else '

Za ovaj putni nalog još nema unesenih računa.

'} +
+ + + """ + return HttpResponse(html, content_type='text/html; charset=utf-8') + + @action(detail=True, methods=['get'], url_path='task-service-context') + def task_service_context(self, request, pk=None): + work_order = self.get_object() + tasks = _work_order_related_tasks_queryset(work_order).prefetch_related( + 'service_records', + 'service_records__photos', + 'service_records__performed_by', + 'service_records__vehicle', + ) + payload = [] + for task in tasks: + records_payload = [] + for record in task.service_records.filter(is_active=True, vehicle_id=work_order.vehicle_id).all(): + photos_payload = [] + for photo in record.photos.filter(is_active=True).all(): + if not photo.image: + continue + optimized_path = reverse('service-photo-optimized', kwargs={'pk': photo.pk}) + photos_payload.append({ + 'id': photo.pk, + 'image_url': request.build_absolute_uri(photo.image.url), + 'optimized_url': request.build_absolute_uri(f"{optimized_path}?w=1280&q=75&fmt=webp"), + 'description': photo.description, + }) + records_payload.append({ + 'id': record.pk, + 'service_title': record.service_title, + 'description': record.description, + 'service_date': record.service_date, + 'cost': str(record.cost), + 'mileage': record.mileage, + 'performed_by_name': _user_display_name(record.performed_by), + 'vehicle_registration': getattr(record.vehicle, 'registration_number', None), + 'photos': photos_payload, + }) + payload.append({ + 'id': task.pk, + 'title': task.title, + 'status': task.status, + 'description': task.description, + 'assigned_to_name': _user_display_name(task.assigned_to), + 'vehicle_registration': getattr(task.vehicle, 'registration_number', None), + 'service_records': records_payload, + }) + + return Response({ + 'work_order_id': work_order.pk, + 'tasks': payload, + }, status=status.HTTP_200_OK) + + @action(detail=True, methods=['post'], url_path='send-email') + def send_email(self, request, pk=None): + work_order = self.get_object() + recipient = _first_non_empty( + request.data.get('recipient'), + getattr(getattr(work_order.vehicle, 'client', None), 'email', None), + getattr(work_order.creator, 'email', None), + ) + if not recipient: + raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."}) + + pdf_bytes = _build_work_order_pdf(work_order) + subject = _first_non_empty(request.data.get('subject')) or f"Putni nalog WO-{work_order.pk}" + body = _first_non_empty(request.data.get('message')) or "U prilogu je PDF putnog naloga." + _send_document_email( + recipient=recipient, + subject=subject, + body=body, + filename=f"{work_order.pk}.work-order.pdf", + pdf_bytes=pdf_bytes, + ) + return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK) + + +class VehicleServiceRecordViewSet(viewsets.ModelViewSet): + serializer_class = VehicleServiceRecordSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + user = self.request.user + return _service_records_queryset_for_user(user) + + @action(detail=True, methods=['get'], url_path='pdf') + def pdf(self, request, pk=None): + service_record = self.get_object() + rows = [ + ("ID", str(service_record.pk)), + ("Datum", str(service_record.service_date)), + ("Dizalica", service_record.vehicle.registration_number), + ("Serviser", service_record.performed_by.get_full_name() or service_record.performed_by.email), + ("Kilometraza", str(service_record.mileage)), + ("Sljedeci servis", str(service_record.next_service_due_at or '-')), + ("Trosak", str(service_record.cost)), + ("Opis", service_record.description or '-'), + ("Dijelovi", service_record.parts or '-'), + ] + pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows) + response = HttpResponse(pdf_bytes, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="{service_record.pk}.service-record.pdf"' + return response + + @action(detail=True, methods=['post'], url_path='send-email') + def send_email(self, request, pk=None): + service_record = self.get_object() + recipient = _first_non_empty( + request.data.get('recipient'), + getattr(getattr(service_record.vehicle, 'client', None), 'email', None), + getattr(service_record.performed_by, 'email', None), + ) + if not recipient: + raise DRFValidationError({"recipient": "Nije pronađena email adresa za slanje."}) + + rows = [ + ("ID", str(service_record.pk)), + ("Datum", str(service_record.service_date)), + ("Dizalica", service_record.vehicle.registration_number), + ("Kilometraza", str(service_record.mileage)), + ("Trosak", str(service_record.cost)), + ("Opis", service_record.description or '-'), + ("Dijelovi", service_record.parts or '-'), + ] + pdf_bytes = _build_simple_pdf(f"Servisni zapis {service_record.pk}", rows) + subject = _first_non_empty(request.data.get('subject')) or f"Servisni zapis {service_record.pk}" + body = _first_non_empty(request.data.get('message')) or ( + f"U prilogu je PDF servisnog zapisa. Poslano {timezone.now().strftime('%d.%m.%Y %H:%M')}." + ) + _send_document_email( + recipient=recipient, + subject=subject, + body=body, + filename=f"{service_record.pk}.service-record.pdf", + pdf_bytes=pdf_bytes, + ) + return Response({"sent": True, "recipient": recipient}, status=status.HTTP_200_OK) + + +class WorkOrderInvoiceViewSet(viewsets.ModelViewSet): + serializer_class = WorkOrderInvoiceSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = (MultiPartParser, FormParser) + + def get_queryset(self): + user = self.request.user + work_order_id = self.request.query_params.get('work_order_id') + qs = WorkOrderInvoice.objects.select_related('work_order', 'work_order__vehicle', 'created_by').filter(is_active=True) + if user.is_staff: + if work_order_id: + qs = qs.filter(work_order_id=work_order_id) + return qs + allowed_orders = _work_orders_queryset_for_user(user).values('id') + qs = qs.filter(work_order_id__in=allowed_orders) + if work_order_id: + qs = qs.filter(work_order_id=work_order_id) + return qs + + def perform_create(self, serializer): + work_order = serializer.validated_data.get('work_order') + if work_order is None: + raise DRFValidationError({"work_order": "Putni nalog je obavezan."}) + allowed = _work_orders_queryset_for_user(self.request.user).filter(pk=work_order.pk).exists() + if not allowed: + raise DRFValidationError({"work_order": "Nemate dozvolu za odabrani putni nalog."}) + serializer.save(created_by=self.request.user) + + def perform_destroy(self, instance): + instance.is_active = False + instance.save(update_fields=['is_active']) + +class VehicleNotificationViewSet(viewsets.ReadOnlyModelViewSet): + """ + List all notifications for the authenticated user and provide an action + to mark a notification as read. + """ + serializer_class = VehicleNotificationSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + # Prikaži samo aktivne notifikacije za trenutnog korisnika + user = self.request.user + return VehicleNotification.objects.filter(recipient=user, is_active=True).order_by('-created_at') + + @action(detail=True, methods=['post'], url_path='mark-read') + def mark_read(self, request, pk=None): + """ + Označi notifikaciju kao pročitanu. + URL: POST /api/fleet/notifications/{pk}/mark-read/ + """ + notif = self.get_object() + # sigurnosna provjera: dozvoljeno samo primatelju + if notif.recipient != request.user: + raise PermissionDenied("Nemate dozvolu za izmjenu ove notifikacije.") + + notif.is_read = True + notif.save(update_fields=['is_read']) + + serializer = self.get_serializer(notif) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=False, methods=['post'], url_path='mark-all-read') + def mark_all_read(self, request): + """ + Opcionalno: označi sve notifikacije trenutnog korisnika kao pročitane. + URL: POST /api/fleet/notifications/mark-all-read/ + """ + user = request.user + qs = VehicleNotification.objects.filter(recipient=user, is_active=True, is_read=False) + updated_count = qs.update(is_read=True) + return Response({"marked": updated_count}, status=status.HTTP_200_OK) + +class VehicleServicePhotoViewSet(viewsets.ModelViewSet): + """ + ViewSet za upravljanje fotografijama servisnih zapisa. + Delegira sve operacije u VehicleServicePhotoService. + Podržava multipart/form-data upload preko POST i PATCH za ažuriranje metapodataka. + """ + queryset = VehicleServicePhoto.objects.filter(is_active=True) + serializer_class = VehicleServicePhotoSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = (MultiPartParser, FormParser) + + def get_queryset(self): + """Filtriraj po service_record ID-u ako je proslijeđen u query parametrima.""" + service_record_id = self.request.query_params.get('service_record_id') + user = self.request.user + qs = VehicleServicePhoto.objects.filter(is_active=True) + if not user.is_staff: + qs = qs.filter(service_record__performed_by=user) + if service_record_id: + qs = qs.filter(service_record_id=service_record_id) + return qs + + @action(detail=True, methods=['get'], url_path='optimized') + def optimized(self, request, pk=None): + photo = self.get_object() + if not photo.image: + raise DRFValidationError({"detail": "Fotografija nije dostupna."}) + + width = _parse_positive_int( + request.query_params.get('w'), + field_name='w', + default=1280, + min_value=160, + max_value=3840, + ) + quality = _parse_positive_int( + request.query_params.get('q'), + field_name='q', + default=75, + min_value=30, + max_value=95, + ) + image_format, extension = _parse_format(request.query_params.get('fmt')) + + try: + photo.image.open('rb') + with Image.open(photo.image) as source: + image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy() + if width and image.width > width: + ratio = width / float(image.width) + target_height = max(1, int(image.height * ratio)) + image = image.resize((width, target_height), Image.Resampling.LANCZOS) + + buffer = BytesIO() + save_kwargs = {"format": image_format, "optimize": True} + if image_format in ('WEBP', 'JPEG'): + save_kwargs["quality"] = quality + image.save(buffer, **save_kwargs) + except (UnidentifiedImageError, OSError): + raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."}) + finally: + photo.image.close() + + response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}") + response['Cache-Control'] = 'private, max-age=86400' + response['Content-Disposition'] = f'inline; filename="photo-{photo.pk}.{extension}"' + return response + + def perform_create(self, serializer): + """ + Delegiraj stvaranje u VehicleServicePhotoService. + serializer.validated_data sadrži sve potrebne podatke; dodaj trenutnog korisnika. + """ + try: + data = serializer.validated_data + if not _can_access_service_record(self.request.user, data.get('service_record')): + raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."}) + photo = VehicleServicePhotoService.upload_photo( + data=data, + uploaded_by=self.request.user + ) + serializer.instance = photo + except DRFValidationError: + raise + except Exception as e: + raise DRFValidationError({"detail": str(e)}) + + def perform_update(self, serializer): + """ + Delegiraj ažuriranje u VehicleServicePhotoService. + """ + try: + instance = serializer.instance + data = serializer.validated_data + photo = VehicleServicePhotoService.update_photo( + instance=instance, + data=data, + user=self.request.user + ) + serializer.instance = photo + except DRFValidationError: + raise + except Exception as e: + raise DRFValidationError({"detail": str(e)}) + + def perform_destroy(self, instance): + """ + Delegiraj soft-delete u VehicleServicePhotoService. + """ + try: + VehicleServicePhotoService.delete_photo( + instance=instance, + user=self.request.user + ) + except DRFValidationError: + raise + except Exception as e: + raise DRFValidationError({"detail": str(e)}) + +class VehicleServiceAttachmentViewSet(viewsets.ModelViewSet): + queryset = VehicleServiceAttachment.objects.filter(is_active=True) + serializer_class = VehicleServiceAttachmentSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = (MultiPartParser, FormParser) + + def get_queryset(self): + user = self.request.user + service_record_id = self.request.query_params.get('service_record_id') + qs = VehicleServiceAttachment.objects.filter(is_active=True) + if not user.is_staff: + qs = qs.filter(service_record__performed_by=user) + if service_record_id: + qs = qs.filter(service_record_id=service_record_id) + return qs + + def perform_create(self, serializer): + if not _can_access_service_record(self.request.user, serializer.validated_data.get('service_record')): + raise DRFValidationError({"service_record": "Nemate dozvolu za ovaj servisni zapis."}) + serializer.save(uploaded_by=self.request.user) + + def perform_destroy(self, instance): + instance.is_active = False + instance.save(update_fields=['is_active']) \ No newline at end of file diff --git a/backend/modules/invoicing/__init__.py b/backend/modules/invoicing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/invoicing/admin.py b/backend/modules/invoicing/admin.py new file mode 100644 index 0000000..79acb0e --- /dev/null +++ b/backend/modules/invoicing/admin.py @@ -0,0 +1,43 @@ +# backend/modules/invoicing/admin.py + +from django.contrib import admin +from .models import InvoiceTransaction + +@admin.register(InvoiceTransaction) +class InvoiceTransactionEntryAdmin(admin.ModelAdmin): + """ + Konfiguracija prikaza financijskih zapisa u Admin panelu. + """ + # Prikaz stupaca u listi + list_display = ('invoice_number', 'work_order_ref', 'amount', 'created_at', 'is_active') + + # Omogućuje brzo filtriranje + list_filter = ('created_at', 'is_active', 'work_order') + list_select_related = ('work_order__vehicle',) + + # Omogućuje pretragu + search_fields = ('invoice_number', 'work_order__id', 'work_order__vehicle__registration_number') + + # Polja koja se ne smiju mijenjati u adminu + readonly_fields = ('id', 'created_at', 'updated_at') + + # Grupiranje polja za lakši pregled + fieldsets = ( + (None, { + 'fields': ('invoice_number', 'work_order', 'amount', 'payment_date', 'is_active') + }), + ('Sustavni podaci', { + 'fields': ('id', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + @admin.display(description="Povezani putni nalog") + def work_order_ref(self, obj): + if not obj.work_order_id: + return "—" + short_id = str(obj.work_order_id).split('-')[0].upper() + registration = getattr(obj.work_order.vehicle, 'registration_number', '') + if registration: + return f"WO-{short_id} ({registration})" + return f"WO-{short_id}" \ No newline at end of file diff --git a/backend/modules/invoicing/apps.py b/backend/modules/invoicing/apps.py new file mode 100644 index 0000000..1ac53db --- /dev/null +++ b/backend/modules/invoicing/apps.py @@ -0,0 +1,8 @@ +# /backend/modules/invoicing/apps.py + +from django.apps import AppConfig + +class FleetConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'modules.invoicing' + verbose_name = "Invoicing" \ No newline at end of file diff --git a/backend/modules/invoicing/migrations/0001_initial.py b/backend/modules/invoicing/migrations/0001_initial.py new file mode 100644 index 0000000..c275010 --- /dev/null +++ b/backend/modules/invoicing/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.15 on 2026-06-29 13:34 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='FinancialEntry', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('invoice_number', models.CharField(max_length=50)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ], + options={ + 'verbose_name': 'Financijski zapis', + 'verbose_name_plural': 'Financijski zapisi', + }, + ), + ] diff --git a/backend/modules/invoicing/migrations/0002_invoice_invoiceitem.py b/backend/modules/invoicing/migrations/0002_invoice_invoiceitem.py new file mode 100644 index 0000000..bc543f0 --- /dev/null +++ b/backend/modules/invoicing/migrations/0002_invoice_invoiceitem.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.15 on 2026-06-30 05:55 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crm', '0001_initial'), + ('invoicing', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('invoice_number', models.CharField(max_length=50, unique=True, verbose_name='Broj fakture')), + ('amount', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Iznos')), + ('currency', models.CharField(default='EUR', max_length=3)), + ('tax_rate', models.DecimalField(decimal_places=2, default=25.0, max_digits=4)), + ('status', models.CharField(choices=[('draft', 'Nacrt'), ('sent', 'Poslano'), ('paid', 'Plaćeno'), ('cancelled', 'Stornirano')], default='draft', max_length=20)), + ('due_date', models.DateField(verbose_name='Rok plaćanja')), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='crm.client', verbose_name='Klijent')), + ('creator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Faktura', + 'verbose_name_plural': 'Fakture', + }, + ), + migrations.CreateModel( + name='InvoiceItem', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('description', models.CharField(max_length=255, verbose_name='Opis stavke')), + ('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Količina')), + ('unit_price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Cijena po jedinici')), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='invoicing.invoice', verbose_name='Faktura')), + ], + options={ + 'verbose_name': 'Stavka fakture', + 'verbose_name_plural': 'Stavke fakture', + }, + ), + ] diff --git a/backend/modules/invoicing/migrations/0003_invoicetransaction_delete_financialentry.py b/backend/modules/invoicing/migrations/0003_invoicetransaction_delete_financialentry.py new file mode 100644 index 0000000..c468206 --- /dev/null +++ b/backend/modules/invoicing/migrations/0003_invoicetransaction_delete_financialentry.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.15 on 2026-06-30 17:54 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoicing', '0002_invoice_invoiceitem'), + ] + + operations = [ + migrations.CreateModel( + name='InvoiceTransaction', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('invoice_number', models.CharField(max_length=50)), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='invoicing.invoice', verbose_name='Transaction')), + ], + options={ + 'verbose_name': 'Financijski zapis', + 'verbose_name_plural': 'Financijski zapisi', + }, + ), + migrations.DeleteModel( + name='FinancialEntry', + ), + ] diff --git a/backend/modules/invoicing/migrations/0004_invoicetransaction_payment_date_and_more.py b/backend/modules/invoicing/migrations/0004_invoicetransaction_payment_date_and_more.py new file mode 100644 index 0000000..dd38f5d --- /dev/null +++ b/backend/modules/invoicing/migrations/0004_invoicetransaction_payment_date_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.15 on 2026-06-30 18:12 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoicing', '0003_invoicetransaction_delete_financialentry'), + ] + + operations = [ + migrations.AddField( + model_name='invoicetransaction', + name='payment_date', + field=models.DateField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AlterField( + model_name='invoicetransaction', + name='invoice', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='invoicing.invoice', verbose_name='Faktura'), + ), + ] diff --git a/backend/modules/invoicing/migrations/0005_alter_invoicetransaction_payment_date.py b/backend/modules/invoicing/migrations/0005_alter_invoicetransaction_payment_date.py new file mode 100644 index 0000000..e2cfbe7 --- /dev/null +++ b/backend/modules/invoicing/migrations/0005_alter_invoicetransaction_payment_date.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.15 on 2026-06-30 18:13 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoicing', '0004_invoicetransaction_payment_date_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='invoicetransaction', + name='payment_date', + field=models.DateField(default=django.utils.timezone.now), + ), + ] diff --git a/backend/modules/invoicing/migrations/0006_invoicetransaction_work_order.py b/backend/modules/invoicing/migrations/0006_invoicetransaction_work_order.py new file mode 100644 index 0000000..b9197c6 --- /dev/null +++ b/backend/modules/invoicing/migrations/0006_invoicetransaction_work_order.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.15 on 2026-07-07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0012_workorder_purpose_choices'), + ('invoicing', '0005_alter_invoicetransaction_payment_date'), + ] + + operations = [ + migrations.AddField( + model_name='invoicetransaction', + name='work_order', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='invoice_transactions', + to='fleet.workorder', + verbose_name='Putni nalog', + ), + ), + ] + diff --git a/backend/modules/invoicing/migrations/__init__.py b/backend/modules/invoicing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/invoicing/models.py b/backend/modules/invoicing/models.py new file mode 100644 index 0000000..85e085a --- /dev/null +++ b/backend/modules/invoicing/models.py @@ -0,0 +1,123 @@ +# backend/modules/invoicing/models.py + +import datetime +from django.utils import timezone +from core.base_models import BaseModel +from django.db import models, transaction +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from .utils import generate_invoice_number + +class Invoice(BaseModel): + # Identifikacija (UUID i created_at dolaze iz BaseModel) + invoice_number = models.CharField(max_length=50, unique=True, verbose_name=_("Broj fakture")) + + # Relacije + creator = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="invoices" + ) + client = models.ForeignKey( + 'crm.Client', + on_delete=models.PROTECT, + verbose_name=_("Klijent") + ) + + # Financijski detalji + amount = models.DecimalField(max_digits=12, decimal_places=2, verbose_name=_("Iznos")) + currency = models.CharField(max_length=3, default="EUR") + tax_rate = models.DecimalField(max_digits=4, decimal_places=2, default=25.00) + + # Statusi + status = models.CharField( + max_length=20, + choices=[ + ('draft', 'Nacrt'), + ('sent', 'Poslano'), + ('paid', 'Plaćeno'), + ('cancelled', 'Stornirano') + ], + default='draft' + ) + + due_date = models.DateField(verbose_name=_("Rok plaćanja")) + + class Meta: + # Nasljeđujemo ordering iz BaseModel, ali ga možemo override-ati + verbose_name = _("Faktura") + verbose_name_plural = _("Fakture") + + def __str__(self): + return f"{self.invoice_number} - {self.client}" + + def get_total_amount(self): + return self.items.aggregate(total=models.Sum(models.F('quantity') * models.F('unit_price')))['total'] or 0 + + def save(self, *args, **kwargs): + if not self.invoice_number: + self.invoice_number = generate_invoice_number() + super().save(*args, **kwargs) + +class InvoiceItem(BaseModel): + # Relacija prema fakturi + invoice = models.ForeignKey( + Invoice, + on_delete=models.CASCADE, + related_name='items', + verbose_name=_("Faktura") + ) + + # Detalji stavke + description = models.CharField( + max_length=255, + verbose_name=_("Opis stavke") + ) + quantity = models.DecimalField( + max_digits=10, + decimal_places=2, + default=1, + verbose_name=_("Količina") + ) + unit_price = models.DecimalField( + max_digits=12, + decimal_places=2, + verbose_name=_("Cijena po jedinici") + ) + + # Automatski izračun (property ne zauzima prostor u bazi) + @property + def total(self): + return self.quantity * self.unit_price + + class Meta: + verbose_name = _("Stavka fakture") + verbose_name_plural = _("Stavke fakture") + + def __str__(self): + # Koristimo prednost BaseModel __str__ metode + return f"{self.description} ({self.quantity} x {self.unit_price})" + +class InvoiceTransaction(BaseModel): + # Polja specifična za fakturiranje + invoice = models.ForeignKey( + Invoice, + on_delete=models.PROTECT, + related_name='transactions', + verbose_name=_("Faktura") + ) + work_order = models.ForeignKey( + 'fleet.WorkOrder', + on_delete=models.SET_NULL, + related_name='invoice_transactions', + null=True, + blank=True, + verbose_name=_("Putni nalog"), + ) + invoice_number = models.CharField(max_length=50) + amount = models.DecimalField(max_digits=10, decimal_places=2) + payment_date = models.DateField(default=timezone.now) + + class Meta: + verbose_name = "Financijski zapis" + verbose_name_plural = "Financijski zapisi" \ No newline at end of file diff --git a/backend/modules/invoicing/serializers.py b/backend/modules/invoicing/serializers.py new file mode 100644 index 0000000..a58b2ec --- /dev/null +++ b/backend/modules/invoicing/serializers.py @@ -0,0 +1,57 @@ +from django.db import transaction +from core.serializers import BaseSerializer +from rest_framework import serializers +from .models import Invoice, InvoiceItem, InvoiceTransaction +from .services import InvoiceService # + +class InvoiceItemSerializer(serializers.ModelSerializer): + class Meta: + model = InvoiceItem + fields = ['description', 'quantity', 'unit_price'] + +class InvoiceSerializer(serializers.ModelSerializer): + items = InvoiceItemSerializer(many=True) # Nested lista stavki + + class Meta: + model = Invoice + fields = ['invoice_number', 'client', 'due_date', 'status', 'items', 'amount'] + read_only_fields = ['invoice_number', 'creator', 'status', 'amount'] + extra_kwargs = { + 'invoice_number': {'required': False, 'allow_null': True}, + 'creator': {'required': False, 'allow_null': True} + } + + def create(self, validated_data): + """ + Delegiramo stvaranje servisu i vraćamo instancu Invoice. + """ + items = validated_data.pop('items', []) + # Servisu šaljemo items u validated_data kao što servis očekuje + validated_data['items'] = items + + # Pokušavamo dohvatiti korisnika iz contexta (ako postoji) + request = self.context.get('request') + user = getattr(request, 'user', None) + + # Pozivamo servis unutar transaction.atomic (servis već radi atomic, ali dodatno osiguranje nije štetno) + invoice = InvoiceService.create_full_invoice(user=user, **validated_data) + return invoice + +class InvoiceTransactionSerializer(BaseSerializer): + invoice = serializers.PrimaryKeyRelatedField(queryset=Invoice.objects.all()) + + def validate_amount(self, value): + request = self.context.get('request') + # Ako request ne postoji (npr. unit test), tretiramo ga kao običnog korisnika (sigurnost prvo) + user = getattr(request, 'user', None) + + # Ako user ne postoji ili nije staff, i iznos je <= 0 -> greška + if value <= 0 and (not user or not user.is_staff): + raise serializers.ValidationError("Iznos računa mora biti veći od 0.") + + return value + + class Meta: + model = InvoiceTransaction + fields = ['invoice', 'work_order', 'invoice_number', 'amount', 'payment_date', 'is_active'] + read_only_fields = ['id', 'created_at', 'updated_at'] \ No newline at end of file diff --git a/backend/modules/invoicing/services.py b/backend/modules/invoicing/services.py new file mode 100644 index 0000000..f2f558d --- /dev/null +++ b/backend/modules/invoicing/services.py @@ -0,0 +1,77 @@ +from django.utils import timezone +from modules.invoicing.models import Invoice, InvoiceItem +from modules.invoicing.utils import generate_invoice_number +from django.db import transaction +from django.contrib.auth import get_user_model +from rest_framework.exceptions import ValidationError +import logging +from decimal import Decimal +from modules.invoicing import tasks # premještamo ovdje ako treba + +logger = logging.getLogger(__name__) + +class InvoiceService: + @staticmethod + def create_full_invoice(user=None, **validated_data): + # 1. Obavezno dohvati stavke + items_data = validated_data.pop('items', []) + + fields_to_remove = ['amount', 'creator', 'invoice_number'] + for field in fields_to_remove: + validated_data.pop(field, None) + + # 2. Sigurna provjera korisnika + if not user: + User = get_user_model() + try: + user = User.objects.get(username="paperless_bot") + except User.DoesNotExist: + raise ValidationError("Sistemski korisnik 'paperless_bot' ne postoji.") + + with transaction.atomic(): + # 3. Računanje iznosa (sigurno rukovanje s items_data) + total = sum(Decimal(item['quantity']) * Decimal(item['unit_price']) for item in items_data) + + # 4. Kreiraj fakturu + invoice = Invoice.objects.create( + **validated_data, + amount=total, + creator=user, + invoice_number=generate_invoice_number() + ) + + # 5. Kreiraj stavke + for item_data in items_data: + InvoiceItem.objects.create(invoice=invoice, **item_data) + + # 6. Pokreni task nakon commit-a + transaction.on_commit(lambda: tasks.process_paperless_document_task.delay(invoice.id)) + + return invoice + + @staticmethod + def create_invoice_entry(data, user=None): + """ + Kreira financijski zapis (transaction) na temelju validated data. + Očekuje se da data['invoice'] može biti instanca ili PK. + """ + from modules.invoicing.models import InvoiceTransaction, Invoice + + invoice = data.get("invoice") + if isinstance(invoice, int): + invoice = Invoice.objects.get(pk=invoice) + elif hasattr(invoice, "pk") is False: + raise ValidationError("Nevažeći invoice.") + + amount = data.get("amount") + payment_date = data.get("payment_date", None) + work_order = data.get("work_order") + + tx = InvoiceTransaction.objects.create( + invoice=invoice, + work_order=work_order, + invoice_number=invoice.invoice_number, + amount=amount, + payment_date=payment_date or timezone.now() + ) + return tx \ No newline at end of file diff --git a/backend/modules/invoicing/tasks.py b/backend/modules/invoicing/tasks.py new file mode 100644 index 0000000..dac2b62 --- /dev/null +++ b/backend/modules/invoicing/tasks.py @@ -0,0 +1,81 @@ +import uuid +from celery import shared_task +from datetime import date, timedelta +from infrastructure.paperless_client import PaperlessGateway +from core.utils import format_currency, parse_iso_date +from django.contrib.auth import get_user_model +import logging +from decimal import Decimal +from modules.crm.models import Client + +logger = logging.getLogger(__name__) + +@shared_task(bind=True, max_retries=3) +def process_paperless_document_task(self, document_id): + """ + Celery zadatak koji povezuje infrastrukturu i poslovnu logiku. + """ + gateway = PaperlessGateway() # rename so we don't overwrite 'client' + + try: + User = get_user_model() + # 1. Dohvat iz infrastrukture + raw_data = gateway.get_document_metadata(document_id) + raw_created = parse_iso_date(raw_data.get("created")) + correspondent_name = raw_data.get("correspondent") + + # pokušaj dohvatiti crm klijenta + try: + crm_client = Client.objects.get(name=correspondent_name) + except Client.DoesNotExist: + logger.error(f"Klijent {correspondent_name} nije pronađen!") + # Kreiramo fallback klijenta ali popunimo UNIQUE polja jedinstvenim vrijednostima + unique_email = f"client+{uuid.uuid4()}@example.test" + unique_tax_id = str(uuid.uuid4()) + crm_client, created = Client.objects.get_or_create( + name=correspondent_name, + defaults={ + "email": unique_email, + "tax_id": unique_tax_id, + } + ) + if created: + logger.info(f"Fallback klijent kreiran za '{correspondent_name}' (id={crm_client.id}).") + + # 2. Transformacija i čišćenje podataka + total_raw = raw_data.get("total_amount", "0") + try: + amount_decimal = Decimal(str(total_raw)) + except Exception: + amount_decimal = Decimal("0") + + processed_data = { + "client": crm_client, + "invoice_number": raw_data.get("invoice_number") or raw_data.get("correspondent"), + "due_date": (raw_created or date.today()) + timedelta(days=30), + "amount": amount_decimal, + "created_at": raw_created, + } + + from modules.invoicing.services import InvoiceService + + lookup_field = User.USERNAME_FIELD + system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first() + + if not system_user: + system_user = User(**{lookup_field: "bot@erp.hr"}) + system_user.email = "bot@erp.hr" + system_user.first_name = "Paperless" + system_user.last_name = "Bot" + system_user.set_unusable_password() + system_user.save() + + # Pozovi servis uvijek nakon što garantujemo da system_user postoji + InvoiceService.create_full_invoice(user=system_user, **processed_data) + + logger.info(f"Dokument {document_id} uspješno obrađen.") + + except Exception as exc: + logger.error(f"Greška pri obradi dokumenta {document_id}: {exc}") + # Automatski retry ako padne + raise self.retry(exc=exc, countdown=60) \ No newline at end of file diff --git a/backend/modules/invoicing/tests/conftest.py b/backend/modules/invoicing/tests/conftest.py new file mode 100644 index 0000000..d07cfab --- /dev/null +++ b/backend/modules/invoicing/tests/conftest.py @@ -0,0 +1,112 @@ +# backend/modules/invoicing/tests/conftest.py + +import pytest +from django.contrib.auth import get_user_model +from modules.invoicing.models import Invoice +from unittest.mock import MagicMock +from django.conf import settings +from core.celery import app as celery_app +import modules.invoicing.tasks as tasks +import uuid + +User = get_user_model() + +@pytest.fixture +def user(): + # Dodan username + return User.objects.create_user(email="test@test.hr", password="password") + +@pytest.fixture +def api_client(user): + from rest_framework.test import APIClient + client = APIClient() + client.force_authenticate(user=user) + return client + +@pytest.fixture +def staff_user(): + return User.objects.create_superuser(email='admin@erp.hr', password='password', first_name='Admin', last_name='User') + +@pytest.fixture +def regular_user(): + return User.objects.create_user(email='user@erp.hr', password='password', first_name='User', last_name='User') + +@pytest.fixture +def serviser_a(): + return User.objects.create_user(email="a@test.hr", password="password", first_name='A', last_name='User') + +@pytest.fixture +def serviser_b(): + return User.objects.create_user(email="b@test.hr", password="password", first_name='B', last_name='User') + +@pytest.fixture +def test_client(): + from modules.crm.models import Client + return Client.objects.create(name="Testni Klijent", tax_id="12345678901") + +@pytest.fixture +def invoice_a(serviser_a, test_client): # Dodali smo test_client + from modules.invoicing.utils import generate_invoice_number + return Invoice.objects.create( + invoice_number=generate_invoice_number(), + creator=serviser_a, + client=test_client, # OVDJE JE BIO PROBLEM (bio je null) + amount=100.00, + due_date="2026-12-31" + ) + +@pytest.fixture(autouse=True) +def paperless_settings(settings): + # Postavi privremene vrijednosti za vrijeme trajanja testa + settings.PAPERLESS_API_URL = "http://mock-paperless.local" + settings.PAPERLESS_API_TOKEN = "mock-token" + +@pytest.fixture(autouse=True) +def force_celery_eager(settings): + settings.CELERY_TASK_ALWAYS_EAGER = True + settings.CELERY_TASK_EAGER_PROPAGATES = True + + # Ovo je ključ: prisilno ažuriraj konfiguraciju Celery aplikacije + celery_app.conf.update( + task_always_eager=True, + task_eager_propagates=True, + ) + +@pytest.fixture(autouse=True) +def mock_paperless_gateway(monkeypatch): + """ + Koristi monkeypatch za zamjenu PaperlessGateway klase. + """ + mock_client = MagicMock() + mock_client.get_document_metadata.return_value = { + "correspondent": "INV-2026-TEST", + "total_amount": "100.00", + "created": "2026-07-01" + } + + # Kreiramo tvornicu koja vraća naš mock + MockGateway = MagicMock(return_value=mock_client) + + # Zamijeni klasu unutar tasks modula + monkeypatch.setattr(tasks, "PaperlessGateway", MockGateway) + + yield MockGateway + +@pytest.fixture +def mock_client(db): + """Kreira klijenta kojeg koristi PaperlessGateway u testovima.""" + from modules.crm.models import Client + return Client.objects.create(name="INV-2026-TEST") # Ime mora odgovarati mocku iz conftest.py + +@pytest.fixture +def create_unique_client(db): + """Factory funkcija za kreiranje unikatnog klijenta.""" + from modules.crm.models import Client + def _create_client(name): + return Client.objects.create( + name=name, + tax_id=str(uuid.uuid4()), # Garantira unikatnost + email=f"{uuid.uuid4()}@test.hr" # Garantira unikatnost + ) + return _create_client + diff --git a/backend/modules/invoicing/tests/test_api.py b/backend/modules/invoicing/tests/test_api.py new file mode 100644 index 0000000..d3bb8ab --- /dev/null +++ b/backend/modules/invoicing/tests/test_api.py @@ -0,0 +1,46 @@ +import uuid +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient +from django.contrib.auth import get_user_model +from decimal import Decimal +from datetime import date + +from modules.crm.models import Client +from modules.invoicing.models import Invoice + + +class InvoiceAPITests(TestCase): + def setUp(self): + self.api_client = APIClient() + User = get_user_model() + # Kreiraj test korisnika (ako treba autentikacija) + self.user = User.objects.create_user( + username=f"user_{uuid.uuid4().hex[:8]}", + email=f"user+{uuid.uuid4().hex[:8]}@example.test", + password="pass" + ) + # Kreiraj jedinstvenog klijenta (email s uuid kako bismo izbjegli duplicate key) + self.client_obj = Client.objects.create( + name="API Test Client", + email=f"client+{uuid.uuid4()}@example.test" + ) + # autentikacija ako je endpoint zaštićen + self.api_client.force_authenticate(user=self.user) + + def test_api_creates_invoice_with_id(self): + url = reverse('invoice-list') # prilagodi ako je druga ruta + payload = { + "client": self.client_obj.id, + "due_date": date.today().isoformat(), + "items": [ + {"description": "Test item", "quantity": "1.00", "unit_price": "10.00"} + ] + } + + response = self.api_client.post(url, payload, format='json') + assert response.status_code in (200, 201) + # provjeri da se faktura stvorila i da vraća id/kod + created = Invoice.objects.filter(client=self.client_obj).first() + assert created is not None + assert created.amount == Decimal("10.00") \ No newline at end of file diff --git a/backend/modules/invoicing/tests/test_models.py b/backend/modules/invoicing/tests/test_models.py new file mode 100644 index 0000000..5717ff4 --- /dev/null +++ b/backend/modules/invoicing/tests/test_models.py @@ -0,0 +1,18 @@ +import pytest +from modules.invoicing.models import Invoice, InvoiceItem + +@pytest.mark.django_db +def test_invoice_str_representation(invoice_a): + assert str(invoice_a) == f"{invoice_a.invoice_number} - {invoice_a.client}" + +@pytest.mark.django_db +def test_invoice_total_calculation(invoice_a): + InvoiceItem.objects.create(invoice=invoice_a, description="Test", quantity=2, unit_price=50.00) + InvoiceItem.objects.create(invoice=invoice_a, description="Test2", quantity=1, unit_price=25.00) + assert invoice_a.get_total_amount() == 125.00 + +@pytest.mark.django_db +def test_invoice_number_autogenerate(): + # Testiramo da li se automatski generira ako nije zadan + from modules.invoicing.models import Invoice + # (Za ovo ti treba 'creator' i 'client' fixture-i) \ No newline at end of file diff --git a/backend/modules/invoicing/tests/test_permissions.py b/backend/modules/invoicing/tests/test_permissions.py new file mode 100644 index 0000000..4dac809 --- /dev/null +++ b/backend/modules/invoicing/tests/test_permissions.py @@ -0,0 +1,10 @@ +import pytest +from rest_framework import status + +@pytest.mark.django_db +def test_invoice_access_restriction(api_client, regular_user, invoice_a): + # invoice_a pripada serviseru_a, a api_client je regular_user + url = f'/api/invoicing/invoices/{invoice_a.id}/' + response = api_client.get(url) + # Trebao bi dobiti 404 ili 403 ovisno o tvojoj logici + assert response.status_code in [status.HTTP_404_NOT_FOUND, status.HTTP_403_FORBIDDEN] \ No newline at end of file diff --git a/backend/modules/invoicing/tests/test_serializers.py b/backend/modules/invoicing/tests/test_serializers.py new file mode 100644 index 0000000..c2aa023 --- /dev/null +++ b/backend/modules/invoicing/tests/test_serializers.py @@ -0,0 +1,22 @@ +import pytest +from modules.invoicing.serializers import InvoiceSerializer +from unittest.mock import MagicMock + +@pytest.mark.django_db +def test_invoice_serializer_valid(regular_user, test_client): + data = { + "client": test_client.id, + "due_date": "2026-12-31", + "status": "draft", + "items": [ + {"description": "Servis", "quantity": 1, "unit_price": 100.00} + ] + } + # Context je ključan za pristup useru + request = MagicMock() + request.user = regular_user + serializer = InvoiceSerializer(data=data, context={'request': request}) + + assert serializer.is_valid(), serializer.errors + invoice = serializer.save() + assert invoice.amount == 100.00 \ No newline at end of file diff --git a/backend/modules/invoicing/tests/test_services.py b/backend/modules/invoicing/tests/test_services.py new file mode 100644 index 0000000..ad2fdc0 --- /dev/null +++ b/backend/modules/invoicing/tests/test_services.py @@ -0,0 +1,40 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from datetime import date +from decimal import Decimal + +from modules.crm.models import Client +from modules.invoicing.models import Invoice, InvoiceItem +from modules.invoicing.services import InvoiceService + + +class InvoiceServiceTests(TestCase): + def setUp(self): + User = get_user_model() + self.creator = User.objects.create_user(username="creator", email="c@example.com", password="pass") + self.client = Client.objects.create(name="Test Client") + + def test_create_full_invoice_creates_invoice_and_items(self): + items = [ + {"description": "Item A", "quantity": Decimal("2.00"), "unit_price": Decimal("10.00")}, + {"description": "Item B", "quantity": Decimal("1.00"), "unit_price": Decimal("5.50")}, + ] + validated_data = { + "client": self.client, + "due_date": date.today(), + "items": items, + } + + invoice = InvoiceService.create_full_invoice(user=self.creator, **validated_data) + + # Basic assertions + self.assertIsInstance(invoice, Invoice) + self.assertEqual(invoice.creator, self.creator) + self.assertEqual(invoice.client, self.client) + self.assertEqual(invoice.items.count(), 2) + + # Total should be 2*10 + 1*5.5 = 25.5 + self.assertEqual(invoice.amount, Decimal("25.50")) + item_descriptions = [i.description for i in invoice.items.all()] + self.assertIn("Item A", item_descriptions) + self.assertIn("Item B", item_descriptions) \ No newline at end of file diff --git a/backend/modules/invoicing/tests/test_tasks.py b/backend/modules/invoicing/tests/test_tasks.py new file mode 100644 index 0000000..c20abbc --- /dev/null +++ b/backend/modules/invoicing/tests/test_tasks.py @@ -0,0 +1,40 @@ +import uuid +from django.test import TestCase +from unittest.mock import patch +from datetime import datetime +from django.contrib.auth import get_user_model + +from modules.crm.models import Client +from modules.invoicing import tasks + + +class TasksTests(TestCase): + def setUp(self): + # Kreiramo klijenta koji vraća PaperlessGateway; email jedinstven radi izbjegavanja duplikata + self.client = Client.objects.create( + name="Paperless Corp", + email=f"paperless+{uuid.uuid4()}@example.test" + ) + User = get_user_model() + # Kreiramo sistemskog korisnika koji već postoji (trebao bi se koristiti) + self.system_user = User.objects.create_user( + username="bot@erp.hr", + email="bot@erp.hr", + password="pass" + ) + + @patch("modules.invoicing.tasks.PaperlessGateway.get_document_metadata") + @patch("modules.invoicing.tasks.InvoiceService.create_full_invoice") + def test_process_paperless_document_calls_service(self, mock_create_full_invoice, mock_get_metadata): + # simuliramo odgovor infrastrukture + mock_get_metadata.return_value = { + "created": "2026-01-01T00:00:00Z", + "correspondent": "Paperless Corp", + "total_amount": "100.00" + } + + # Pozovemo task sinkrono pomoću .apply (ispravno mapira argumente) + tasks.process_paperless_document_task.apply(args=(123,)) + + # Očekujemo da je pozvan servis za izradu fakture + mock_create_full_invoice.assert_called() \ No newline at end of file diff --git a/backend/modules/invoicing/tests/test_utils.py b/backend/modules/invoicing/tests/test_utils.py new file mode 100644 index 0000000..4273b4c --- /dev/null +++ b/backend/modules/invoicing/tests/test_utils.py @@ -0,0 +1,28 @@ +from django.test import TestCase +from datetime import date +from decimal import Decimal + +from django.contrib.auth import get_user_model +from modules.invoicing.models import Invoice +from modules.invoicing.utils import generate_invoice_number +from modules.crm.models import Client + + +class UtilsTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username="creator", email="c@example.com", password="pass") + self.client = Client.objects.create(name="Client Utils") + + def test_generate_invoice_number_increments(self): + year = date.today().year + Invoice.objects.create( + invoice_number=f"INV-{year}-0001", + creator=self.user, + client=self.client, + amount=Decimal("1.00"), + due_date=date.today() + ) + next_num = generate_invoice_number() + # očekujemo INV--0002 + self.assertTrue(next_num.endswith("-0002")) \ No newline at end of file diff --git a/backend/modules/invoicing/tests/test_views.py b/backend/modules/invoicing/tests/test_views.py new file mode 100644 index 0000000..990d851 --- /dev/null +++ b/backend/modules/invoicing/tests/test_views.py @@ -0,0 +1,46 @@ +from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model +from unittest.mock import patch + +from modules.invoicing.views import InvoiceTransactionEntryViewSet +from modules.invoicing.serializers import InvoiceTransactionSerializer +from modules.invoicing.models import InvoiceTransaction +from modules.invoicing.models import Invoice +from modules.crm.models import Client +from datetime import date +from decimal import Decimal + + +class ViewsTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username="u1", email="u1@example.com", password="pass") + self.client = Client.objects.create(name="Client Views") + self.invoice = Invoice.objects.create( + invoice_number="INV-TEST-0001", + creator=self.user, + client=self.client, + amount=Decimal("10.00"), + due_date=date.today() + ) + self.factory = RequestFactory() + self.viewset = InvoiceTransactionEntryViewSet() + + @patch("modules.invoicing.services.InvoiceService.create_invoice_entry") + def test_perform_create_calls_service(self, mock_create_invoice_entry): + # simuliramo serializer.validated_data i poziv perform_create + serializer = InvoiceTransactionSerializer(data={ + "invoice": self.invoice.id, + "invoice_number": self.invoice.invoice_number, + "amount": "5.00", + "is_active": True + }, context={"request": self.factory.get("/")}) + serializer.is_valid(raise_exception=True) + + # Poziv perform_create koji bi trebao delegirati na servis + self.viewset.request = self.factory.post("/") + self.viewset.request.user = self.user + + # Ako kod poziva InvoiceService.create_invoice_entry, mock se aktivira. + self.viewset.perform_create(serializer) + mock_create_invoice_entry.assert_called() \ No newline at end of file diff --git a/backend/modules/invoicing/urls.py b/backend/modules/invoicing/urls.py new file mode 100644 index 0000000..ea35e15 --- /dev/null +++ b/backend/modules/invoicing/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter +from .views import InvoiceViewSet, InvoiceTransactionEntryViewSet + +router = DefaultRouter() +router.register(r'invoices', InvoiceViewSet, basename='invoice') +router.register(r'transaction', InvoiceTransactionEntryViewSet, basename='financial-transaction-entry') + +urlpatterns = router.urls \ No newline at end of file diff --git a/backend/modules/invoicing/utils.py b/backend/modules/invoicing/utils.py new file mode 100644 index 0000000..e98f2ba --- /dev/null +++ b/backend/modules/invoicing/utils.py @@ -0,0 +1,26 @@ +import datetime +from django.apps import apps +from django.db import transaction + +def generate_invoice_number(): + """ + Generira novi broj fakture u formatu INV-YYYY-XXXX. + Koristi transakciju kako bi se spriječilo dupliciranje brojeva. + """ + with transaction.atomic(): + Invoice = apps.get_model('invoicing', 'Invoice') + + current_year = datetime.date.today().year + + # Zaključavamo tablicu za čitanje kako bismo izbjegli Race Condition + last_invoice = Invoice.objects.filter( + invoice_number__startswith=f"INV-{current_year}-" + ).select_for_update().order_by('-invoice_number').first() + + if last_invoice: + last_num = int(last_invoice.invoice_number.split('-')[-1]) + new_num = last_num + 1 + else: + new_num = 1 + + return f"INV-{current_year}-{new_num:04d}" \ No newline at end of file diff --git a/backend/modules/invoicing/views.py b/backend/modules/invoicing/views.py new file mode 100644 index 0000000..e34a266 --- /dev/null +++ b/backend/modules/invoicing/views.py @@ -0,0 +1,75 @@ +# backend/modules/invoicing/views.py + +import logging +from rest_framework import viewsets, permissions, status +from rest_framework.response import Response +from rest_framework.exceptions import ValidationError + +from .models import InvoiceTransaction, Invoice +from .serializers import InvoiceSerializer, InvoiceTransactionSerializer +from .services import InvoiceService + +# Postavljanje loggera za modul +logger = logging.getLogger(__name__) + +class InvoiceViewSet(viewsets.ModelViewSet): + queryset = Invoice.objects.all().select_related('client', 'creator') + serializer_class = InvoiceSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_create(self, serializer): + validated_data = serializer.validated_data + + # 2. Delegiraj SVE servisu + from .services import InvoiceService + invoice_instance = InvoiceService.create_full_invoice( + user=self.request.user, + **validated_data + ) + + serializer.instance = invoice_instance + + def get_queryset(self): + """ + Filtriraj samo aktivne zapise + """ + # Korisnik vidi samo fakture koje je on kreirao + # (ili koje su povezane s njegovim profilom, ako si klijent) + if self.request.user.is_staff: + return Invoice.objects.all() + return Invoice.objects.filter(creator=self.request.user, is_active=True) + +class InvoiceTransactionEntryViewSet(viewsets.ModelViewSet): + """ + ViewSet za upravljanje financijskim zapisima. + Korištenjem InvoiceService-a, ViewSet ostaje lagan i fokusiran samo na HTTP sloj. + """ + queryset = InvoiceTransaction.objects.filter(is_active=True).select_related('invoice', 'work_order').order_by('-created_at') + serializer_class = InvoiceTransactionSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_create(self, serializer): + """ + Presrećemo proces kreiranja kako bismo koristili InvoiceService. + """ + try: + # Proslijeđujemo validirane podatke i korisnika u servis + InvoiceService.create_invoice_entry( + data=serializer.validated_data, + user=self.request.user + ) + except ValidationError as e: + # Ponovno podižemo iznimku kako bi DRF ispravno vratio 400 Bad Request + logger.warning(f"Validacijska greška pri kreiranju računa od strane {self.request.user}: {e}") + raise e + except Exception as e: + logger.error(f"Kritična greška pri kreiranju financijskog zapisa: {str(e)}", exc_info=True) + raise ValidationError({"error": "Došlo je do greške prilikom spremanja zapisa."}) + + def perform_destroy(self, instance): + """ + Umjesto fizičkog brisanja, radimo soft-delete ako je potrebno. + """ + instance.is_active = False + instance.save() + logger.info(f"Financijski zapis {instance.id} je deaktiviran od strane {self.request.user}") \ No newline at end of file diff --git a/backend/modules/services.py b/backend/modules/services.py new file mode 100644 index 0000000..8d42dc1 --- /dev/null +++ b/backend/modules/services.py @@ -0,0 +1,70 @@ +from rest_framework.exceptions import ValidationError +from django.db import transaction +from .models import Vehicle, WorkOrder, VehicleServiceRecord +from decimal import Decimal + + +class VehicleService: + @staticmethod + def create_vehicle(data: dict) -> Vehicle: + vehicle = Vehicle(**data) + try: + vehicle.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + + vehicle.save() + return vehicle + + @staticmethod + def update_vehicle(instance: Vehicle, data: dict) -> Vehicle: + for k, v in data.items(): + setattr(instance, k, v) + try: + instance.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + instance.save() + return instance + + @staticmethod + def create_work_order(data: dict) -> WorkOrder: + # expecting creator in data + wo = WorkOrder(**data) + try: + wo.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + + # save will handle updating vehicle mileage if end_mileage set + with transaction.atomic(): + wo.save() + return wo + + @staticmethod + def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder: + for k, v in data.items(): + setattr(instance, k, v) + try: + instance.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + with transaction.atomic(): + instance.save() + return instance + + @staticmethod + def create_service_record(data: dict) -> VehicleServiceRecord: + rec = VehicleServiceRecord(**data) + try: + rec.full_clean() + except Exception as e: + raise ValidationError({"detail": str(e)}) + with transaction.atomic(): + rec.save() + # optionally update vehicle.current_mileage if rec.mileage > current + v = rec.vehicle + if rec.mileage and rec.mileage > (v.current_mileage or 0): + v.current_mileage = rec.mileage + v.save() + return rec \ No newline at end of file diff --git a/backend/modules/task_management/__init__.py b/backend/modules/task_management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/task_management/admin.py b/backend/modules/task_management/admin.py new file mode 100644 index 0000000..b049937 --- /dev/null +++ b/backend/modules/task_management/admin.py @@ -0,0 +1,21 @@ +# backend/modules/task_management/admin.py + +from django.contrib import admin +from .models import Task + +@admin.register(Task) +class TaskAdmin(admin.ModelAdmin): + """ + Konfiguracija prikaza modela u Admin panelu. + """ + # Koja polja želimo vidjeti u tabličnom prikazu + list_display = ('title', 'status', 'assigned_to', 'work_order', 'created_at') + + # Omogućuje filtriranje s desne strane + list_filter = ('status', 'created_at', 'work_order') + + # Omogućuje pretraživanje po tekstu + search_fields = ('title', 'description', 'work_order__id', 'work_order__vehicle__registration_number') + + # Polja koja su samo za čitanje (npr. UUID koji se ne smije mijenjati) + readonly_fields = ('id', 'created_at', 'updated_at') \ No newline at end of file diff --git a/backend/modules/task_management/apps.py b/backend/modules/task_management/apps.py new file mode 100644 index 0000000..73b93ac --- /dev/null +++ b/backend/modules/task_management/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TaskManagementAppConfig(AppConfig): + name = 'modules.task_management' diff --git a/backend/modules/task_management/migrations/0001_initial.py b/backend/modules/task_management/migrations/0001_initial.py new file mode 100644 index 0000000..082d82e --- /dev/null +++ b/backend/modules/task_management/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.15 on 2026-06-29 13:34 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Task', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')), + ('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('status', models.CharField(choices=[('aktivan', 'Aktivan'), ('servis', 'U tijeku'), ('neaktivan', 'Otkazano')], default='aktivan', max_length=20)), + ('assigned_to', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tasks', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/modules/task_management/migrations/0002_task_work_order.py b/backend/modules/task_management/migrations/0002_task_work_order.py new file mode 100644 index 0000000..9671ba2 --- /dev/null +++ b/backend/modules/task_management/migrations/0002_task_work_order.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.15 on 2026-07-07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0012_workorder_purpose_choices'), + ('task_management', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='work_order', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='tasks', + to='fleet.workorder', + ), + ), + ] + diff --git a/backend/modules/task_management/migrations/0003_task_vehicle.py b/backend/modules/task_management/migrations/0003_task_vehicle.py new file mode 100644 index 0000000..d0211d1 --- /dev/null +++ b/backend/modules/task_management/migrations/0003_task_vehicle.py @@ -0,0 +1,37 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0013_vehicleservicerecord_task'), + ('task_management', '0002_task_work_order'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='vehicle', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='tasks', + to='fleet.vehicle', + verbose_name='Dizalica', + ), + ), + migrations.AlterField( + model_name='task', + name='work_order', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='work_order_tasks', + to='fleet.workorder', + verbose_name='Putni nalog', + ), + ), + ] diff --git a/backend/modules/task_management/migrations/__init__.py b/backend/modules/task_management/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/modules/task_management/models.py b/backend/modules/task_management/models.py new file mode 100644 index 0000000..cb7d8e2 --- /dev/null +++ b/backend/modules/task_management/models.py @@ -0,0 +1,59 @@ +# backend/modules/task_management/models.py + +from django.db import models +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from core.base_models import BaseModel # Uvozimo naš Base + +class Task(BaseModel): + """ + Modularni Task model nasljeđuje sva polja iz BaseModel. + """ + STATUS_CHOICES = [ + ('aktivan', 'Aktivan'), + ('servis', 'U tijeku'), + ('neaktivan', 'Otkazano'), + ] + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='aktivan' + ) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name='tasks' + ) + vehicle = models.ForeignKey( + 'fleet.Vehicle', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='tasks', + verbose_name=_("Dizalica"), + ) + work_order = models.ForeignKey( + 'fleet.WorkOrder', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='work_order_tasks', + verbose_name=_("Putni nalog"), + ) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"{self.title} ({self.status})" + + def clean(self): + super().clean() + if self.status == 'neaktivan' and not self.work_order_id: + raise ValidationError({ + 'work_order': "Putni nalog je obavezan prije zatvaranja zadatka." + }) \ No newline at end of file diff --git a/backend/modules/task_management/serializers.py b/backend/modules/task_management/serializers.py new file mode 100644 index 0000000..b74f3b7 --- /dev/null +++ b/backend/modules/task_management/serializers.py @@ -0,0 +1,75 @@ +# backend/modules/task_management/serializers.py + +from rest_framework import serializers +from .models import Task + +class TaskSerializer(serializers.ModelSerializer): + assigned_to_name = serializers.SerializerMethodField() + work_order_label = serializers.SerializerMethodField() + vehicle_registration = serializers.SerializerMethodField() + + class Meta: + model = Task + fields = [ + 'id', 'title', 'description', 'status', + 'assigned_to', 'assigned_to_name', + 'vehicle', 'vehicle_registration', + 'work_order', 'work_order_label', + 'created_at', + ] + read_only_fields = ['id', 'created_at'] + + def validate_title(self, value): + """Primjer custom validacije: naziv ne smije biti prekratak.""" + if len(value) < 3: + raise serializers.ValidationError("Naslov zadatka mora imati barem 3 znaka.") + return value + + def validate(self, attrs): + current = getattr(self, 'instance', None) + status_value = attrs.get('status', getattr(current, 'status', 'aktivan')) + work_order = attrs.get('work_order', getattr(current, 'work_order', None)) + vehicle = attrs.get('vehicle', getattr(current, 'vehicle', None)) + + if current is None and not vehicle: + raise serializers.ValidationError({ + 'vehicle': "Dizalica je obavezna pri kreiranju taska." + }) + + if work_order and not vehicle: + vehicle = work_order.vehicle + attrs['vehicle'] = vehicle + if work_order and vehicle and work_order.vehicle_id != vehicle.id: + raise serializers.ValidationError({ + 'work_order': "Putni nalog mora pripadati istoj dizalici kao task." + }) + + if status_value == 'neaktivan' and not work_order: + raise serializers.ValidationError({ + 'work_order': "Putni nalog je obavezan prije zatvaranja zadatka." + }) + return attrs + + def get_work_order_label(self, obj): + if not obj.work_order_id: + return None + return f"PN-{str(obj.work_order_id).split('-')[0].upper()}" + + def get_assigned_to_name(self, obj): + if not obj.assigned_to_id: + return None + full_name = obj.assigned_to.get_full_name() + if full_name: + return full_name + email = getattr(obj.assigned_to, 'email', None) + if email: + return email + username = getattr(obj.assigned_to, 'username', None) + if username: + return username + return str(obj.assigned_to_id) + + def get_vehicle_registration(self, obj): + if not obj.vehicle_id: + return None + return obj.vehicle.registration_number \ No newline at end of file diff --git a/backend/modules/task_management/services.py b/backend/modules/task_management/services.py new file mode 100644 index 0000000..4c7dd91 --- /dev/null +++ b/backend/modules/task_management/services.py @@ -0,0 +1,48 @@ +# backend/modules/task_management/services.py + +from .models import Task +from rest_framework.exceptions import ValidationError +from django.core.exceptions import ValidationError as DjangoValidationError +import logging + +logger = logging.getLogger(__name__) + +class TaskService: + @staticmethod + def create_task_entry(data: dict, user=None) -> Task: + """ + Univerzalna metoda: prihvaća podatke i opcionalnog korisnika. + """ + # Poslovna pravila + if data.get('status') == 'DONE' and (user and not user.is_staff): + raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."}) + + payload = dict(data) + if user and 'assigned_to' not in payload: + payload['assigned_to'] = user + + entry = Task(**payload) + try: + entry.full_clean() + except DjangoValidationError as exc: + logger.error(f"Neuspješna validacija zadatka: {exc}") + raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages) + entry.save() + logger.info(f"Zadatak uspješno kreiran: {entry.id}") + return entry + + @staticmethod + def update_task_entry(instance, data, user): + # Validacija za update (npr. samo vlasnik ili admin smije ažurirati) + if not user.is_staff and instance.assigned_to != user: + raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.") + + # Spremanje + for attr, value in data.items(): + setattr(instance, attr, value) + try: + instance.full_clean() + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages) + instance.save() + return instance \ No newline at end of file diff --git a/backend/modules/task_management/tasks.py b/backend/modules/task_management/tasks.py new file mode 100644 index 0000000..58eb627 --- /dev/null +++ b/backend/modules/task_management/tasks.py @@ -0,0 +1,37 @@ +# backend/modules/task_management/tasks.py + +from celery import shared_task +from .models import Task +from django.contrib.auth import get_user_model +from modules.fleet.models import Vehicle, WorkOrder + +@shared_task +def create_task_from_invoice(invoice_data): + """ + Ovaj zadatak se poziva asinkrono. + """ + User = get_user_model() + # Logika: dodijeli zadatak adminu ako je račun veći od 1000 + amount = float(invoice_data.get('amount', 0)) + admin_user = User.objects.filter(is_staff=True).first() + work_order = None + vehicle = None + work_order_id = invoice_data.get('work_order_id') + vehicle_id = invoice_data.get('vehicle_id') + + if work_order_id: + work_order = WorkOrder.objects.filter(pk=work_order_id).first() + if work_order: + vehicle = work_order.vehicle + if vehicle is None and vehicle_id: + vehicle = Vehicle.objects.filter(pk=vehicle_id).first() + + if amount > 1000: + Task.objects.create( + title=f"Provjera računa: {invoice_data['invoice_number']}", + description=f"Račun iznosi {amount}, potrebna revizija.", + assigned_to=admin_user, + status='aktivan', + vehicle=vehicle, + work_order=work_order, + ) \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_models.py b/backend/modules/task_management/tests/test_models.py new file mode 100644 index 0000000..821cfb2 --- /dev/null +++ b/backend/modules/task_management/tests/test_models.py @@ -0,0 +1,12 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model + +from modules.task_management.models import Task + + +class TaskModelTests(TestCase): + def test_str_returns_title_and_status(self): + user = get_user_model().objects.create_user(username="u1", email="u1@example.test", password="pass") + task = Task.objects.create(title="My Task", description="Desc", assigned_to=user) + assert str(task).startswith("My Task") + assert "aktivan" in str(task) or "(" in str(task) # basic sanity \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_serializers.py b/backend/modules/task_management/tests/test_serializers.py new file mode 100644 index 0000000..444120f --- /dev/null +++ b/backend/modules/task_management/tests/test_serializers.py @@ -0,0 +1,32 @@ +from django.test import TestCase +from modules.task_management.serializers import TaskSerializer +from rest_framework.exceptions import ValidationError +from django.contrib.auth import get_user_model +from modules.task_management.models import Task +from modules.fleet.models import Vehicle, WorkOrder + + +class TaskSerializerTests(TestCase): + def test_validate_title_too_short_raises(self): + serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan"}) + with self.assertRaises(ValidationError): + serializer.is_valid(raise_exception=True) + + def test_validate_title_ok(self): + serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan"}) + assert serializer.is_valid(), serializer.errors + + def test_cannot_close_without_work_order(self): + serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan"}) + with self.assertRaises(ValidationError): + serializer.is_valid(raise_exception=True) + + def test_can_close_with_work_order(self): + User = get_user_model() + user = User.objects.create_user(username="task-user", email="task-user@example.test", password="pass") + vehicle = Vehicle.objects.create(registration_number="ZG-TSK-02", asset_type='crane') + work_order = WorkOrder.objects.create(vehicle=vehicle, creator=user, purpose='defektaza') + task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=user) + + serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True) + assert serializer.is_valid(), serializer.errors \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_services.py b/backend/modules/task_management/tests/test_services.py new file mode 100644 index 0000000..179591f --- /dev/null +++ b/backend/modules/task_management/tests/test_services.py @@ -0,0 +1,53 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.exceptions import ValidationError +from modules.task_management.services import TaskService +from modules.task_management.models import Task +from modules.fleet.models import Vehicle, WorkOrder + + +class TaskServiceTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username="user1", email="u1@example.test", password="pass") + self.other_user = User.objects.create_user(username="user2", email="u2@example.test", password="pass") + self.staff = User.objects.create_user(username="admin", email="admin@example.test", password="pass", is_staff=True) + + def test_create_task_entry_creates_task_and_assigns_user(self): + data = {"title": "Do work", "description": "Something", "status": "aktivan"} + entry = TaskService.create_task_entry(data=data, user=self.user) + assert isinstance(entry, Task) + assert entry.assigned_to == self.user + assert entry.title == "Do work" + + def test_create_task_entry_short_title_raises(self): + data = {"title": "x", "description": "", "status": "aktivan"} + with self.assertRaises(ValidationError): + TaskService.create_task_entry(data=data, user=self.user) + + def test_update_task_entry_permission_denied_for_non_owner(self): + task = Task.objects.create(title="T", description="", assigned_to=self.other_user) + with self.assertRaises(ValidationError): + TaskService.update_task_entry(instance=task, data={"title": "New"}, user=self.user) + + def test_update_task_entry_allowed_for_owner(self): + task = Task.objects.create(title="Old", description="", assigned_to=self.user) + updated = TaskService.update_task_entry(instance=task, data={"title": "New"}, user=self.user) + assert updated.title == "New" + + def test_update_task_entry_rejects_closing_without_work_order(self): + task = Task.objects.create(title="Za zatvaranje", description="", assigned_to=self.user, status='servis') + with self.assertRaises(ValidationError): + TaskService.update_task_entry(instance=task, data={"status": "neaktivan"}, user=self.user) + + def test_update_task_entry_allows_closing_with_work_order(self): + vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01", asset_type='crane') + work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='defektaza') + task = Task.objects.create(title="Za zatvaranje", description="", assigned_to=self.user, status='servis') + updated = TaskService.update_task_entry( + instance=task, + data={"status": "neaktivan", "work_order": work_order}, + user=self.user, + ) + assert updated.status == "neaktivan" + assert updated.work_order == work_order \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_tasks.py b/backend/modules/task_management/tests/test_tasks.py new file mode 100644 index 0000000..8d705b3 --- /dev/null +++ b/backend/modules/task_management/tests/test_tasks.py @@ -0,0 +1,25 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from modules.task_management.tasks import create_task_from_invoice +from modules.task_management.models import Task + + +class TaskCeleryTests(TestCase): + def setUp(self): + User = get_user_model() + # Create an admin user to be assigned the task + self.admin = User.objects.create_user(username="admin", email="admin@example.test", password="pass", is_staff=True) + + def test_create_task_from_invoice_creates_task_for_large_amount(self): + invoice_data = {"amount": 1500, "invoice_number": "INV-100"} + # run the task function synchronously via .run (no bind) + create_task_from_invoice.run(invoice_data) + task = Task.objects.filter(title__icontains="INV-100").first() + assert task is not None + assert task.assigned_to == self.admin + + def test_create_task_from_invoice_does_not_create_for_small_amount(self): + invoice_data = {"amount": 100, "invoice_number": "INV-101"} + create_task_from_invoice.run(invoice_data) + task = Task.objects.filter(title__icontains="INV-101").first() + assert task is None \ No newline at end of file diff --git a/backend/modules/task_management/tests/test_views.py b/backend/modules/task_management/tests/test_views.py new file mode 100644 index 0000000..d8dd4f6 --- /dev/null +++ b/backend/modules/task_management/tests/test_views.py @@ -0,0 +1,54 @@ +from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model +from unittest.mock import patch + +from modules.task_management.views import TaskViewSet +from modules.task_management.serializers import TaskSerializer +from modules.task_management.models import Task + + +class TaskViewSetTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username="u1", email="u1@example.test", password="pass") + self.factory = RequestFactory() + self.view = TaskViewSet() + self.view.request = self.factory.post("/") + self.view.request.user = self.user + + @patch("modules.task_management.views.TaskService.create_task_entry") + def test_perform_create_calls_service(self, mock_create): + data = {"title": "Task from view", "description": "", "status": "aktivan"} + serializer = TaskSerializer(data=data, context={"request": self.view.request}) + assert serializer.is_valid(), serializer.errors + + # Call perform_create, which should delegate to TaskService.create_task_entry + self.view.perform_create(serializer) + mock_create.assert_called_once() + args, kwargs = mock_create.call_args + assert "data" in kwargs and kwargs["user"] == self.user + + @patch("modules.task_management.views.TaskService.update_task_entry") + def test_perform_update_calls_service_and_saves(self, mock_update): + # Create an instance and use serializer to update + task = Task.objects.create(title="Old", description="", assigned_to=self.user) + data = {"title": "Updated", "description": "new", "status": "aktivan"} + serializer = TaskSerializer(instance=task, data=data, partial=True, context={"request": self.view.request}) + assert serializer.is_valid(), serializer.errors + + # attach view.request and instance then call perform_update + self.view.request = self.factory.put("/") + self.view.request.user = self.user + serializer.instance = task + + self.view.perform_update(serializer) + mock_update.assert_called_once_with(instance=task, data=serializer.validated_data, user=self.view.request.user) + # confirm instance saved with new title + task.refresh_from_db() + assert task.title == "Updated" + + def test_perform_destroy_soft_deletes(self): + task = Task.objects.create(title="ToDelete", description="", assigned_to=self.user) + self.view.perform_destroy(task) + task.refresh_from_db() + assert task.is_active is False \ No newline at end of file diff --git a/backend/modules/task_management/urls.py b/backend/modules/task_management/urls.py new file mode 100644 index 0000000..49ecc8e --- /dev/null +++ b/backend/modules/task_management/urls.py @@ -0,0 +1,9 @@ +from rest_framework.routers import DefaultRouter +from .views import TaskViewSet + +router = DefaultRouter() +# Registriramo ViewSet pod putanjom 'tasks' +# Rezultat: /api/task_management/tasks/ +router.register(r'tasks', TaskViewSet, basename='task') + +urlpatterns = router.urls \ No newline at end of file diff --git a/backend/modules/task_management/views.py b/backend/modules/task_management/views.py new file mode 100644 index 0000000..3c402d0 --- /dev/null +++ b/backend/modules/task_management/views.py @@ -0,0 +1,64 @@ +# backend/modules/task_management/views.py + +import logging +from rest_framework import viewsets, permissions +from rest_framework.exceptions import ValidationError +from .models import Task +from .serializers import TaskSerializer +from .services import TaskService + +# Postavljanje loggera za modul +logger = logging.getLogger(__name__) + +class TaskViewSet(viewsets.ModelViewSet): + """ + ViewSet koji koristi TaskService za rukovanje operacijama. + """ + serializer_class = TaskSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + qs = Task.objects.filter(is_active=True).select_related( + 'assigned_to', 'vehicle', 'work_order', 'work_order__vehicle' + ) + vehicle_id = self.request.query_params.get('vehicle') + if vehicle_id: + qs = qs.filter(vehicle_id=vehicle_id) + return qs + + def perform_create(self, serializer): + """ + Umjesto direktnog serializer.save(), pozivamo servis. + Proslijeđujemo validirane podatke (dict) i korisnika. + """ + try: + entry = TaskService.create_task_entry( + data=serializer.validated_data, + user=self.request.user) + serializer.instance = entry + except ValidationError as e: + # Ponovno podižemo iznimku kako bi DRF ispravno vratio 400 Bad Request + logger.warning(f"Validacijska greška pri kreiranju zadatka od strane {self.request.user}: {e}") + raise e + + def perform_update(self, serializer): + """ + Servis obavlja biznis logiku (validaciju/modifikaciju), + a serializer.save() finalizira zapis. + """ + # Servis sada samo modificira instancu u memoriji + TaskService.update_task_entry( + instance=serializer.instance, + data=serializer.validated_data, + user=self.request.user + ) + # Sada DRF sigurno sprema izmijenjenu instancu + serializer.save() + + def perform_destroy(self, instance): + """ + Umjesto fizičkog brisanja, radimo soft-delete ako je potrebno. + """ + instance.is_active = False + instance.save() + logger.info(f"Zapis zadatka {instance.id} je deaktiviran od strane {self.request.user}") diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..cce1585 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = core.settings +python_files = tests.py test_*.py *_tests.py \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f2660a3 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,75 @@ +amqp==5.3.1 +annotated-types==0.7.0 +anyio==4.13.0 +asgiref==3.11.1 +billiard==4.2.4 +celery==5.6.3 +certifi==2026.5.20 +cffi==2.0.0 +charset-normalizer==3.4.7 +click==8.4.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +colorama==0.4.6 +cryptography==49.0.0 +defusedxml==0.7.1 +deprecation==2.1.0 +Django==5.2.15 +django-cors-headers==4.9.0 +django-debug-toolbar>=4.0.0 +django_extensions>=4.1 +django-filter==25.2 +django-redis==7.0.0 +django-unfold>=0.100.0 +djangorestframework==3.17.1 +djangorestframework_simplejwt==5.5.1 +Faker==40.15.0 +flower==2.0.1 +fonttools==4.63.0 +fpdf2==2.8.7 +gunicorn==26.0.0 +idna==3.17 +img2pdf==0.6.3 +ocrmypdf==17.7.0 +opencv-python-headless>=4.10.0.82,<4.13.0 +packaging==26.2 +pdfminer.six==20260107 +pi_heif==1.4.0 +pikepdf==10.9.1 +pillow==12.2.0 +psycopg2-binary==2.9.12 +pycparser==3.0 +pydantic==2.13.4 +pydantic_core==2.46.4 +Pygments==2.20.0 +PyJWT==2.12.1 +pypdf==6.13.3 +pypdfium2==5.10.1 +pytesseract==0.3.13 +pytest>=9.1.1 +pytest-django>=4.12.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +pusher>=3.3.4 +redis==8.0.0 +reportlab==4.5.1 +requests==2.34.2 +rich==15.0.0 +six==1.17.0 +sqlparse==0.5.5 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2026.2 +tzlocal==5.3.1 +uharfbuzz==0.55.0 +urllib3==2.7.0 +vine==5.1.0 +watchfiles==1.2.0 +wcwidth==0.7.0 +whitenoise==6.12.0 +# pinned versions for TF + numpy compatibility +numpy>=1.24,<2.0 +tensorflow==2.15.0 +keras-ocr +albumentations \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..9e1d0c7 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,51 @@ +services: + backend: + container_name: 004erpbackend + build: + context: ./backend + dockerfile: Dockerfile + command: python manage.py runserver 0.0.0.0:8000 + user: "1000:1000" + volumes: + - ./backend:/app + - media_volume:/app/media + ports: + - "8001:8000" + env_file: + - .env + depends_on: + db: { condition: service_healthy } + redis: { condition: service_started } + + frontend: + container_name: 004erpfrontend + build: + context: ./frontend + dockerfile: Dockerfile + command: npm run dev -- --host 0.0.0.0 --force + ports: + - "4321:4321" + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - PUBLIC_API_URL=${PUBLIC_API_URL} + - NODE_ENV=development + + flower: + container_name: 004erpflower + build: + context: ./backend + dockerfile: Dockerfile + command: celery -A core flower --port=5555 + ports: + - "5555:5555" + env_file: + - .env + depends_on: + - redis + - worker + +volumes: + media_volume: + external: true # Ako je definiran u glavnom file-u, ovo ga povezuje \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1608c2c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + db: + image: postgres:16-alpine + container_name: 004erpdb + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + volumes: [postgres_data:/var/lib/postgresql/data] + + redis: + image: redis:7-alpine + container_name: 004erpredis + volumes: [redis_data:/data] + # Lean optimizacija: Ograničavamo maksimalnu potrošnju memorije samog Redisa na 100MB + # i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe. + command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru + + worker: + container_name: 004erpworker + build: + context: ./backend + dockerfile: Dockerfile + command: celery -A core worker --loglevel=info + user: "1000:1000" + volumes: + - ./backend:/app + - media_volume:/app/media + env_file: + - .env + depends_on: + db: { condition: service_healthy } + redis: { condition: service_started } + deploy: + resources: + limits: + memory: 512M + +volumes: + postgres_data: + redis_data: + media_volume: # Ovo je ključno za tvoj ERP \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..79a31e2 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,32 @@ +# Izolacija paketa (Kritično) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Astro i Build cache (Sprječava prijenos starih lokalnih buildova) +.astro/ +dist/ + +# Okruženje i Tajne (Sigurnost) +.env +.env.production +.env.development +.env.local +*.env.bak + +# Kontrola verzija i Git +.git/ +.gitignore + +# Docker higijena (Optimizacija cache slojeva) +Dockerfile +.dockerignore +docker-compose*.yml + +# Operacijski sustav i Editori +.DS_Store +Thumbs.db +.vscode/ +.idea/ \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..831e56c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +# Koristimo stabilnu Node.js sliku +FROM node:22-alpine + +WORKDIR /app + +# Kopiramo datoteke koje definiraju pakete +COPY package*.json ./ + +# Instaliramo ovisnosti (u dev modu Compose će ionako povući sve što treba) +RUN npm install + +# Kopiramo ostatak izvornog koda frontenda +COPY . . + +# Otvaramo port 4321 na kojem će se vrtjeti Astro +EXPOSE 4321 + +# Zadana naredba (koju tvoj docker-compose.dev.yml ionako prepisuje, +# ali je važno da je ovdje ispravan i čist fallback) +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "4321"] \ No newline at end of file diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs new file mode 100644 index 0000000..3a24e66 --- /dev/null +++ b/frontend/astro.config.mjs @@ -0,0 +1,62 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; +import tailwindcss from '@tailwindcss/vite'; +import preact from '@astrojs/preact'; +const isDev = process.env.NODE_ENV === 'development'; + +// https://astro.build/config +export default defineConfig({ + output: 'server', + + adapter: node({ + mode: 'standalone' + }), + + // 🛰️ Postavke razvojnog poslužitelja unutar Docker mreže + server: { + host: true, // Sluša na 0.0.0.0 unutar kontejnera + port: 4321, // Unutarnji port kontejnera + }, + + vite: { + resolve: { + alias: { + react: 'preact/compat', + 'react-dom': 'preact/compat', + 'react-dom/test-utils': 'preact/test-utils', + 'react/jsx-runtime': 'preact/jsx-runtime', + 'react/jsx-dev-runtime': 'preact/jsx-dev-runtime', + }, + }, + define: { + __PUSHER_KEY__: JSON.stringify(process.env.PUBLIC_PUSHER_KEY || process.env.PUSHER_KEY || ''), + __PUSHER_CLUSTER__: JSON.stringify(process.env.PUBLIC_PUSHER_CLUSTER || process.env.PUSHER_CLUSTER || ''), + }, + ssr: { + noExternal: ['@react-spring/web', '@react-spring/core', '@react-spring/animated', '@react-spring/shared'], + }, + build: { + assetsInlineLimit: 1024, + }, + plugins: [tailwindcss()], + + // ⚙️ Ugrađene preporuke za Docker File Watching i Tihi Refresh (HMR) + server: isDev ? { + hmr: { + protocol: 'ws', + host: 'localhost', + // Mora odgovarati mapiranom portu u docker-compose (4321:4321) + clientPort: 4321, + }, + watch: { + // Prisiljava Vite na "polling" sustav jer Windows/WSL2 volumeni nekad blokiraju inotify signale + usePolling: true, + interval: 100, + } + } : {}, // U produkciji je ovaj blok potpuno prazan radi maksimalnih performansi + }, + + // Redoslijed integracija: Tailwind pa Preact + integrations: [preact()], +}); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..191e81f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5977 @@ +{ + "name": "frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "version": "0.0.1", + "dependencies": { + "@astrojs/node": "^10.1.2", + "@astrojs/preact": "^5.1.4", + "@nanostores/preact": "^1.1.0", + "@preact/signals": "^2.9.2", + "@react-spring/web": "^10.1.2", + "@tailwindcss/vite": "^4.3.0", + "astro": "^6.4.2", + "flowbite": "^4.0.2", + "idb": "^8.0.3", + "nanostores": "^1.3.0", + "preact": "^10.29.2", + "pusher-js": "^8.4.0", + "tailwindcss": "^4.3.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@astrojs/compiler": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.10.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/markdown-remark": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/node": { + "version": "10.1.4", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.0", + "send": "^1.2.1", + "server-destroy": "^1.0.1" + }, + "peerDependencies": { + "astro": "^6.3.0" + } + }, + "node_modules/@astrojs/preact": { + "version": "5.1.5", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.0", + "@preact/preset-vite": "^2.10.5", + "@preact/signals": "^2.8.2", + "devalue": "^5.8.1", + "preact-render-to-string": "^6.6.6", + "vite": "^7.3.2" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "preact": "^10.6.5" + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.2", + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0", + "dset": "^3.1.4", + "is-docker": "^4.0.0", + "is-wsl": "^3.1.1", + "which-pm-runs": "^1.1.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.4.2", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.6.0", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.2", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nanostores/preact": { + "version": "1.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "nanostores": "^1.2.0", + "preact": ">=10.0.0" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.10.5", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@prefresh/vite": "^2.4.11", + "@rollup/pluginutils": "^5.0.0", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.4.3", + "magic-string": "^0.30.21", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.8", + "zimmerframe": "^1.1.4" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/@preact/signals": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-2.9.2.tgz", + "integrity": "sha512-DvFPISNMSh3vPqRwPa1tAVAHl85aDq4pTyNu1bTGfrKr64F3EOCHjdUl9aUdohKBf1v9PRGLYuGFcJpfztkdoQ==", + "license": "MIT", + "dependencies": { + "@preact/signals-core": "^1.14.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact": ">= 10.25.0 || >=11.0.0-0" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.14.3", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.3", + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.10", + "license": "MIT", + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.2", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@react-spring/animated": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.1.2.tgz", + "integrity": "sha512-yAsQ/bbp6+vko7WNCI1M00c6KLE9XKTGCrgRhQqS4JcK3oF5qBV4rHYrQEprvEvYXxt+1H5FsLS2eVValEPfFw==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~10.1.2", + "@react-spring/types": "~10.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.1.2.tgz", + "integrity": "sha512-lPGOAg0V+PV3ucopOajD+YCxoe8twALqAeG4c/+sqVjBsyUNNdx8qfz/DcNSPKA8PV3+AyQELlCxlDfap9cmBQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.1.2", + "@react-spring/shared": "~10.1.2", + "@react-spring/types": "~10.1.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.1.2.tgz", + "integrity": "sha512-KC6vSFZyPnRJ2rXqipV9QqR4SaYbYXjvKfdYKWipNK2mWuV79gr20WmpVUOsTiEHGRY3WSOdWCHN+P9Pdaqb7Q==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.1.2.tgz", + "integrity": "sha512-47/8bNQ/o0uEmxEnPBuERlC29VSqiTn5P9ln9tUMSVkYKYxBtouYE686F5kAGj+eHRPoNCw7drxWE9nv2d1LMw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.1.2", + "@react-spring/types": "~10.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.1.2.tgz", + "integrity": "sha512-G4CWowmVPz+rDG1y9QRVq/prZNxiNwQaHC0kgT/MgY6jAGgdRgTV4VThpvJDwWvUitk3xZB4soP4d36fjeQ09g==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.1.2.tgz", + "integrity": "sha512-KxDB3zaDqy9qFsu7fdxjyraAxweHH4k5TW5WGT/OuMK6hKaxSDfhrQfRBzfFaSVvMBf+NghbJFanllV4ia6i7A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.1.2", + "@react-spring/core": "~10.1.2", + "@react-spring/shared": "~10.1.2", + "@react-spring/types": "~10.1.2", + "csstype": "^3.2.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.3.0", + "@shikijs/types": "4.3.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.2", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "postcss": "^8.5.15", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "license": "ISC" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astro": { + "version": "6.4.8", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^4.0.0", + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/markdown-remark": "7.2.0", + "@astrojs/telemetry": "3.3.2", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", + "@oslojs/encoding": "^1.1.0", + "@rollup/pluginutils": "^5.3.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^1.1.1", + "devalue": "^5.8.1", + "diff": "^8.0.3", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.27.3", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", + "magic-string": "^0.30.21", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^0.6.18", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.4", + "rehype": "^13.0.2", + "semver": "^7.7.4", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "svgo": "^4.0.1", + "tinyclip": "^0.1.12", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.4", + "unist-util-visit": "^5.1.0", + "unstorage": "^1.17.5", + "vfile": "^6.0.3", + "vite": "^7.3.2", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.3.6" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0" + } + }, + "node_modules/astro/node_modules/semver": { + "version": "7.8.5", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.41", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.28.4", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.385", + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/flowbite": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.3", + "flowbite-datepicker": "^2.0.0", + "mini-svg-data-uri": "^1.4.3", + "postcss": "^8.5.1", + "tailwindcss": "^4.1.12" + } + }, + "node_modules/flowbite-datepicker": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@rollup/plugin-node-resolve": "^15.2.3", + "@tailwindcss/postcss": "^4.1.17" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "4.0.0", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "license": "MIT" + }, + "node_modules/kolorist": { + "version": "1.8.0", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nanostores": { + "version": "1.4.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/neotraverse": { + "version": "0.6.18", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "license": "MIT" + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-mock-http": { + "version": "1.0.4", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.50", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.0", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.0", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.7.0", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/piccolore": { + "version": "0.1.3", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.3", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.7.0", + "license": "MIT", + "peerDependencies": { + "preact": ">=10 || >= 11.0.0-0" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/pusher-js": { + "version": "8.5.0", + "license": "MIT", + "dependencies": { + "tweetnacl": "^1.0.3" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/server-destroy": { + "version": "1.0.1", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shiki": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.3.0", + "@shikijs/engine-javascript": "4.3.0", + "@shikijs/engine-oniguruma": "4.3.0", + "@shikijs/langs": "4.3.0", + "@shikijs/themes": "4.3.0", + "@shikijs/types": "4.3.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/simple-code-frame": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "license": "MIT" + }, + "node_modules/smol-toml": { + "version": "1.7.0", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "0.1.15", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "license": "Unlicense" + }, + "node_modules/ufo": { + "version": "1.6.4", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.6.0", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.4", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ofetch": "^1.5.1", + "ohash": "^2.0.11" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.5", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/unstorage/node_modules/lru-cache": { + "version": "11.5.1", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.13", + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "license": "ISC" + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..de900d1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "", + "type": "module", + "version": "0.0.1", + "engines": { + "node": ">=22.12.0", + "npm": ">=11.12.0" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "overrides": { + "vite": "^7.1.0" + }, + "dependencies": { + "@astrojs/node": "^10.1.2", + "@astrojs/preact": "^5.1.4", + "@nanostores/preact": "^1.1.0", + "@preact/signals": "^2.9.2", + "@react-spring/web": "^10.1.2", + "@tailwindcss/vite": "^4.3.0", + "astro": "^6.4.2", + "flowbite": "^4.0.2", + "idb": "^8.0.3", + "nanostores": "^1.3.0", + "preact": "^10.29.2", + "pusher-js": "^8.4.0", + "tailwindcss": "^4.3.0" + } +} diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..8af5f58 --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "ERP Generic", + "short_name": "ERP", + "description": "Offline-capable ERP dashboard for field service teams", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f9fafb", + "theme_color": "#4f46e5", + "lang": "hr", + "icons": [ + { + "src": "/pwa-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/frontend/public/offline.html b/frontend/public/offline.html new file mode 100644 index 0000000..e4b786c --- /dev/null +++ b/frontend/public/offline.html @@ -0,0 +1,53 @@ + + + + + + Offline | ERP Generic + + + +
+

Trenutno ste offline

+

+ Aplikacija i dalje radi s lokalno spremljenim podacima. Novi zapisi bit će sinkronizirani + čim se internetska veza vrati. +

+ +
+ + diff --git a/frontend/public/pwa-icon.svg b/frontend/public/pwa-icon.svg new file mode 100644 index 0000000..9cb4c29 --- /dev/null +++ b/frontend/public/pwa-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js new file mode 100644 index 0000000..7a1fd8d --- /dev/null +++ b/frontend/public/service-worker.js @@ -0,0 +1,62 @@ +const CACHE_NAME = 'erp-shell-v1'; +const OFFLINE_URL = '/offline.html'; +const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => + Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); + return response; + }) + .catch(async () => { + const cached = await caches.match(request); + return cached || caches.match(OFFLINE_URL); + }) + ); + return; + } + + if (['style', 'script', 'worker', 'font', 'image'].includes(request.destination)) { + event.respondWith( + caches.match(request).then((cached) => { + const networkFetch = fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); + return response; + }) + .catch(() => cached); + return cached || networkFetch; + }) + ); + } +}); diff --git a/frontend/src/components/AuthWidget.jsx b/frontend/src/components/AuthWidget.jsx new file mode 100644 index 0000000..68b793f --- /dev/null +++ b/frontend/src/components/AuthWidget.jsx @@ -0,0 +1,61 @@ +import { useStore } from '@nanostores/preact'; +import { useEffect, useState } from 'preact/hooks'; +import { $accessToken, $authReady, hydrateAuthFromStorage, logout } from '../stores/authStore'; +import { showToast } from '../stores/toastStore'; + +export default function AuthWidget() { + const token = useStore($accessToken); + const authReady = useStore($authReady); + // hasMounted: SSR uvijek renderira "Prijava" link — identičan SSR i client DOM + const [hasMounted, setHasMounted] = useState(false); + + useEffect(() => { + setHasMounted(true); + // AuthWidget je autonoman — ne oslanja se na to da netko drugi (FleetDashboardShell) + // pozove hydrateAuthFromStorage(). Idempotentna je (authHydrated flag unutar store-a + // osigurava da se localStorage čita samo jednom, čak i ako se poziva višestruko). + hydrateAuthFromStorage(); + }, []); + + // Dok SSR ili auth hydration nisu gotovi — prikaži neutralni "Prijava" link + // kako ne bi došlo do layout shifta ili hydration mismatch-a + if (!hasMounted || !authReady) { + return ( +
+ + Prijava + +
+ ); + } + + return ( +
+ {!token ? ( + + Prijava + + ) : ( + <> + + + )} +
+ ); +} diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx new file mode 100644 index 0000000..31950f4 --- /dev/null +++ b/frontend/src/components/Navbar.jsx @@ -0,0 +1,100 @@ +import NotificationBell from './NotificationBell'; +import ThemeToggle from './ui/ThemeToggle'; +import AuthWidget from './AuthWidget'; +import UserDisplay from './ui/UserDisplay'; +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { hydrateAuthFromStorage } from '../stores/authStore'; + +const ITEMS = [ + { id: 'dashboard', label: 'Dashboard', href: '/' }, + { id: 'work-orders', label: 'Putni nalozi', href: '/putni-nalozi' }, + { id: 'service-records', label: 'Servisni zapisi', href: '/servisni-zapisi' }, + { id: 'vehicles', label: 'Vozila', href: '/vehicles' }, + { id: 'clients', label: 'Klijenti', href: '/clients' }, +]; + +export default function Navbar({ minimal = false }) { + const [pathname, setPathname] = useState('/'); + const normalizedPath = useMemo(() => { + const value = String(pathname || '/'); + if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1); + return value; + }, [pathname]); + + useEffect(() => { + const updatePath = () => setPathname(window.location.pathname || '/'); + updatePath(); + window.addEventListener('astro:page-load', updatePath); + // Hidratira auth iz storage pri prvi puta učitavanja Navbar-a + hydrateAuthFromStorage(); + return () => window.removeEventListener('astro:page-load', updatePath); + }, []); + + const currentItem = useMemo( + () => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0], + [normalizedPath] + ); + + return ( + + ); +} diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx new file mode 100644 index 0000000..c91a30c --- /dev/null +++ b/frontend/src/components/NotificationBell.jsx @@ -0,0 +1,135 @@ +import { useStore } from '@nanostores/preact'; +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { + $isRealtimeConnected, + $notifications, + $unreadNotifications, + connectNotifications, + fetchNotifications, + markAllNotificationsAsRead, + markNotificationAsRead, +} from '../stores/notificationStore'; +import { $accessToken, $user } from '../stores/authStore'; +import NotificationDetailModal from './notifications/NotificationDetailModal'; + +function formatTimestamp(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return new Intl.DateTimeFormat('hr-HR', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); +} + +export default function NotificationBell() { + const [mounted, setMounted] = useState(false); + const [open, setOpen] = useState(false); + const [selectedNotification, setSelectedNotification] = useState(null); + const notifications = useStore($notifications); + const unread = useStore($unreadNotifications); + const isConnected = useStore($isRealtimeConnected); + const token = useStore($accessToken); + const user = useStore($user); + + useEffect(() => { + setMounted(true); + }, []); + + const latestNotifications = useMemo( + () => notifications.slice(0, 6), + [notifications] + ); + + useEffect(() => { + if (!token || !user?.id) return; + fetchNotifications(); + connectNotifications(); + }, [token, user?.id]); + + // Stabilan placeholder za SSR + prvi client render. + if (!mounted || !token) { + return