58 Commits

Author SHA1 Message Date
mariomitte
9a7c7e3163 feat: add per-task PDF/DOCX export for service records
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Enable generating service-record documents for a single task on /putni-nalozi/racuni while preserving existing full work-order export.

- Backend service-records PDF/DOCX endpoints now accept optional task_id and validate the task belongs to the selected work order.
- Document builders support scoped task lists, and per-task downloads use task-specific filenames.
- Frontend task cards now include per-task PDF/DOCX actions, while top-level buttons are clarified as exporting all service records.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 00:44:15 +02:00
mariomitte
7da1c84139 feat: per-task work hours table, notes, and read-only work order data in PDF page
Backend:
- Added service_report_note TextField to Task model (migration 0012)
- Exposed service_report_note in TaskSerializer
- task_service_context: fixed service record filter from
  work_order.vehicle_id -> task.vehicle_id (bug: cross-crane work orders
  returned wrong records for non-primary crane)
- task_service_context: include service_report_note in each task payload
- _build_work_order_service_records_docx_bytes: use per-task
  service_report_note (fallback: work_order.notes); fix service records
  query to use task vehicle ids for cross-crane support
- PDF generator: same per-task notes and cross-crane vehicle fix

Frontend:
- WorkOrderInvoicesPdfPage: removed global 'Uredi tablicu radnih sati' /
  'Uredi tekst napomene' buttons that always opened tasks[0]; replaced
  with per-task buttons inside each task article
- WorkOrderInvoicesPdfPage: separate editingTaskNote state / handler
  that calls updateTaskServiceReportNote (PATCH task.service_report_note)
- WorkOrderServiceNotesModal: now receives task prop instead of workOrder;
  reads/writes task.service_report_note; shows task title in header
- TaskWorkHoursTableModal: added workOrder prop; renders read-only amber
  info block with travel dates, mileage, and servicer vehicle data
- fleetDashboardStore: added updateTaskServiceReportNote() function
- WorkOrderInvoicesPdfPage: show saved service_report_note inline on
  each task card for quick reference

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-03 00:28:15 +02:00
mariomitte
e8272a00bb fix: navbar mobile dropdown clipped left edge - use left-0 instead of right-0
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
The dropdown was anchored to right-0 of the <details> element.
On mobile, the trigger button sits near the left side of the viewport
so right-0 caused the panel to extend leftward off-screen.
Changing to left-0 opens the panel rightward from the button, keeping
it fully visible.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-02 23:31:10 +02:00
mariomitte
d49ec15cc7 Mobile UX: navbar dropdown, compact KPI, table scrolling, remove test toast
1. Navbar mobile dropdown
   - Fixed potential clipping: added z-50 and top-full to dropdown panel
   - Added overflow-visible to nav wrapper so dropdown is not cut off
   - Wider panel (min-w-52), larger tap targets (py-2.5 per link)
   - Active route indicated by colored dot + bold text
   - Trigger button now shows hamburger icon + page name

2. KPI cards (section-dashboard)
   - grid-cols-2 on mobile (was single column), sm:3, xl:5
   - Reduced padding px-3 py-2.5 (was p-4) and font text-xl (was text-2xl)
   - flex-col gap-0.5 layout for tighter vertical rhythm

3. Test Toast removed
   - Deleted ToastTrigger.jsx component entirely
   - Removed import and conditional render from Layout.astro
   - Removed unused isDev variable from Layout.astro

4. AnimatedDataTable improvements
   - Replaced plain-text loading placeholder with animated skeleton rows
     (pulse animation with variable-width grey bars per column)
   - Added horizontal scroll fade indicators: left/right gradient overlays
     appear automatically via ResizeObserver + scroll event listener
   - scroll-smooth touch scrolling on mobile (WebkitOverflowScrolling, scrollbarWidth)
   - Added scope=col on th elements and aria-label / aria-busy props
   - Slightly snappier spring config (tension 260, friction 28)
   - leave animation reduced to 4px shift (was 8px) for less jarring removal

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-02 23:17:38 +02:00
mariomitte
1d7d631fbd Refactor dashboard sections: KPI cards, filters, fallbacks
- Replace 4 static KPI cards with 5 interactive cards
  - 'Otvoreni nalozi' and 'Servisi danas' are now clickable links
  - Added context-aware subtotals (u kontekstu) when crane context is set
  - Replaced 'Upozorenja' (mileage-based, irrelevant for cranes) with 'Bez putnog naloga'
  - 'Dovrseni nalozi' now shows completion percentage
  - Added new card 'Moji zadaci danas' (tasks assigned to current user, today)
- Added crane context filter toggle on the tasks table (Sve dizalice / Odabrani kontekst)
- Service records page now shows last 10 records as fallback when no crane context is selected
- Moved SyncLogPanel into a collapsible <details> element
- Removed dead code: PAGE_MODE_TO_SECTION, activeSection/setActiveSection state,
  navbar:navigate event handler, and DashboardTopbar nav tab block (showSectionNav
  was always false; navigation is handled by Navbar.jsx)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-02 23:06:33 +02:00
mariomitte
45142bba4b Fix task refresh after work order close
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Keep the linked work order visible in task details and force-refresh tasks immediately after work order changes so the dashboard table updates without a hard refresh.
2026-08-01 10:36:20 +02:00
mariomitte
e7641e7be0 fix: proširi bilješke na članove tima i osvježi kalendar
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Widget Bilješke sada omogućuje odabir više članova tima (ne samo
servisera), a backend validacija i testovi su usklađeni.

Dodatno, nakon realtime service_note notifikacije sada se osvježava
service-notes store, te create flow više ne briše lokalne bilješke kada
odgovor vrati praznu listu.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 09:48:44 +02:00
mariomitte
52f54e3a75 feat: prikaži ime tvrtke uz ime dizalice u stupcu DIZALICA
Uz naziv dizalice (make model) u zagradi prikazuje ime klijenta/tvrtke
za svaki redak (sharedCranes i fallback slučaj).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 08:52:30 +02:00
mariomitte
a8f441c150 fix: prikaži svaku dizalicu i SN u zasebnom retku u tablici WO
Umjesto odvajanja s ' • ' u istom retku, svaka dizalica i njen SN
prikazuju se u zasebnom <span> retku unutar flex-col containera.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 08:35:23 +02:00
mariomitte
2a9cac0c81 fix: prikaži sve povezane dizalice i SN u tablici putnih naloga
Kada putni nalog dijele taskovi više dizalica, stupci DIZALICA i SN
u tablici Putni nalozi dizalica prikazuju sve uključene dizalice
odvojene s ' • ' umjesto samo one za koju je WO kreiran.

- Dodana workOrderSharedCranes mapa (workOrderId -> {vehicleId -> crane})
  izgrađena iz task.work_order + task.vehicle veza
- Render stupaca DIZALICA i SN koristi mapu ako postoje shared cranes,
  inače fallback na craneInfo s originalnog WO-a

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 08:27:54 +02:00
mariomitte
79145c58e4 fix: prikaži shared putne naloge u kontekstu i filtriraj zatvorene u dropdownu
- scopedWorkOrders: u context modu uključi i putne naloge koji su dodijeljeni
  taskovima kontekstne dizalice (cross-crane shared WO), a ne samo one čiji
  vehicle === hydratedSelectedVehicleId
- selectedTaskWorkOrders: prikaži samo otvorene naloge (status != closed)
  u dropdownu za dodjelu putnog naloga tasku
- Ažuriran emptyMessage tablice da sugerira prebacivanje na 'svi nalozi'

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 08:09:45 +02:00
mariomitte
fd8c6d8040 feat: dozvoli dijeljenje putnog naloga izmedju razlicitih dizalica
Problem: serviser nije mogao odabrati putni nalog kreiran za drugu dizalicu
jer je frontend filtrirao naloge po aktivnom crane kontekstu, a backend
serializer je bacao ValidationError ako WO.vehicle != task.vehicle.

Promjene:
- TaskSerializer: uklonjena cross-crane validacija - WO moze pripadati
  drugoj dizalici od one na tasku (cross-crane sharing)
- FleetDashboardShell: serviceRecordWorkOrders i selectedTaskWorkOrders
  vise ne filtriraju po vehicle ID-u, prikazuju sve otvorene naloge
- TaskServiceRecordsModal: gumb i modal za izmjenu podataka dizalice
  (radni sati, kilometraza) premjesten iz WorkOrderDetailModal
- WorkOrderDetailModal: uklonjen gumb 'Izmijeni podatke dizalice'
- Dodana 3 nova backend testa za cross-crane WO dodjelu (15/15 prolaze)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 07:54:57 +02:00
mariomitte
06321fc3d1 feat: auto-odabir i datum u dropdownu putnih naloga za taskove
- TaskCreateModal: auto-pretselektira jedini otvoreni nalog za danas pri otvaranju modala
- Dropdown labele prikazuju datum naloga i oznacavaju danasnji nalog sa zvjezdicom (★)
- Dodana napomena da vise taskova moze dijeliti isti putni nalog
- TaskServiceRecordsModal: danasnji nalozi sortirani na vrh liste
- Datum prikazan u dropdownu i u prikazu trenutno odabranog naloga

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-01 07:15:39 +02:00
mariomitte
6470a2996c fix: ukloni DOCX zaštitu za uređivanje izvještaja
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Generirani servisni DOCX preuzimao je document/write zaštitu iz predloška pa je Word datoteka bila neurediva.\n\nDodano je uklanjanje documentProtection/writeProtection/readOnlyRecommended postavki pri kreiranju DOCX dokumenta te regresijski test koji provjerava da zaštita nije prisutna u word/settings.xml.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 18:47:37 +02:00
mariomitte
16264cb8a9 fix: dodaj fotografije servisnih zapisa u DOCX
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
DOCX izvjestaj servisnih zapisa sada ugradjuje povezane fotografije nakon kompresije slike za manju velicinu dokumenta.

Dodan je regresijski test koji provjerava da generirani DOCX sadrzi embedded media datoteke.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 18:09:16 +02:00
mariomitte
51a65f9b80 fix: omoguci rucni unos vremena u mjesecnom izvjestaju
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodaje uredski unos pocetka i kraja rada u sidebaru mjesecnog izvjestaja servisera i automatski racuna redovan rad iz unesenog raspona. Zadrzava postojece predpopunjavanje za rad u uredu i validira neispravan vremenski raspon.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 17:37:44 +02:00
mariomitte
882e255523 fix: dohvat mjesecnih racuna za troskove
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Uklanja ovisnost o workOrders.invoices i dohvaća račune direktno kroz API filtriran po mjesecu. Dodaje i display code putnog naloga u serializer kako bi prikaz u izvještaju ostao potpun.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 17:05:56 +02:00
mariomitte
3a33751012 feat: dnevni unosi i svi dani u mjesecnom izvjestaju servisera
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- Dodan model MonthlyServicerDayEntry s automatskom popunom prema tipu:
  office (08:00-16:00, Zagreb ured), vacation i sick_leave (8h bez vremena)
- Migracija 0034_monthlyservicerdayentry za novu tablicu s UniqueConstraint
- Novi ViewSet + serializer + endpoint fleet/monthly-servicer-day-entries/
  (upsert: POST za novi dan ili update za isti dan ako vec postoji)
- Backend DOCX report (_build_monthly_servicer_report_rows) sad generira
  redak za SVAKI dan u mjesecu: taskovi > rucni unos > prazan red
- TaskCalendarWidget potpuno prepisan bez mojibake znakova
  (zamjena UTF-8 specijalnih znakova ASCII ekvivalentima u string literalima)
- Tablica izvjestaja servisera: klikom na red bez naloga otvara se overlay
  s tri opcije (Rad u uredu / Bolovanje / Godisnji)
- TaskCalendarPortal: dodan calendarWorkOrders computed (svi statusi)
  kako bi report imao potpune podatke o radnom vremenu
- fetchMonthlyServicerEntries i upsertMonthlyServicerEntry u store-u

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 16:42:07 +02:00
mariomitte
fe739b14c0 feat: dodaj mjesecni izvjestaj servisera i troskova u kalendar widget
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- Dva nova Django API endpointa (GET /api/fleet/reports/monthly-servicer/ i
  /api/fleet/reports/monthly-costs/) koji generiraju DOCX u A4 landscape formatu
- TaskCalendarWidget prosiren s report panelom: dva gumba iznad kalendara
  (Izvjestaj servisera / Izvjestaj troskova) sirenjem drawer-a na 680 px
- Preview tablice s podacima iz postojeceg tasks/workOrders store-a
- downloadMonthlyServiserReport i downloadMonthlyCostsReport u store-u
- TaskCalendarPortal prosljeduje activeWorkOrders prop widgetu

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 13:31:58 +02:00
mariomitte
8b82481bb4 feat: vise taskova po putnom nalogu i izmjena podataka dizalice
- task_management/views.py: dodaj ?work_order= filter u get_queryset
  (jedini razlog zasto putni nalog nije mogao imati vise taskova bio je
  nedostatak backend filtera; model uvijek podrzavao vise taskova)
- fleet/serializers.py: ukloni current_mileage iz read_only_fields
  kako bi se moglo azurirati putem PATCH /fleet/cranes/{id}/
- fleetDashboardStore.js: dodaj fetchTasksByWorkOrder(workOrderId)
  koji dohvaca GET tasks/tasks/?work_order={id}
- WorkOrderDetailModal.jsx:
  * import TaskCreateModal, fetchTasksByWorkOrder, updateVehicle, getStatusLabel
  * nova sekcija 'Zadaci putnog naloga' s popisom i gumbom '+ Dodaj zadatak'
  * gumb '🔧 Izmijeni podatke dizalice' otvara CraneDataModal
  * CraneDataModal: unos superstructure_working_hours, chassis_working_hours,
    current_mileage; poziva updateVehicle; prikazuje trenutne vrijednosti
    kao placeholder

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 12:28:48 +02:00
mariomitte
525152b817 fix: ispravi prikaz računa u admin/frontendu i smanji PDF slike
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- admin.py: thumbnail_preview u WorkOrderInvoiceInline i WorkOrderInvoiceAdmin
  prikazuje slike/PDF veze putem optimiziranog image endpointa
- serializers.py: image_url sada uključuje ?w=1280&q=80&fmt=jpeg za ne-PDF slike
- views.py: doda _compress_image_for_pdf() helper, zamijeni ImageReader(photo.image)
  s kompresiranom JPEG verzijom na oba mjesta u PDF generiranju
- tasks.py: kompresija invoice slike u JPEG u memoriji prije ImageReader poziva
- WorkOrderInvoicesPdfPage.jsx:
  * InvoiceImagePreview prikazuje 'Učitavanje...' dok se blob dohvaća (ne 401 fallback)
  * uklonjen resolveMediaUrl fallback koji uzrokuje 401 greške
  * svaka invoice kartica je klikabilna i otvara InvoiceDetailModal
  * InvoiceDetailModal prikazuje detalje i uvećanu sliku/PDF vezu

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 11:54:34 +02:00
mariomitte
ae6ae08fb1 feat: dodaj DOCX akcije i napomene na racunima
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- dodaje DOCX preuzimanje za putni nalog, servisne zapise i racune na /putni-nalozi/racuni\n- uvodi modal za uredjivanje teksta napomene izvjestaja servisera\n- poboljsava update work order toka i upsert lokalnog stanja\n- ukljucuje TaskCalendar migraciju, dev watch stabilizaciju i deploy upute

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 13:34:53 +02:00
mariomitte
1055820741 fix: uskladi servisni DOCX template i deduplikaciju dijelova
- koristi novi Servisni N-R template za service-records DOCX\n- premjesta tablicu radnih sati odmah ispod naslova na prvoj stranici\n- deduplicira ponovljene unose koristenih dijelova u tablici artikala\n- uklanja fallback neautorizirani media URL za zasticene fleet resurse

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-23 13:27:13 +02:00
mariomitte
908025abc6 fix: prikazi napomenu iz DB u servisnom PDF-u
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Servisni PDF sada koristi work_order.notes za polje Napomene umjesto izvedenog teksta iz task naslova. Dodana je i invalidacija generated PDF cachea pri updateu putnog naloga kako bi se nakon spremanja napomene uvijek generirao svjezi PDF.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 23:18:48 +02:00
mariomitte
666ddb56c9 feat: dodaj prefill email potpisa i body logging
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Uvodi signature polje i default predlozak potpisa za nove korisnike, te data migraciju koja popunjava postojece prazne potpise. Email flow sada dosljedno dodaje korisnicki potpis i sprema finalni body poruke u dispatch log radi audita.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 15:58:50 +02:00
mariomitte
ebb642e572 fix: popravi vidljivost checkboxa u dark modu
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodaj prilagođeni stil checkboxa kako bi odabrano i neodabrano stanje bilo jasno vidljivo u dark temi, te primijeni novu klasu na checkboxe u modalima slanja emaila.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 14:56:09 +02:00
mariomitte
5945423346 feat: async email slanje s cache PDF fallbackom i admin logom
Prebaci slanje emaila putnog naloga u pozadinski task bez blokiranja UI-a i dodaj fallback kad queue nije dostupna. Kod greške slanja spremi generirane PDF-ove u cache i pošalji notifikaciju s download linkovima.

Dodaj EmailDispatchLog model i Django admin pregled za praćenje kome je poslano, što je poslano, status i detalje privitaka.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 14:41:45 +02:00
mariomitte
42fcd836ed feat: dodaj servisni kontekst u task detalji modal
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodaj gumb 'Postavi servisni kontekst' u zaglavlje modala Radni zadatak detalji i poveži ga s postojećom logikom postavljanja konteksta prema dizalici zadatka.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-20 12:00:54 +02:00
mariomitte
b92596f6a6 feat: proširi prikaz dizalice i vlasnika u zadacima
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodaj detalje dizalice (proizvođač, model i SN) te vlasnika u modal radnog zadatka i kalendar zadataka, uz zajednički frontend helper za DRY prikaz.

Proširi Task API serializer dodatnim poljima za prikaz dizalice/vlasnika i ažuriraj testove serializera za nova izlazna polja.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-20 09:53:09 +02:00
mariomitte
1a18498b30 feat: dodaj bilješke widget i admin kalendar/notifikacije
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Uveden je novi Notes drawer s kalendar integracijom, zajednički DRY
left-slide mehanizam te međusobno isključivo prikazivanje s Kalendar
widgetom.

Backend i frontend su prošireni za servisne bilješke (uključujući slanje
na više servisera), dodan je users/servicers endpoint, te su uvedene
warning notifikacije kada serviser ima više od 2 zadatka isti dan.

U admin su dodane stavke za Bilješke i zaseban Kalendar zadataka (proxy
TaskCalendar) radi lakšeg operativnog pregleda.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-20 06:33:36 +02:00
mariomitte
61996bd1a0 fix: premjesti gumb tablice sati u zaglavlje servisnih zapisa
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
U /putni-nalozi/racuni gumb 'Uredi tablicu radnih sati' premješten je
iz kartica pojedinih taskova u zaglavlje grupe Servisni zapisi, pokraj
gumba za preuzimanje PDF-a, po istom UX obrascu kao dodatni troškovi.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 21:41:00 +02:00
mariomitte
ae35a6041d feat: dodaj dodatne troškove s OCR sinkronizacijom
Dodana je tablica DODATNI TROŠKOVI za putni nalog (backend model + API +
frontend modal) s izračunom ukupnog iznosa za isplatu.

Upload računa sada odmah upisuje redak u dodatne troškove, a asinkroni OCR
(Celery) naknadno pokušava popuniti broj računa i iznos iz slike/PDF-a.

Uvedena je invalidacija PDF cache-a nakon promjena računa/dodatnih troškova
kako bi generirani PDF uvijek prikazivao najnovije podatke.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 21:23:29 +02:00
mariomitte
d35eaeccf4 feat: dodaj ishodište i PDF račune za putni nalog
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodano je origin_location polje kroz backend model, serializer, admin i UI
modal za kreiranje/uređivanje putnog naloga (default: Zagreb).

Računi putnog naloga sada prihvaćaju slike i PDF datoteke, uz backend
validaciju formata, podršku za serving PDF-a i prilagodbu preview prikaza.

Za greške uploada računa dodan je error toast kako bi poruke validacije bile
vidljive korisniku odmah u sučelju.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:54:15 +02:00
mariomitte
cbcbf0b3af fix: sortiranje putnih naloga i upload modal u detaljima
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodaje sortiranje stupca Putni nalog (↑/↓) u tablicama gdje je prikazan work order kod i uspoređuje kodove u formatu MTDDMMYY po godini, mjesecu i danu.

Popravlja tok uređivanja servisnog zapisa tako da upload fotografija ostaje u višem overlayju bez zatvaranja parent modala te nakon uspješnog uploada osvježava listu datoteka u detaljima.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:16:19 +02:00
mariomitte
ad7b293f54 fix: filtriraj putne naloge i prosiri servisni zapis modal
Ogranicava prikaz 'Dohvati sve putne naloge' na naloge trenutnog servisera, dok supervisor bez serviser uloge i dalje vidi sve.

U detaljima servisnog zapisa dodaje gumb za upload datoteka i povezuje ga s postojecim ServicePhotoUpload modalom.

U modalu Novi servisni zapis dodaje polje Naziv servisnog zapisa iznad opisa i salje ga kao service_title.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 19:02:05 +02:00
mariomitte
8485431180 fix: zamijeni auth polling health endpointom
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodaje backend /api/health/ endpoint i prebacuje frontend heartbeat sa /api/token/ OPTIONS na /api/health/ GET kako bi se izbjeglo periodicko gadanje auth rute.

Dodaje TTL cache za clientStore (lastFetchedAt + force opcija) kako bi se smanjili redundantni crm/clients pozivi pri navigaciji.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 16:27:20 +02:00
mariomitte
bbdd02c9e9 fix: stabiliziraj navbar tranziciju i ukloni white splash
Zadrzava Navbar i ostale UI otoke kroz Astro tranzicije te sinkronizira aktivni path preko astro:page-load kako bi react-spring indikator animirao iz trenutne pozicije.

Uklanja bijeli splash pri promjeni stranice i prelasku teme postavljanjem html background boje inline, prilagodbom ThemeToggle logike i uklanjanjem body color tranzicije.

Dodano je i TTL kesiranje za dashboard/task dohvat kako bi se smanjili redundantni API pozivi pri navigaciji.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 16:18:15 +02:00
mariomitte
fe7edf5643 fix: premjesti kalendar widget u Layout, popravi PDF toast i Servisi danas
- TaskCalendarPortal u Layout.astro (van AnimatedPage transforma) pa fixed
  pozicija uvijek prati viewport rub bez probijanja sadržaja
- Kalendar widgeta panel koristi clipping wrapper (overflow:hidden + animirana
  width) umjesto translate3d — potpuno skriva sjenu i border kad je zatvoren
- Serviser vidi samo svoje taskove, supervisor vidi sve; neprijavljeni korisnik
  ne vidi widget (return null)
- FleetDashboardShell sluša calendar:open-task event i otvara TaskServiceRecordsModal
- Servisi danas broji Taskove (scheduled_date=danas, status aktivan/servis)
  umjesto Servisnih zapisa
- downloadGeneratedPdfByUrl hvata grešku i prikazuje Toast (npr. PDF nije
  dostupan ili je istekao)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 15:27:20 +02:00
mariomitte
0c9e1b7d50 feat: dodaj scheduled_date na Task i kalendar widget
- Novo polje Task.scheduled_date (DateField, nullable, db_index) za planiranje
- Migracija 0009_task_scheduled_date
- Celery task notify_upcoming_tasks: dnevno u 08:00 šalje Pusher + email za sutrašnje zadatke
- TaskCalendarWidget: react-spring slide panel s lijeve strane, grid prikaz mjeseca, dot indikatori, klik otvara TaskServiceRecordsModal
- FleetDashboardShell: import + render kalendara, Planirano stupac u tablici zadataka
- TaskCreateModal + TaskServiceRecordsModal: scheduled_date input i prikaz
- Admin + serializer + views: scheduled_date filter i prikaz
- 47 backend testova prolaze (uključuje 8 novih za notify_upcoming_tasks)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 14:52:10 +02:00
mariomitte
bf8e25efe7 feat: dodaj display_code polje za putne naloge (MT070626 format)
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- Dodano polje 'display_code' na WorkOrder model (format: 2 inicijala + ddmmyy)
- Migracije za kreiranje polja i backfill postojećih naloga
- Ažuriran WorkOrderSerializer za validaciju i serijalizaciju display_code
- Backend tasks za auto-generiranje display_code pri kreiranju
- Frontend komponente ažurirane za prikaz display_code umjesto UUID:
  * FleetDashboardShell tablica putnih naloga
  * WorkOrderDetailModal
  * TaskCreateModal
  * Sve PDF stranice
- displayIds.js utility za formatiranjeDisplay ID-eva
- Admin interface prikazuje display_code
2026-07-17 23:53:47 +02:00
mariomitte
ef8718a4cf fix: ukloni dupli toast provider u layoutu
Uklonjen je paralelni ToastProvider iz Layout.astro kako bi ostao samo postojeći ToastContainer (nanostore), čime se sprječava hydration crash i null.map greška na /putni-nalozi.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 19:39:04 +02:00
mariomitte
75257525c4 fix: osvjezi taskove nakon azuriranja putnog naloga
Nakon spremanja izmjena u Uredi putni nalog, frontend sada uz fleet podatke ponovno dohvaća i taskove, pa se status taska odmah prikaže kao 'spreman_za_zavrsetak' kada backend zatvori povezani putni nalog.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 19:32:50 +02:00
mariomitte
7acaef941f feat: integritet zatvaranja taska i putnog naloga
Dodaje status 'spreman_za_zavrsetak' i backend provjeru koja blokira zatvaranje taska kada je povezani putni nalog otvoren, uz 409 odgovor i opcionalni auto-close flow.\n\nPri zatvaranju putnog naloga taskovi u statusu 'servis' automatski prelaze u 'spreman_za_zavrsetak'. Frontend modal sada podržava potvrdu i retry s auto_close_work_order=true.\n\nUključeni su i ciljani testovi za novi behavior, te ažuriran prikaz statusa u dashboardu.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 19:20:33 +02:00
mariomitte
e14665d8ec fix: dev proxy za frontend API pozive
U dev modu frontend sada koristi /api preko Astro/Vite proxy sloja prema backend servisu, umjesto direktnog gađanja localhost:8001 iz browsera.\n\nTime se uklanja ERR_CONNECTION_REFUSED u lokalnom Docker development okruženju.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 18:58:09 +02:00
mariomitte
ef51036bc7 fix: refresh notifikacija nakon PDF zahtjeva (cache hit i async polling)
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Dodan _refreshNotificationsAsync() koji odmah osvjezi notifikacijski feed nakon cache hit PDF downloada, te _schedulePdfNotificationPolling() koji pollingom na 5s/20s/60s hvata Celery completion kad Pusher nije dostupan ili worker kasni.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 17:21:46 +02:00
mariomitte
13de9e113c fix: ispravljen dvostruki /api/ prefiks u download_url za generirani PDF
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Backend je vraćao download_url s /api/ prefiksom, a apiClient.js rezolvira URL relativno na BASE_URL koji vec sadrži /api/ - rezultat je bio /api/api/ i 404. Uklonjen /api/ prefiks na svim mjestima (views.py x2, tasks.py x1).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 17:03:11 +02:00
mariomitte
e8330473cd feat: async PDF cache i API media putanje za putne naloge
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
Uvodi background request tok za generiranje PDF-a s 24h cache-om i cleanup taskom preko Celery beat-a. Dodaje notification metadata download_url za ponovno preuzimanje bez regeneracije, API image endpointe za putne naloge i račune, te frontend integraciju za async download i produkcijski prikaz slika.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 16:42:02 +02:00
mariomitte
5e7ec72a4e fix: ispravljen prikaz Vozilo servisera u WorkOrderDetailModal
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- Vozilo servisera sada prikazuje servicer_vehicle_* polja (registracija,
  marka/model, km start/end, trosak goriva) umjesto craneInfo podataka
- start_mileage/end_mileage (km dizalice na nalogu) premjesteni u Podaci dizalice
- servicer_vehicle_* polja dodana u form state, useEffect init i submit payload
- Trošak goriva (EUR) dodan kao novo polje u sekciji Vozilo servisera

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-17 11:12:14 +02:00
mariomitte
346682b172 config: disable CommonPasswordValidator za interni ERP
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-16 21:56:11 +02:00
mariomitte
8e4b3f8aea feat: WorkOrderPhoto izolacija, upload modal i SN stupac u tablici putnih naloga
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
- Dodan WorkOrderPhoto model (FK na WorkOrder, odvojen od VehicleServicePhoto)
- Migracija 0022_workorderphoto
- WorkOrderViewSet.images action refaktoriran: GET/POST koristi WorkOrderPhoto
- WorkOrderPhotoSerializer dodan u serializers.py
- WorkOrderPhotoInline dodan u admin.py
- Novi WorkOrderPhotoUpload.jsx modal s podrskom za onBack prop i z-[60]
- WorkOrderDetailModal: gumb Dodaj fotografije u editMode, photoUploadOpen state,
  ispravljen onSuccess callback (map na URL stringove), reset pri zatvaranju
- FleetDashboardShell: stupac SN u tablici putnih naloga (craneInfo.crane_serial_number)
- fleetDashboardStore: uklonjen dupli toast iz uploadWorkOrderPhoto
- Dodan test test_work_order_images_endpoint.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-16 06:33:08 +02:00
mariomitte
1ab2bfb3c3 patch prikaži stranicu servisnih zapisa sa SN dizalice
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-14 23:09:00 +02:00
mariomitte
3ad536a23e interaktivna Tablica radnih sati, db
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-13 00:36:28 +02:00
70151f1225 production with django admin template
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-12 22:44:03 +02:00
mariomitte
ac5f25a99c patch ne otkriva entrypoint.sh na windows 2026-07-12 15:25:57 +02:00
24cd9ae11c WIP
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-12 03:59:56 +02:00
3268e53642 WIP: local production settings updates 2026-07-12 03:56:32 +02:00
mariomitte
4e3bf6dab0 patch oko teksta PDFa i UI tablica
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
2026-07-12 03:25:03 +02:00
mariomitte
3b54499b17 patch astro frontend config file 2026-07-10 23:34:29 +02:00
135 changed files with 14576 additions and 1371 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

View File

@@ -1 +1,23 @@
Frontend + Backend + Database + Deployment
Frontend + Backend + Database + Deployment
### Deploy na serveru
Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promjena iz repozitorija
```bash
cd /opt/erp/app
git fetch origin
git reset --hard origin/main
# rebuild backend + worker + beat + frontend
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend
# digni servise s novim image-ima
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --force-recreate backend worker beat frontend
# OBAVEZNO migracije (scheduled_date)
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend python manage.py migrate
# provjera
curl -i https://api-004.captain.mitteworkspace.cloud/api/health/
```

View File

@@ -1,5 +1,6 @@
import os
from pathlib import Path
from celery.schedules import crontab
from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -11,6 +12,7 @@ SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-fk#ywtdyi8=t6^vdbo!=b
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
INSTALLED_APPS = [
'unfold',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -27,6 +29,14 @@ INSTALLED_APPS = [
'modules.crm',
'modules.fleet'
]
UNFOLD = {
'SITE_TITLE': 'ERP Admin',
'SITE_HEADER': 'ERP Administration',
'SITE_SYMBOL': 'dashboard',
'SHOW_HISTORY': True,
}
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # Mora biti prvi
'django.middleware.security.SecurityMiddleware',
@@ -61,7 +71,7 @@ WSGI_APPLICATION = 'core.wsgi.application'
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.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
@@ -160,12 +170,22 @@ CACHES = {
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_BEAT_SCHEDULE = {
'cleanup-expired-generated-pdfs-hourly': {
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
'schedule': crontab(minute=0),
},
'notify-upcoming-tasks-daily': {
'task': 'modules.task_management.tasks.notify_upcoming_tasks',
'schedule': crontab(hour=8, minute=0),
},
}
# 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'
# STATIC_ROOT = BASE_DIR / '/app/staticfiles'
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
MEDIA_URL = '/media/'
@@ -211,4 +231,4 @@ CACHES = {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
}
}
}

View File

@@ -46,4 +46,14 @@ INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
INTERNAL_IPS = ['127.0.0.1']
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_ALWAYS_EAGER = True
# 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 / '/tmp/staticfiles'
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

View File

@@ -16,9 +16,11 @@ DATABASES = {
}
# Sigurnosne postavke
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True
# Produkcijski CORS (samo domena na kojoj vrti Astro)
CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',')
@@ -34,5 +36,10 @@ CSRF_TRUSTED_ORIGINS = [
]
# Statika i Mediji
STATIC_URL = '/static/'
STATIC_ROOT = '/app/staticfiles'
MEDIA_ROOT = '/app/media'
MEDIA_URL = '/media/'
MEDIA_ROOT = '/app/media'
# Privremeni fallback dok se media ne servira preko reverse proxy-ja.
SERVE_MEDIA_FILES = os.environ.get('SERVE_MEDIA_FILES', '1').strip().lower() in ('1', 'true', 'yes', 'on')

View File

@@ -2,14 +2,19 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.http import JsonResponse
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
def api_health(_request):
return JsonResponse({'status': 'ok'})
urlpatterns = [
path('admin/', admin.site.urls),
path('api/health/', api_health, name='api_health'),
# Uključujemo rute iz modula (svaki modul ima svoj urls.py)
path('api/crm/', include('modules.crm.urls')),
@@ -30,4 +35,6 @@ if settings.DEBUG:
urlpatterns += [
path('__debug__/', include(debug_toolbar.urls)),
]
if settings.DEBUG or getattr(settings, 'SERVE_MEDIA_FILES', False):
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -9,7 +9,7 @@ class CustomUserAdmin(UserAdmin):
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')}),
('Osobni podaci', {'fields': ('first_name', 'last_name', 'telefon', 'oib', 'occupation', 'work_position', 'residence', 'signature')}),
('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')}),
)
@@ -18,7 +18,7 @@ class CustomUserAdmin(UserAdmin):
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('email', 'password', 'is_team_member'),
'fields': ('email', 'password', 'first_name', 'last_name', 'telefon', 'occupation', 'signature', 'is_team_member'),
}),
)

View File

@@ -0,0 +1,8 @@
email,first_name,last_name,password,is_serviser,is_kupac,is_team_member,is_staff,is_superuser,is_active,telefon,oib,occupation,residence,work_position,licence_number,is_verified,client_id,client_tax_id
serviser1@example.com,Ime1,Prezime1,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
serviser2@example.com,Ime2,Prezime2,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
serviser3@example.com,Ime3,Prezime3,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
admin1@example.com,Admin1,Prezime1,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
admin2@example.com,Admin2,Prezime2,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
ramo@knezljubo.hr,Ramo,Husanović,password123,true,false,true,false,false,true,+38591111222,12345678901,Serviser,Zagreb,Serviser dizalice,LIC-001,true,,
domagoj@knezljubo.hr,Domagoj,Knez,password123,false,false,true,true,true,true,+38591111222,12345678901,Admin,Zagreb,Direktor,LIC-001,true,,
1 email first_name last_name password is_serviser is_kupac is_team_member is_staff is_superuser is_active telefon oib occupation residence work_position licence_number is_verified client_id client_tax_id
2 serviser1@example.com Ime1 Prezime1 password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
3 serviser2@example.com Ime2 Prezime2 password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
4 serviser3@example.com Ime3 Prezime3 password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
5 admin1@example.com Admin1 Prezime1 password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true
6 admin2@example.com Admin2 Prezime2 password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true
7 ramo@knezljubo.hr Ramo Husanović password123 true false true false false true +38591111222 12345678901 Serviser Zagreb Serviser dizalice LIC-001 true
8 domagoj@knezljubo.hr Domagoj Knez password123 false false true true true true +38591111222 12345678901 Admin Zagreb Direktor LIC-001 true

View File

@@ -0,0 +1,202 @@
import csv
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from modules.crm.models import Client
def _to_bool(value, field_name):
if value is None or value == "":
return None
normalized = str(value).strip().lower()
if normalized in {"1", "true", "t", "yes", "y", "da"}:
return True
if normalized in {"0", "false", "f", "no", "n", "ne"}:
return False
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
def _to_int(value, field_name):
if value is None or value == "":
return None
try:
return int(str(value).strip())
except (TypeError, ValueError) as exc:
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
class Command(BaseCommand):
help = "Uvozi korisnike (radnike) iz CSV datoteke u CustomUser model."
expected_columns = [
"email",
"first_name",
"last_name",
"password",
"is_serviser",
"is_kupac",
"is_team_member",
"is_staff",
"is_superuser",
"is_active",
"telefon",
"oib",
"occupation",
"residence",
"work_position",
"licence_number",
"is_verified",
"client_id",
"client_tax_id",
]
def add_arguments(self, parser):
parser.add_argument("csv_path", type=str, help="Putanja do CSV datoteke.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Validira i simulira import bez spremanja u bazu.",
)
parser.add_argument(
"--create-only",
action="store_true",
help="Kreira samo nove korisnike; postojeće preskače.",
)
parser.add_argument(
"--default-password",
type=str,
default="",
help="Fallback lozinka za nove korisnike ako polje 'password' nije popunjeno.",
)
def handle(self, *args, **options):
csv_path = Path(options["csv_path"]).expanduser().resolve()
dry_run = options["dry_run"]
create_only = options["create_only"]
default_password = options["default_password"]
if not csv_path.exists():
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
User = get_user_model()
created_count = 0
updated_count = 0
skipped_count = 0
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CommandError("CSV nema zaglavlje (header).")
normalized_headers = [str(h).strip() for h in reader.fieldnames]
missing = [column for column in self.expected_columns if column not in normalized_headers]
if missing:
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
row_number = 1
with transaction.atomic():
for row in reader:
row_number += 1
if not any((value or "").strip() for value in row.values()):
continue
email = (row.get("email") or "").strip().lower()
if not email:
raise CommandError(f"Red {row_number}: email je obavezan.")
user = User.objects.filter(email__iexact=email).first()
if user and create_only:
skipped_count += 1
self.stdout.write(
self.style.WARNING(
f"Red {row_number}: preskočeno (korisnik već postoji) {email}"
)
)
continue
is_create = user is None
if is_create:
password = (row.get("password") or "").strip() or default_password
if not password:
raise CommandError(
f"Red {row_number}: password je obavezan za nove korisnike "
"(ili koristite --default-password)."
)
user = User(email=email)
user.set_password(password)
else:
password = (row.get("password") or "").strip()
if password:
user.set_password(password)
client = self._resolve_client(row=row, row_number=row_number)
user.first_name = (row.get("first_name") or "").strip()
user.last_name = (row.get("last_name") or "").strip()
user.telefon = (row.get("telefon") or "").strip()
user.oib = (row.get("oib") or "").strip() or None
user.occupation = (row.get("occupation") or "").strip()
user.residence = (row.get("residence") or "").strip()
user.work_position = (row.get("work_position") or "").strip()
user.licence_number = (row.get("licence_number") or "").strip() or None
for bool_field in [
"is_serviser",
"is_kupac",
"is_team_member",
"is_staff",
"is_superuser",
"is_active",
"is_verified",
]:
parsed = _to_bool(row.get(bool_field), bool_field)
if parsed is not None:
setattr(user, bool_field, parsed)
user.client_profile = client
user.username = None
user.full_clean()
if not dry_run:
user.save()
if is_create:
created_count += 1
else:
updated_count += 1
self.stdout.write(
self.style.SUCCESS(
f"Red {row_number}: {'kreiran' if is_create else 'ažuriran'} korisnik {email}"
)
)
if dry_run:
transaction.set_rollback(True)
summary = (
f"Import korisnika završen. created={created_count}, updated={updated_count}, "
f"skipped={skipped_count}, dry_run={dry_run}"
)
self.stdout.write(self.style.SUCCESS(summary))
def _resolve_client(self, *, row, row_number):
client_id = _to_int(row.get("client_id"), "client_id")
client_tax_id = (row.get("client_tax_id") or "").strip()
if client_id:
client = Client.objects.filter(id=client_id).first()
if not client:
raise CommandError(f"Red {row_number}: klijent s ID={client_id} ne postoji.")
return client
if client_tax_id:
client = Client.objects.filter(tax_id=client_tax_id).first()
if not client:
raise CommandError(f"Red {row_number}: klijent s tax_id={client_tax_id} ne postoji.")
return client
return None

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0006_customuser_work_position'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='signature',
field=models.TextField(blank=True, default='', verbose_name='Email potpis'),
),
]

View File

@@ -0,0 +1,39 @@
from django.db import migrations
def prefill_empty_signatures(apps, schema_editor):
CustomUser = apps.get_model('users', 'CustomUser')
for user in CustomUser.objects.all().iterator():
signature_value = user.signature if isinstance(user.signature, str) else ''
if signature_value.strip():
continue
first_name = (user.first_name or '').strip()
last_name = (user.last_name or '').strip()
full_name = f"{first_name} {last_name}".strip()
fallback_name = (user.email or '').strip() or 'Ime i prezime'
display_name = full_name or fallback_name
occupation = (user.occupation or '').strip()
name_line = f"{occupation} {display_name}".strip() if occupation else display_name
mobile = (user.telefon or '').strip() or "+385 91 636 3466"
signature = (
"Poslano iz aplikacije\n"
"S poštovanjem / Freundliche Grüße / Kind regards\n\n"
f"{name_line}\n\n"
"KNEZ LJUBO d.o.o.\n"
"Ivlje 44, 10040 Zagreb\n\n"
"Office: Donje Svetice 40, 10000 Zagreb\n\n"
f"Mobile: {mobile}\n\n"
"www.knezljubo.hr"
)
user.signature = signature
user.save(update_fields=['signature'])
class Migration(migrations.Migration):
dependencies = [
('users', '0007_customuser_signature'),
]
operations = [
migrations.RunPython(prefill_empty_signatures, migrations.RunPython.noop),
]

View File

@@ -3,6 +3,17 @@ from django.core.exceptions import ValidationError
from django.db import models
from .managers import CustomUserManager
DEFAULT_EMAIL_SIGNATURE_TEMPLATE = (
"Poslano iz aplikacije\n"
"S poštovanjem / Freundliche Grüße / Kind regards\n\n"
"{name_line}\n\n"
"KNEZ LJUBO d.o.o.\n"
"Ivlje 44, 10040 Zagreb\n\n"
"Office: Donje Svetice 40, 10000 Zagreb\n\n"
"Mobile: {mobile}\n\n"
"www.knezljubo.hr"
)
class CustomUser(AbstractUser):
# Email koristimo za login, pa mora biti jedinstven
username=None
@@ -19,6 +30,7 @@ class CustomUser(AbstractUser):
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")
signature = models.TextField(blank=True, default='', verbose_name="Email potpis")
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")
@@ -47,6 +59,27 @@ class CustomUser(AbstractUser):
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 build_default_signature(self):
full_name = self.get_full_name().strip()
fallback_name = self.email.strip() if isinstance(self.email, str) and self.email.strip() else 'Ime i prezime'
display_name = full_name or fallback_name
occupation = self.occupation.strip() if isinstance(self.occupation, str) else ''
name_line = f"{occupation} {display_name}".strip() if occupation else display_name
mobile = self.telefon.strip() if isinstance(self.telefon, str) and self.telefon.strip() else "+385 91 636 3466"
return DEFAULT_EMAIL_SIGNATURE_TEMPLATE.format(
name_line=name_line,
mobile=mobile,
)
def get_email_signature(self):
stored_signature = self.signature.strip() if isinstance(self.signature, str) else ''
if stored_signature:
return stored_signature
return self.build_default_signature()
def save(self, *args, **kwargs):
signature = self.signature.strip() if isinstance(self.signature, str) else ''
if not signature:
self.signature = self.build_default_signature()
self.full_clean() # Prisiljava pokretanje clean() prije svakog save()
super().save(*args, **kwargs)
super().save(*args, **kwargs)

View File

@@ -1,5 +1,4 @@
from rest_framework import serializers
from django.core.exceptions import ObjectDoesNotExist
from .models import CustomUser
class UserSerializer(serializers.ModelSerializer):
@@ -14,11 +13,8 @@ class UserSerializer(serializers.ModelSerializer):
'first_name': {'required': True, 'allow_blank': False}
}
def _get_assigned_asset(self, obj):
try:
return obj.assigned_vehicles
except ObjectDoesNotExist:
return None
def _get_assigned_assets(self, obj):
return obj.assigned_vehicles.all()
def _serialize_asset(self, vehicle):
if not vehicle:
@@ -34,10 +30,14 @@ class UserSerializer(serializers.ModelSerializer):
}
def get_assigned_vehicle(self, obj):
return self._serialize_asset(self._get_assigned_asset(obj))
assigned = self._get_assigned_assets(obj)
vehicle = assigned.filter(asset_type='vehicle').first() or assigned.first()
return self._serialize_asset(vehicle)
def get_assigned_crane(self, obj):
return self._serialize_asset(self._get_assigned_asset(obj))
assigned = self._get_assigned_assets(obj)
crane = assigned.filter(asset_type='crane').first() or assigned.first()
return self._serialize_asset(crane)
def validate(self, data):
"""
@@ -66,7 +66,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
fields = ['id', 'email', 'full_name', 'is_team_member']
fields = ['id', 'email', 'full_name', 'is_team_member', 'is_serviser']
read_only_fields = fields
def get_full_name(self, obj):

View File

@@ -0,0 +1,39 @@
import uuid
import pytest
from django.contrib.auth import get_user_model
@pytest.mark.django_db
def test_new_user_gets_prefilled_signature():
User = get_user_model()
suffix = uuid.uuid4().hex[:8]
user = User.objects.create_user(
email=f"signature-{suffix}@example.test",
password="test1234",
first_name="Mario",
last_name="Tkalac",
telefon="+385916363466",
)
signature = user.signature
assert signature.startswith("Poslano iz aplikacije\n")
assert "S poštovanjem / Freundliche Grüße / Kind regards" in signature
assert "Mario Tkalac" in signature
assert "KNEZ LJUBO d.o.o." in signature
assert "Office: Donje Svetice 40, 10000 Zagreb" in signature
assert "Mobile: +385916363466" in signature
assert "www.knezljubo.hr" in signature
@pytest.mark.django_db
def test_custom_signature_is_preserved():
User = get_user_model()
user = User.objects.create_user(
email=f"custom-signature-{uuid.uuid4().hex[:8]}@example.test",
password="test1234",
signature="Custom email signature",
)
assert user.signature == "Custom email signature"
assert user.get_email_signature() == "Custom email signature"

View File

@@ -1,4 +1,5 @@
import pytest
from django.contrib.auth.models import Group
from django.contrib.auth import get_user_model
User = get_user_model()
@@ -22,4 +23,20 @@ def test_create_superuser():
password="password123"
)
assert admin.is_superuser is True
assert admin.is_staff is True
assert admin.is_staff is True
@pytest.mark.django_db
def test_user_groups_many_to_many_relation():
user = User.objects.create_user(
email="m2m@example.com",
password="password123",
)
group_a = Group.objects.create(name="Serviseri")
group_b = Group.objects.create(name="Dispeceri")
user.groups.add(group_a, group_b)
assert user.groups.count() == 2
assert group_a.user_set.filter(id=user.id).exists()
assert group_b.user_set.filter(id=user.id).exists()

View File

@@ -58,4 +58,29 @@ class TestUserAPI:
self.user.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert self.user.first_name == "Inženjer"
assert self.user.first_name == "Inženjer"
def test_get_servicers_returns_only_servicers(self):
serviser = User.objects.create_user(
email="serviser@test.hr",
first_name="Serviser",
last_name="Jedan",
password="strongpassword123",
is_serviser=True,
is_team_member=True,
)
non_serviser = User.objects.create_user(
email="nons@test.hr",
first_name="Nije",
last_name="Serviser",
password="strongpassword123",
is_serviser=False,
is_team_member=True,
)
self.client.force_authenticate(user=self.user)
url = reverse('user-servicers')
response = self.client.get(url)
assert response.status_code == status.HTTP_200_OK
returned_ids = {str(item['id']) for item in response.data}
assert str(serviser.id) in returned_ids
assert str(non_serviser.id) not in returned_ids

View File

@@ -30,6 +30,12 @@ class UserViewSet(viewsets.ModelViewSet):
serializer = TeamMemberSerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], url_path='servicers')
def servicers(self, request):
queryset = User.objects.filter(is_active=True, is_serviser=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(

103
backend/entrypoint.sh Normal file → Executable file
View File

@@ -1,101 +1,22 @@
#!/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 "--> Pokrećem migracije..."
python manage.py migrate --noinput
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..."
python manage.py collectstatic --noinput
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
echo "--> Pokrećem Gunicorn..."
exec 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 "$@"
echo "--> Pokrećem Celery..."
shift
exec celery "$@"
else
exec "$@"
fi
exec "$@"
fi

View File

View File

@@ -0,0 +1,10 @@
CONTACT ID,EMAIL,sib_contact_owner,LASTNAME,FIRSTNAME,_DETECTED_LANGUAGE,DOUBLE_OPT-IN,OPT_IN,CONTACT_TIMEZONE,EXT_ID,LINKEDIN,JOB_TITLE,WHATSAPP,LANDLINE_NUMBER,SMS,ADDED_TIME,MODIFIED_TIME
1,mtkalac.mt@gmail.com,,TKALAC,MARIO,,,,,,,,,,385955002483,20-07-2026,20-07-2026
2,domagoj@knezljubo.hr,,Knez,Domagoj,Croatian,No,NO,(GMT+02:00) Zagreb,domagojknezljubo,,direktor,385913228904,,385913228904,20-07-2026,20-07-2026
3,ljubo@knezljubo.hr,,Ljubo,Knez,Croatian,No,NO,(GMT+02:00) Zagreb,ljuboknezljubo,,direktor,385913228902,,385913228902,20-07-2026,20-07-2026
4,mario.tkalac@knezljubo.hr,,Tkalac,Mario,Croatian,No,NO,(GMT+02:00) Zagreb,mariotkalacknezljubo,,serviser,385916303466,,385916303466,20-07-2026,20-07-2026
5,fran@knezljubo.hr,,Mati<EFBFBD>,Fran,Croatian,No,NO,(GMT+02:00) Zagreb,franmaticknezljubo,,prodaja,385916004943,,385916004943,20-07-2026,20-07-2026
6,iva.markic@knezljubo.hr,,Marki<EFBFBD>,Iva,Croatian,No,NO,(GMT+02:00) Zagreb,ivamarkicknezljubo,,knjigovo<EFBFBD>a,385919423525,,385919423525,20-07-2026,20-07-2026
7,ramo@knezljubo.hr,,Husanovi<EFBFBD>,Ramo,Croatian,No,NO,(GMT+02:00) Zagreb,ramohusanovicknezljubo,,serviser,385913228910,,385913228910,20-07-2026,20-07-2026
8,office@knezljubo.hr,,Cepi<EFBFBD>,Dario,Croatian,No,NO,(GMT+02:00) Zagreb,dariocepicknezljubo,logistika,385994067776,,385994067776,20-07-2026,20-07-2026
9,josip@knezljubo.hr,,Pintari<EFBFBD>,Josip,Croatian,No,NO,(GMT+02:00) Zagreb,josippintaricknezljubo,serviser,385913228906,,385913228906,20-07-2026,20-07-2026
1 CONTACT ID,EMAIL,sib_contact_owner,LASTNAME,FIRSTNAME,_DETECTED_LANGUAGE,DOUBLE_OPT-IN,OPT_IN,CONTACT_TIMEZONE,EXT_ID,LINKEDIN,JOB_TITLE,WHATSAPP,LANDLINE_NUMBER,SMS,ADDED_TIME,MODIFIED_TIME
2 1,mtkalac.mt@gmail.com,,TKALAC,MARIO,,,,,,,,,,385955002483,20-07-2026,20-07-2026
3 2,domagoj@knezljubo.hr,,Knez,Domagoj,Croatian,No,NO,(GMT+02:00) Zagreb,domagojknezljubo,,direktor,385913228904,,385913228904,20-07-2026,20-07-2026
4 3,ljubo@knezljubo.hr,,Ljubo,Knez,Croatian,No,NO,(GMT+02:00) Zagreb,ljuboknezljubo,,direktor,385913228902,,385913228902,20-07-2026,20-07-2026
5 4,mario.tkalac@knezljubo.hr,,Tkalac,Mario,Croatian,No,NO,(GMT+02:00) Zagreb,mariotkalacknezljubo,,serviser,385916303466,,385916303466,20-07-2026,20-07-2026
6 5,fran@knezljubo.hr,,Matiæ,Fran,Croatian,No,NO,(GMT+02:00) Zagreb,franmaticknezljubo,,prodaja,385916004943,,385916004943,20-07-2026,20-07-2026
7 6,iva.markic@knezljubo.hr,,Markiæ,Iva,Croatian,No,NO,(GMT+02:00) Zagreb,ivamarkicknezljubo,,knjigovoða,385919423525,,385919423525,20-07-2026,20-07-2026
8 7,ramo@knezljubo.hr,,Husanoviæ,Ramo,Croatian,No,NO,(GMT+02:00) Zagreb,ramohusanovicknezljubo,,serviser,385913228910,,385913228910,20-07-2026,20-07-2026
9 8,office@knezljubo.hr,,Cepiæ,Dario,Croatian,No,NO,(GMT+02:00) Zagreb,dariocepicknezljubo,logistika,385994067776,,385994067776,20-07-2026,20-07-2026
10 9,josip@knezljubo.hr,,Pintariæ,Josip,Croatian,No,NO,(GMT+02:00) Zagreb,josippintaricknezljubo,serviser,385913228906,,385913228906,20-07-2026,20-07-2026

View File

@@ -0,0 +1,11 @@
name,client_type,tax_id,email,phone,address,city,postal_code
Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000
BOSMAN,legal,10000000002,bosman@example.com,+38591111002,Ulica 2,Zagreb,10000
BOSSIL,legal,10000000003,bossil@example.com,+38591111003,Ulica 3,Zagreb,10000
PAKLOG,legal,10000000004,paklog@example.com,+38591111004,Ulica 4,Zagreb,10000
Palace,legal,10000000005,palace@example.com,+38591111005,Ulica 5,Zagreb,10000
Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320
Paron,legal,10000000007,paron@example.com,+38591111007,Ulica 7,Zagreb,10000
PRANGL,legal,10000000008,prangl@example.com,+38591111008,Ulica 8,Zagreb,10000
Salona MONT,legal,10000000009,salona.mont@example.com,+38591111009,Ulica 9,Split,21000
STRABAG,legal,10000000010,strabag@example.com,+38591111010,Ulica 10,Zagreb,10000
1 name client_type tax_id email phone address city postal_code
2 Adriacink transport d.o.o. legal 10000000001 adriacink.transport@example.com +38591111001 Ulica 1 Zagreb 10000
3 BOSMAN legal 10000000002 bosman@example.com +38591111002 Ulica 2 Zagreb 10000
4 BOSSIL legal 10000000003 bossil@example.com +38591111003 Ulica 3 Zagreb 10000
5 PAKLOG legal 10000000004 paklog@example.com +38591111004 Ulica 4 Zagreb 10000
6 Palace legal 10000000005 palace@example.com +38591111005 Ulica 5 Zagreb 10000
7 Petrokemija Kutina legal 10000000006 petrokemija.kutina@example.com +38591111006 Ulica 6 Kutina 44320
8 Paron legal 10000000007 paron@example.com +38591111007 Ulica 7 Zagreb 10000
9 PRANGL legal 10000000008 prangl@example.com +38591111008 Ulica 8 Zagreb 10000
10 Salona MONT legal 10000000009 salona.mont@example.com +38591111009 Ulica 9 Split 21000
11 STRABAG legal 10000000010 strabag@example.com +38591111010 Ulica 10 Zagreb 10000

View File

@@ -0,0 +1,130 @@
import csv
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.core.exceptions import ValidationError
from django.db import transaction
from modules.crm.models import Client
class Command(BaseCommand):
help = "Uvozi kupce (vlasnike dizalica) iz CSV datoteke u Client model."
expected_columns = [
"name",
"client_type",
"tax_id",
"email",
"phone",
"address",
"city",
"postal_code",
]
def add_arguments(self, parser):
parser.add_argument("csv_path", type=str, help="Putanja do CSV datoteke.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Validira i simulira import bez spremanja u bazu.",
)
parser.add_argument(
"--create-only",
action="store_true",
help="Kreira samo nove kupce; postojeće preskače.",
)
def handle(self, *args, **options):
csv_path = Path(options["csv_path"]).expanduser().resolve()
dry_run = options["dry_run"]
create_only = options["create_only"]
if not csv_path.exists():
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
created_count = 0
updated_count = 0
skipped_count = 0
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CommandError("CSV nema zaglavlje (header).")
normalized_headers = [str(h).strip() for h in reader.fieldnames]
missing = [column for column in self.expected_columns if column not in normalized_headers]
if missing:
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
row_number = 1
with transaction.atomic():
for row in reader:
row_number += 1
if not any((value or "").strip() for value in row.values()):
continue
tax_id = (row.get("tax_id") or "").strip()
email = (row.get("email") or "").strip().lower()
if not tax_id:
raise CommandError(f"Red {row_number}: tax_id je obavezan.")
if not email:
raise CommandError(f"Red {row_number}: email je obavezan.")
client = Client.objects.filter(tax_id=tax_id).first()
if client and create_only:
skipped_count += 1
self.stdout.write(
self.style.WARNING(
f"Red {row_number}: preskočeno (kupac već postoji) tax_id={tax_id}"
)
)
continue
is_create = client is None
if is_create:
client = Client(tax_id=tax_id)
client_type = (row.get("client_type") or "legal").strip()
if client_type not in {"legal", "individual"}:
raise CommandError(
f"Red {row_number}: client_type mora biti 'legal' ili 'individual'."
)
client.name = (row.get("name") or "").strip()
client.client_type = client_type
client.email = email
client.phone = (row.get("phone") or "").strip() or None
client.address = (row.get("address") or "").strip()
client.city = (row.get("city") or "").strip()
client.postal_code = (row.get("postal_code") or "").strip()
try:
client.full_clean()
except ValidationError as exc:
raise CommandError(
f"Red {row_number}: validacija nije prošla -> {exc.message_dict}"
) from exc
if not dry_run:
client.save()
if is_create:
created_count += 1
else:
updated_count += 1
self.stdout.write(
self.style.SUCCESS(
f"Red {row_number}: {'kreiran' if is_create else 'ažuriran'} kupac {client.name}"
)
)
if dry_run:
transaction.set_rollback(True)
summary = (
f"Import kupaca završen. created={created_count}, updated={updated_count}, "
f"skipped={skipped_count}, dry_run={dry_run}"
)
self.stdout.write(self.style.SUCCESS(summary))

View File

@@ -1,3 +1,4 @@
from pathlib import Path
from django.contrib import admin
from django.contrib.admin import ModelAdmin
from django.utils.html import format_html
@@ -5,7 +6,12 @@ from .models import (
Crane,
Vehicle,
WorkOrder,
WorkOrderPhoto,
WorkOrderInvoice,
WorkOrderAdditionalCostsTable,
ServiceContextNote,
GeneratedWorkOrderPdf,
EmailDispatchLog,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
@@ -42,7 +48,7 @@ class ServiceAttachmentInline(admin.TabularInline):
class WorkOrderInline(admin.TabularInline):
model = WorkOrder
extra = 0
fields = ('creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
readonly_fields = ('date',)
@@ -56,8 +62,47 @@ class ServiceRecordInline(admin.TabularInline):
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',)
fields = ('thumbnail_preview', 'naziv_racuna', 'lokacija', 'datum', 'opis', 'image', 'created_by', 'created_at', 'is_active')
readonly_fields = ('thumbnail_preview', 'created_at')
@admin.display(description="Pregled")
def thumbnail_preview(self, obj):
if not obj.pk or not obj.image:
return ""
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return format_html(
'<a href="/api/fleet/work-order-invoices/{}/image/" target="_blank">📄 PDF</a>',
obj.pk,
)
return format_html(
'<a href="/api/fleet/work-order-invoices/{}/image/?w=1600&q=90&fmt=jpeg" target="_blank">'
'<img src="/api/fleet/work-order-invoices/{}/image/?w=200&q=75&fmt=jpeg" '
'style="height:64px;width:auto;border-radius:4px;object-fit:cover;" />'
'</a>',
obj.pk,
obj.pk,
)
class WorkOrderPhotoInline(admin.TabularInline):
model = WorkOrderPhoto
extra = 0
fields = ('thumbnail_preview', 'image', 'description', 'uploaded_by', 'created_at', 'is_active')
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(
'<a href="{}" target="_blank">'
'<img src="{}" style="height:64px;width:auto;border-radius:4px;object-fit:cover;" />'
'</a>',
obj.image.url,
obj.image.url,
)
return ""
@admin.register(Vehicle)
@@ -154,12 +199,12 @@ class CraneAdmin(VehicleAdmin):
@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_display = ('display_code', 'id', 'vehicle', 'creator', 'date', 'origin_location', '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')
search_fields = ('display_code', 'vehicle__registration_number', 'creator__username', 'purpose')
readonly_fields = ('date',)
ordering = ('-date',)
inlines = (WorkOrderInvoiceInline,)
inlines = (WorkOrderInvoiceInline, WorkOrderPhotoInline)
def distance_display(self, obj):
return obj.distance
@@ -168,10 +213,56 @@ class WorkOrderAdmin(admin.ModelAdmin):
@admin.register(WorkOrderInvoice)
class WorkOrderInvoiceAdmin(admin.ModelAdmin):
list_display = ('id', 'work_order', 'naziv_racuna', 'lokacija', 'datum', 'created_by', 'is_active')
list_display = ('id', 'image_preview', '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')
readonly_fields = ('image_preview', 'created_at')
@admin.display(description="Slika")
def image_preview(self, obj):
if not obj.pk or not obj.image:
return ""
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return format_html(
'<a href="/api/fleet/work-order-invoices/{}/image/" target="_blank">📄 PDF</a>',
obj.pk,
)
return format_html(
'<a href="/api/fleet/work-order-invoices/{}/image/?w=1600&q=90&fmt=jpeg" target="_blank">'
'<img src="/api/fleet/work-order-invoices/{}/image/?w=200&q=75&fmt=jpeg" '
'style="height:80px;width:auto;border-radius:4px;object-fit:cover;" />'
'</a>',
obj.pk,
obj.pk,
)
@admin.register(WorkOrderAdditionalCostsTable)
class WorkOrderAdditionalCostsTableAdmin(admin.ModelAdmin):
list_display = ('id', 'work_order', 'total_for_payout', 'updated_at')
search_fields = ('work_order__display_code', 'work_order__vehicle__registration_number')
readonly_fields = ('created_at', 'updated_at')
ordering = ('-updated_at',)
@admin.register(ServiceContextNote)
class ServiceContextNoteAdmin(admin.ModelAdmin):
list_display = ('id', 'recipient', 'created_by', 'audience_source', 'note_date', 'is_closed', 'created_at')
list_filter = ('audience_source', 'is_closed', 'note_date')
search_fields = ('note', 'recipient__email', 'recipient__first_name', 'recipient__last_name', 'created_by__email')
readonly_fields = ('created_at', 'updated_at', 'closed_at')
ordering = ('is_closed', 'note_date', '-created_at')
@admin.register(GeneratedWorkOrderPdf)
class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin):
list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at')
list_filter = ('pdf_type', 'status', 'is_active', 'expires_at')
search_fields = ('work_order__id', 'requested_by__username', 'requested_by__email', 'filename')
readonly_fields = ('created_at', 'updated_at')
ordering = ('-created_at',)
@admin.register(VehicleServiceRecord)
class VehicleServiceRecordAdmin(admin.ModelAdmin):
@@ -194,4 +285,50 @@ class VehicleServiceRecordAdmin(admin.ModelAdmin):
def short_description(self, obj):
if obj.description:
return obj.description[:60] + ('' if len(obj.description) > 60 else '')
return ""
return ""
@admin.register(EmailDispatchLog)
class EmailDispatchLogAdmin(admin.ModelAdmin):
list_display = (
'id',
'dispatch_type',
'status',
'requested_by',
'work_order',
'service_record',
'recipients_count',
'sent_at',
'created_at',
)
list_filter = ('dispatch_type', 'status', 'created_at', 'sent_at')
search_fields = (
'subject',
'message',
'error_message',
'requested_by__email',
'requested_by__username',
'work_order__display_code',
)
readonly_fields = (
'dispatch_type',
'requested_by',
'work_order',
'service_record',
'recipients',
'subject',
'message',
'attachments',
'status',
'error_message',
'metadata',
'sent_at',
'created_at',
'updated_at',
)
ordering = ('-created_at',)
@admin.display(description='Primatelja')
def recipients_count(self, obj):
recipients = obj.recipients or []
return len(recipients) if isinstance(recipients, list) else 0

Binary file not shown.

View File

@@ -0,0 +1,17 @@
def append_user_signature(body, user):
body_text = str(body or '').strip()
if user is None:
return body_text
signature = ''
get_signature = getattr(user, 'get_email_signature', None)
if callable(get_signature):
signature = str(get_signature() or '').strip()
else:
signature = str(getattr(user, 'signature', '') or '').strip()
if not signature:
return body_text
if not body_text:
return signature
return f"{body_text}\n\n{signature}"

View File

@@ -0,0 +1,2 @@
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,assigned_servicer_email,is_active
ZG-CRN-001,123456,Liebherr,LTM 1050,2022,WLH1234567890ABCD,1200,800,45200,15000,true,,serviser1@example.com,true
1 registration_number crane_serial_number make model year vin superstructure_working_hours chassis_working_hours current_mileage service_interval_km is_company_vehicle client_id assigned_servicer_email is_active
2 ZG-CRN-001 123456 Liebherr LTM 1050 2022 WLH1234567890ABCD 1200 800 45200 15000 true serviser1@example.com true

View File

@@ -0,0 +1,2 @@
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,client_name,client_type,client_tax_id,client_email,client_phone,client_address,client_city,client_postal_code,assigned_servicer_email,is_active
ZG-CRN-001,123456,Liebherr,LTM 1050,2022,WLH1234567890ABCD,1200,800,45200,15000,false,,Kupac Primjer d.o.o.,legal,12345678901,kupac.primjer@example.com,+38591111222,Ulica 1,Zagreb,10000,serviser1@example.com,true
1 registration_number crane_serial_number make model year vin superstructure_working_hours chassis_working_hours current_mileage service_interval_km is_company_vehicle client_id client_name client_type client_tax_id client_email client_phone client_address client_city client_postal_code assigned_servicer_email is_active
2 ZG-CRN-001 123456 Liebherr LTM 1050 2022 WLH1234567890ABCD 1200 800 45200 15000 false Kupac Primjer d.o.o. legal 12345678901 kupac.primjer@example.com +38591111222 Ulica 1 Zagreb 10000 serviser1@example.com true

View File

@@ -0,0 +1,12 @@
registration_number,crane_serial_number,make,model,year,vin,superstructure_working_hours,chassis_working_hours,current_mileage,service_interval_km,is_company_vehicle,client_id,client_name,client_type,client_tax_id,client_email,client_phone,client_address,client_city,client_postal_code,assigned_servicer_email,is_active
ZG-LTM-001,210501,Liebherr,LTM 1350,2021,WLHZGLTM0012021AA,3400,2900,128450,15000,false,,Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000,serviser1@example.com,true
ZG-LTM-002,210502,Liebherr,LTM 1100,2022,WLHZGLTM0022022BB,2850,2410,96420,15000,false,,PAKLOG,legal,10000000004,paklog@example.com,+38591111004,Ulica 4,Zagreb,10000,serviser2@example.com,true
ZG-LTM-003,210503,Liebherr,LTM 1110,2023,WLHZGLTM0032023CC,1620,1395,54780,15000,false,,Palace,legal,10000000005,palace@example.com,+38591111005,Ulica 5,Zagreb,10000,serviser3@example.com,true
ZG-LTM-004,210504,Liebherr,LTM 1750,2021,WLHZGLTM0042021UA,3400,2900,128450,15000,false,,Adriacink transport d.o.o.,legal,10000000001,adriacink.transport@example.com,+38591111001,Ulica 1,Zagreb,10000,serviser1@example.com,true
ZG-LTM-005,210505,Liebherr,LTM 1500,2021,WLHZGLTM0052023AA,3400,2900,128450,15000,false,,BOSMAN,legal,10000000002,bosman@example.com,+38591111002,Ulica 2,Zagreb,10000,serviser1@example.com,true
ZG-LTM-006,210506,Liebherr,LTM 1060,2022,WLHZGLTM0062022BB,2850,2410,96420,15000,false,,BOSSIL,legal,10000000003,bossil@example.com,+38591111003,Ulica 3,Zagreb,10000,serviser2@example.com,true
ZG-LTM-007,210507,Liebherr,LTM 1060,2022,WLHZGLTM0072022BB,2850,2410,96420,15000,false,,Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320,serviser3@example.com,true
ZG-LTM-008,210508,Liebherr,LTM 1040,2022,WLHZGLTM0082022BB,2850,2410,96420,15000,false,,Paron,legal,10000000007,paron@example.com,+38591111007,Ulica 7,Zagreb,10000,serviser3@example.com,true
ZG-LTM-009,210509,Liebherr,LTM 1040,2022,WLHZGLTM0092022BB,2850,2410,96420,15000,false,,Petrokemija Kutina,legal,10000000006,petrokemija.kutina@example.com,+38591111006,Ulica 6,Kutina,44320,serviser3@example.com,true
ZG-LTM-010,210510,Liebherr,LTM 1250,2022,WLHZGLTM0102022BB,2850,2410,96420,15000,false,,PRANGL,legal,10000000008,prangl@example.com,+38591111008,Ulica 8,Zagreb,10000,serviser3@example.com,true
ZG-LTM-011,210511,Liebherr,LTM 1090,2022,WLHZGLTM0112022BB,2850,2410,96420,15000,false,,Salona MONT,legal,10000000009,salona.mont@example.com,+38591111009,Ulica 9,Split,21000,serviser3@example.com,true
1 registration_number crane_serial_number make model year vin superstructure_working_hours chassis_working_hours current_mileage service_interval_km is_company_vehicle client_id client_name client_type client_tax_id client_email client_phone client_address client_city client_postal_code assigned_servicer_email is_active
2 ZG-LTM-001 210501 Liebherr LTM 1350 2021 WLHZGLTM0012021AA 3400 2900 128450 15000 false Adriacink transport d.o.o. legal 10000000001 adriacink.transport@example.com +38591111001 Ulica 1 Zagreb 10000 serviser1@example.com true
3 ZG-LTM-002 210502 Liebherr LTM 1100 2022 WLHZGLTM0022022BB 2850 2410 96420 15000 false PAKLOG legal 10000000004 paklog@example.com +38591111004 Ulica 4 Zagreb 10000 serviser2@example.com true
4 ZG-LTM-003 210503 Liebherr LTM 1110 2023 WLHZGLTM0032023CC 1620 1395 54780 15000 false Palace legal 10000000005 palace@example.com +38591111005 Ulica 5 Zagreb 10000 serviser3@example.com true
5 ZG-LTM-004 210504 Liebherr LTM 1750 2021 WLHZGLTM0042021UA 3400 2900 128450 15000 false Adriacink transport d.o.o. legal 10000000001 adriacink.transport@example.com +38591111001 Ulica 1 Zagreb 10000 serviser1@example.com true
6 ZG-LTM-005 210505 Liebherr LTM 1500 2021 WLHZGLTM0052023AA 3400 2900 128450 15000 false BOSMAN legal 10000000002 bosman@example.com +38591111002 Ulica 2 Zagreb 10000 serviser1@example.com true
7 ZG-LTM-006 210506 Liebherr LTM 1060 2022 WLHZGLTM0062022BB 2850 2410 96420 15000 false BOSSIL legal 10000000003 bossil@example.com +38591111003 Ulica 3 Zagreb 10000 serviser2@example.com true
8 ZG-LTM-007 210507 Liebherr LTM 1060 2022 WLHZGLTM0072022BB 2850 2410 96420 15000 false Petrokemija Kutina legal 10000000006 petrokemija.kutina@example.com +38591111006 Ulica 6 Kutina 44320 serviser3@example.com true
9 ZG-LTM-008 210508 Liebherr LTM 1040 2022 WLHZGLTM0082022BB 2850 2410 96420 15000 false Paron legal 10000000007 paron@example.com +38591111007 Ulica 7 Zagreb 10000 serviser3@example.com true
10 ZG-LTM-009 210509 Liebherr LTM 1040 2022 WLHZGLTM0092022BB 2850 2410 96420 15000 false Petrokemija Kutina legal 10000000006 petrokemija.kutina@example.com +38591111006 Ulica 6 Kutina 44320 serviser3@example.com true
11 ZG-LTM-010 210510 Liebherr LTM 1250 2022 WLHZGLTM0102022BB 2850 2410 96420 15000 false PRANGL legal 10000000008 prangl@example.com +38591111008 Ulica 8 Zagreb 10000 serviser3@example.com true
12 ZG-LTM-011 210511 Liebherr LTM 1090 2022 WLHZGLTM0112022BB 2850 2410 96420 15000 false Salona MONT legal 10000000009 salona.mont@example.com +38591111009 Ulica 9 Split 21000 serviser3@example.com true

View File

@@ -0,0 +1,4 @@
registration_number,make,model,year,vin,current_mileage,service_interval_km,assigned_servicer_email,is_active
ZG-SRV-001,Volkswagen,Caddy,2021,WV1ZZZ2KZMX000001,128400,15000,serviser1@example.com,true
ZG-SRV-002,Renault,Trafic,2022,VF1FL0000NY000002,96420,15000,serviser2@example.com,true
ZG-SRV-003,Ford,Transit Custom,2023,WF0YXXTTGYPU00003,54780,15000,serviser3@example.com,true
1 registration_number make model year vin current_mileage service_interval_km assigned_servicer_email is_active
2 ZG-SRV-001 Volkswagen Caddy 2021 WV1ZZZ2KZMX000001 128400 15000 serviser1@example.com true
3 ZG-SRV-002 Renault Trafic 2022 VF1FL0000NY000002 96420 15000 serviser2@example.com true
4 ZG-SRV-003 Ford Transit Custom 2023 WF0YXXTTGYPU00003 54780 15000 serviser3@example.com true

View File

@@ -0,0 +1,283 @@
import csv
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.core.exceptions import ValidationError
from django.db import transaction
from modules.crm.models import Client
from modules.fleet.models import Vehicle
def _to_bool(value, field_name):
if value is None or value == "":
return None
normalized = str(value).strip().lower()
if normalized in {"1", "true", "t", "yes", "y", "da"}:
return True
if normalized in {"0", "false", "f", "no", "n", "ne"}:
return False
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
def _to_int(value, field_name):
if value is None or value == "":
return None
try:
return int(str(value).strip())
except (TypeError, ValueError) as exc:
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
class Command(BaseCommand):
help = "Uvozi dizalice iz CSV datoteke i sprema ih u Vehicle model (asset_type=crane)."
expected_columns = [
"registration_number",
"crane_serial_number",
"make",
"model",
"year",
"vin",
"superstructure_working_hours",
"chassis_working_hours",
"current_mileage",
"service_interval_km",
"is_company_vehicle",
"client_id",
"assigned_servicer_email",
"is_active",
]
optional_client_columns = [
"client_name",
"client_type",
"client_tax_id",
"client_email",
"client_phone",
"client_address",
"client_city",
"client_postal_code",
]
def add_arguments(self, parser):
parser.add_argument(
"csv_path",
type=str,
help="Apsolutna ili relativna putanja do CSV datoteke.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validira CSV i ispisuje rezultat bez spremanja u bazu.",
)
parser.add_argument(
"--create-only",
action="store_true",
help="Kreira samo nove zapise; postojeće registracije se preskaču.",
)
def handle(self, *args, **options):
csv_path = Path(options["csv_path"]).expanduser().resolve()
dry_run = options["dry_run"]
create_only = options["create_only"]
if not csv_path.exists():
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
created_count = 0
updated_count = 0
skipped_count = 0
User = get_user_model()
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CommandError("CSV nema zaglavlje (header).")
normalized_headers = [str(h).strip() for h in reader.fieldnames]
missing = [column for column in self.expected_columns if column not in normalized_headers]
if missing:
raise CommandError(
"CSV nema obavezna polja: " + ", ".join(missing)
)
row_number = 1
with transaction.atomic():
for row in reader:
row_number += 1
if not any((value or "").strip() for value in row.values()):
continue
registration_number = (row.get("registration_number") or "").strip()
crane_serial_number = (row.get("crane_serial_number") or "").strip()
if not registration_number:
raise CommandError(f"Red {row_number}: registration_number je obavezan.")
if not crane_serial_number:
raise CommandError(f"Red {row_number}: crane_serial_number je obavezan.")
vehicle = Vehicle.objects.filter(registration_number=registration_number).first()
if vehicle and create_only:
skipped_count += 1
self.stdout.write(
self.style.WARNING(
f"Red {row_number}: preskočeno (već postoji) {registration_number}"
)
)
continue
if vehicle is None:
vehicle = Vehicle(registration_number=registration_number)
was_created = True
else:
was_created = False
assigned_servicer = None
assigned_servicer_email = (row.get("assigned_servicer_email") or "").strip()
if assigned_servicer_email:
assigned_servicer = User.objects.filter(email=assigned_servicer_email).first()
if not assigned_servicer:
raise CommandError(
f"Red {row_number}: korisnik s emailom '{assigned_servicer_email}' ne postoji."
)
is_company_vehicle = _to_bool(row.get("is_company_vehicle"), "is_company_vehicle")
is_active = _to_bool(row.get("is_active"), "is_active")
if is_company_vehicle is None:
is_company_vehicle = True
client = self._resolve_or_create_client(
row=row,
row_number=row_number,
is_company_vehicle=is_company_vehicle,
)
vehicle.asset_type = "crane"
vehicle.crane_serial_number = crane_serial_number
vehicle.make = (row.get("make") or "").strip()
vehicle.model = (row.get("model") or "").strip()
vehicle.year = _to_int(row.get("year"), "year")
vehicle.vin = (row.get("vin") or "").strip() or None
vehicle.superstructure_working_hours = _to_int(
row.get("superstructure_working_hours"),
"superstructure_working_hours",
) or 0
vehicle.chassis_working_hours = _to_int(
row.get("chassis_working_hours"),
"chassis_working_hours",
) or 0
vehicle.current_mileage = _to_int(row.get("current_mileage"), "current_mileage") or 0
vehicle.service_interval_km = _to_int(
row.get("service_interval_km"),
"service_interval_km",
) or 15000
if is_company_vehicle is not None:
vehicle.is_company_vehicle = is_company_vehicle
vehicle.client = client
vehicle.assigned_servicer = assigned_servicer
if is_active is not None:
vehicle.is_active = is_active
try:
vehicle.full_clean()
except ValidationError as exc:
raise CommandError(f"Red {row_number}: validacija nije prošla -> {exc.message_dict}") from exc
if not dry_run:
vehicle.save()
if was_created:
created_count += 1
else:
updated_count += 1
self.stdout.write(
self.style.SUCCESS(
f"Red {row_number}: {'kreirano' if was_created else 'ažurirano'} {registration_number}"
)
)
if dry_run:
transaction.set_rollback(True)
summary = (
f"Import završen. created={created_count}, updated={updated_count}, "
f"skipped={skipped_count}, dry_run={dry_run}"
)
self.stdout.write(self.style.SUCCESS(summary))
def _resolve_or_create_client(self, *, row, row_number, is_company_vehicle):
client_id = _to_int(row.get("client_id"), "client_id")
if client_id:
client = Client.objects.filter(id=client_id).first()
if not client:
raise CommandError(
f"Red {row_number}: klijent s ID={client_id} ne postoji."
)
return client
client_tax_id = (row.get("client_tax_id") or "").strip()
client_email = (row.get("client_email") or "").strip().lower()
client_name = (row.get("client_name") or "").strip()
# Reuse pattern iz postojećih seed komandi: pokušaj pronaći postojeći zapis prije kreiranja.
if client_tax_id:
existing = Client.objects.filter(tax_id=client_tax_id).first()
if existing:
return existing
if client_email:
existing = Client.objects.filter(email__iexact=client_email).first()
if existing:
return existing
if client_name:
existing = Client.objects.filter(name=client_name).first()
if existing:
return existing
if is_company_vehicle:
return None
if not any([client_tax_id, client_email, client_name]):
raise CommandError(
f"Red {row_number}: za klijentsku dizalicu (is_company_vehicle=false) "
"morate navesti client_id ili client podatke za kreiranje kupca."
)
missing_for_create = []
create_fields = {
"name": client_name,
"client_type": (row.get("client_type") or "legal").strip() or "legal",
"tax_id": client_tax_id,
"email": client_email,
"phone": (row.get("client_phone") or "").strip() or None,
"address": (row.get("client_address") or "").strip(),
"city": (row.get("client_city") or "").strip(),
"postal_code": (row.get("client_postal_code") or "").strip(),
}
for required_key in ["name", "tax_id", "email", "address", "city", "postal_code"]:
if not create_fields[required_key]:
missing_for_create.append(required_key)
if missing_for_create:
raise CommandError(
f"Red {row_number}: nedostaju podaci za kreiranje kupca: {', '.join(missing_for_create)}"
)
if create_fields["client_type"] not in {"legal", "individual"}:
raise CommandError(
f"Red {row_number}: client_type mora biti 'legal' ili 'individual'."
)
client, was_created = Client.objects.get_or_create(
tax_id=create_fields["tax_id"],
defaults=create_fields,
)
if was_created:
self.stdout.write(
self.style.SUCCESS(
f"Red {row_number}: kreiran kupac '{client.name}' (OIB/VAT: {client.tax_id})"
)
)
return client

View File

@@ -0,0 +1,267 @@
import csv
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from modules.fleet.models import Vehicle
def _to_bool(value, field_name):
if value is None or value == "":
return None
normalized = str(value).strip().lower()
if normalized in {"1", "true", "t", "yes", "y", "da"}:
return True
if normalized in {"0", "false", "f", "no", "n", "ne"}:
return False
raise CommandError(f"Neispravna boolean vrijednost za '{field_name}': {value}")
def _to_int(value, field_name):
if value is None or value == "":
return None
try:
return int(str(value).strip())
except (TypeError, ValueError) as exc:
raise CommandError(f"Neispravna brojčana vrijednost za '{field_name}': {value}") from exc
class Command(BaseCommand):
help = (
"Uvozi vozila servisera iz CSV datoteke i sprema ih u Vehicle model "
"(asset_type=vehicle, is_company_vehicle=true)."
)
expected_columns = [
"registration_number",
"make",
"model",
"year",
"vin",
"current_mileage",
"service_interval_km",
"assigned_servicer_email",
"is_active",
]
users_template_columns = [
"email",
"is_serviser",
]
def add_arguments(self, parser):
parser.add_argument(
"csv_path",
type=str,
help="Apsolutna ili relativna putanja do CSV datoteke vozila.",
)
parser.add_argument(
"--users-csv-path",
type=str,
default="",
help=(
"Putanja do users_import_template.csv (fallback je "
"backend/core/users/import_templates/users_import_template.csv)."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validira CSV i ispisuje rezultat bez spremanja u bazu.",
)
parser.add_argument(
"--create-only",
action="store_true",
help="Kreira samo nove zapise; postojeće registracije se preskaču.",
)
def handle(self, *args, **options):
csv_path = self._resolve_input_csv_path(options["csv_path"])
users_csv_path = self._resolve_users_csv_path(options.get("users_csv_path") or "")
dry_run = options["dry_run"]
create_only = options["create_only"]
if not csv_path.exists():
raise CommandError(f"CSV datoteka ne postoji: {csv_path}")
if not users_csv_path.exists():
raise CommandError(f"users_import_template.csv ne postoji: {users_csv_path}")
allowed_servicer_emails = self._load_servicer_emails_from_template(users_csv_path)
if not allowed_servicer_emails:
raise CommandError(
"users_import_template.csv ne sadrži nijednog servisera (is_serviser=true)."
)
User = get_user_model()
created_count = 0
updated_count = 0
skipped_count = 0
with csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CommandError("CSV nema zaglavlje (header).")
normalized_headers = [str(h).strip() for h in reader.fieldnames]
missing = [column for column in self.expected_columns if column not in normalized_headers]
if missing:
raise CommandError("CSV nema obavezna polja: " + ", ".join(missing))
row_number = 1
with transaction.atomic():
for row in reader:
row_number += 1
if not any((value or "").strip() for value in row.values()):
continue
registration_number = (row.get("registration_number") or "").strip()
if not registration_number:
raise CommandError(f"Red {row_number}: registration_number je obavezan.")
assigned_servicer_email = (row.get("assigned_servicer_email") or "").strip().lower()
if not assigned_servicer_email:
raise CommandError(f"Red {row_number}: assigned_servicer_email je obavezan.")
if assigned_servicer_email not in allowed_servicer_emails:
raise CommandError(
f"Red {row_number}: '{assigned_servicer_email}' nije valjan serviser "
"u users_import_template.csv."
)
assigned_servicer = User.objects.filter(email__iexact=assigned_servicer_email).first()
if not assigned_servicer:
raise CommandError(
f"Red {row_number}: korisnik s emailom '{assigned_servicer_email}' ne postoji u bazi."
)
if not assigned_servicer.is_serviser:
raise CommandError(
f"Red {row_number}: korisnik '{assigned_servicer_email}' nije označen kao serviser."
)
vehicle = Vehicle.objects.filter(registration_number=registration_number).first()
if vehicle and create_only:
skipped_count += 1
self.stdout.write(
self.style.WARNING(
f"Red {row_number}: preskočeno (već postoji) {registration_number}"
)
)
continue
if vehicle is None:
vehicle = Vehicle(registration_number=registration_number)
was_created = True
else:
was_created = False
if vehicle.asset_type and vehicle.asset_type != "vehicle":
raise CommandError(
f"Red {row_number}: zapis {registration_number} već postoji kao "
f"asset_type='{vehicle.asset_type}', ne može se prepisati u 'vehicle'."
)
is_active = _to_bool(row.get("is_active"), "is_active")
vehicle.asset_type = "vehicle"
vehicle.make = (row.get("make") or "").strip()
vehicle.model = (row.get("model") or "").strip()
vehicle.year = _to_int(row.get("year"), "year")
vehicle.vin = (row.get("vin") or "").strip() or None
vehicle.current_mileage = _to_int(row.get("current_mileage"), "current_mileage") or 0
vehicle.service_interval_km = _to_int(
row.get("service_interval_km"),
"service_interval_km",
) or 15000
vehicle.is_company_vehicle = True
vehicle.client = None
vehicle.assigned_servicer = assigned_servicer
if is_active is not None:
vehicle.is_active = is_active
try:
vehicle.full_clean()
except ValidationError as exc:
raise CommandError(f"Red {row_number}: validacija nije prošla -> {exc.message_dict}") from exc
if not dry_run:
vehicle.save()
if was_created:
created_count += 1
else:
updated_count += 1
self.stdout.write(
self.style.SUCCESS(
f"Red {row_number}: {'kreirano' if was_created else 'ažurirano'} "
f"{registration_number} -> {assigned_servicer_email}"
)
)
if dry_run:
transaction.set_rollback(True)
summary = (
f"Import vozila servisera završen. created={created_count}, updated={updated_count}, "
f"skipped={skipped_count}, dry_run={dry_run}"
)
self.stdout.write(self.style.SUCCESS(summary))
def _resolve_input_csv_path(self, csv_path):
resolved = Path(csv_path).expanduser().resolve()
if resolved.exists():
return resolved
raw = str(csv_path).strip()
candidates = []
if "/backend/" in raw:
candidates.append(raw.replace("/backend/", "/", 1))
if "\\backend\\" in raw:
candidates.append(raw.replace("\\backend\\", "\\", 1))
if raw.startswith("backend/"):
candidates.append(raw.replace("backend/", "", 1))
if raw.startswith("backend\\"):
candidates.append(raw.replace("backend\\", "", 1))
for candidate in candidates:
candidate_path = Path(candidate).expanduser().resolve()
if candidate_path.exists():
self.stdout.write(
self.style.WARNING(
f"CSV nije pronađen na '{resolved}', koristim '{candidate_path}'."
)
)
return candidate_path
return resolved
def _resolve_users_csv_path(self, users_csv_path):
if users_csv_path:
return Path(users_csv_path).expanduser().resolve()
backend_root = Path(__file__).resolve().parents[4]
return backend_root / "core" / "users" / "import_templates" / "users_import_template.csv"
def _load_servicer_emails_from_template(self, users_csv_path):
with users_csv_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames:
raise CommandError("users_import_template.csv nema zaglavlje (header).")
normalized_headers = [str(h).strip() for h in reader.fieldnames]
missing = [column for column in self.users_template_columns if column not in normalized_headers]
if missing:
raise CommandError(
"users_import_template.csv nema obavezna polja: " + ", ".join(missing)
)
emails = set()
row_number = 1
for row in reader:
row_number += 1
if not any((value or "").strip() for value in row.values()):
continue
is_serviser = _to_bool(row.get("is_serviser"), f"is_serviser (users CSV, red {row_number})")
email = (row.get("email") or "").strip().lower()
if is_serviser and email:
emails.add(email)
return emails

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.15 on 2026-07-11
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fleet", "0020_alter_workorderinvoice_created_at_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="vehicle",
name="assigned_servicer",
field=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",
),
),
]

View File

@@ -0,0 +1,43 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
def work_order_photo_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_photos/{unique_name}"
class Migration(migrations.Migration):
dependencies = [
('fleet', '0021_alter_vehicle_assigned_servicer_many_to_one'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WorkOrderPhoto',
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)),
('image', models.ImageField(upload_to=work_order_photo_upload_to, verbose_name='Fotografija putnog naloga')),
('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_work_order_photos', to=settings.AUTH_USER_MODEL, verbose_name='Postavio')),
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Fotografija putnog naloga',
'verbose_name_plural': 'Fotografije putnog naloga',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,39 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import modules.fleet.models
import uuid
class Migration(migrations.Migration):
dependencies = [
('fleet', '0022_workorderphoto'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='GeneratedWorkOrderPdf',
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')),
('pdf_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_records', 'Servisni zapisi'), ('invoices', 'Računi')], db_index=True, max_length=32, verbose_name='Tip PDF-a')),
('status', models.CharField(choices=[('pending', 'U obradi'), ('ready', 'Spremno'), ('failed', 'Neuspješno')], db_index=True, default='pending', max_length=16, verbose_name='Status')),
('file', models.FileField(blank=True, null=True, upload_to=modules.fleet.models._generated_work_order_pdf_upload_to, verbose_name='PDF datoteka')),
('filename', models.CharField(blank=True, max_length=255, verbose_name='Naziv datoteke')),
('expires_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Vrijedi do')),
('error_message', models.TextField(blank=True, verbose_name='Poruka greške')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_generated_work_order_pdfs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_pdfs', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Generirani PDF putnog naloga',
'verbose_name_plural': 'Generirani PDF-ovi putnih naloga',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.15 on 2026-07-17 14:32
import modules.fleet.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0023_generatedworkorderpdf'),
]
operations = [
migrations.AlterField(
model_name='workorderphoto',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
),
migrations.AlterField(
model_name='workorderphoto',
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='workorderphoto',
name='image',
field=models.ImageField(upload_to=modules.fleet.models._work_order_photo_upload_to, verbose_name='Fotografija putnog naloga'),
),
migrations.AlterField(
model_name='workorderphoto',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.15 on 2026-07-17 21:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0024_alter_workorderphoto_created_at_and_more'),
]
operations = [
migrations.AddField(
model_name='workorder',
name='display_code',
field=models.CharField(
blank=True,
db_index=True,
help_text='Format: MT070626 (2 slova + 6 znamenki).',
max_length=8,
verbose_name='Prikaz putnog naloga',
),
),
]

View File

@@ -0,0 +1,30 @@
from django.db import migrations
def backfill_workorder_display_code(apps, schema_editor):
WorkOrder = apps.get_model('fleet', 'WorkOrder')
for work_order in WorkOrder.objects.select_related('creator').all():
current_code = str(getattr(work_order, 'display_code', '') or '').strip().upper()
if current_code:
continue
creator = getattr(work_order, 'creator', None)
first_name = str(getattr(creator, 'first_name', '') or '').strip()
last_name = str(getattr(creator, 'last_name', '') or '').strip()
if not first_name or not last_name:
continue
order_date = getattr(work_order, 'date', None)
if not order_date:
continue
display_code = f"{first_name[0].upper()}{last_name[0].upper()}{order_date.strftime('%d%m%y')}"
WorkOrder.objects.filter(pk=work_order.pk).update(display_code=display_code)
class Migration(migrations.Migration):
dependencies = [
('fleet', '0025_workorder_display_code'),
]
operations = [
migrations.RunPython(backfill_workorder_display_code, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0026_backfill_workorder_display_code'),
]
operations = [
migrations.AddField(
model_name='workorder',
name='origin_location',
field=models.CharField(blank=True, max_length=255, verbose_name='Lokacija ishodišta'),
),
]

View File

@@ -0,0 +1,18 @@
from django.db import migrations, models
import modules.fleet.models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0027_workorder_origin_location'),
]
operations = [
migrations.AlterField(
model_name='workorderinvoice',
name='image',
field=models.FileField(blank=True, null=True, upload_to=modules.fleet.models._work_order_invoice_upload_to, verbose_name='Datoteka računa'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-19 17:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0028_alter_workorderinvoice_image'),
]
operations = [
migrations.AlterField(
model_name='workorder',
name='purpose',
field=models.CharField(blank=True, choices=[('defektaza', 'Defektaža'), ('kontrola', 'Kontrola'), ('redovni_pregled', 'Redovni pregled'), ('popravak', 'Popravak')], max_length=50, verbose_name='Svrha'),
),
]

View File

@@ -0,0 +1,31 @@
from decimal import Decimal
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fleet', '0028_alter_workorderinvoice_image'),
]
operations = [
migrations.CreateModel(
name='WorkOrderAdditionalCostsTable',
fields=[
('id', models.UUIDField(editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_active', models.BooleanField(default=True)),
('data', models.JSONField(blank=True, default=None, null=True, verbose_name='Podaci tablice dodatnih troškova')),
('total_for_payout', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12, verbose_name='Ukupno za isplatu')),
('work_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='additional_costs_table', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Tablica dodatnih troškova putnog naloga',
'verbose_name_plural': 'Tablice dodatnih troškova putnog naloga',
'ordering': ['-updated_at'],
},
),
]

View File

@@ -0,0 +1,11 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fleet', '0029_alter_workorder_purpose'),
('fleet', '0029_workorderadditionalcoststable'),
]
operations = []

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.15 on 2026-07-19 19:01
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0030_merge_20260719_2057'),
]
operations = [
migrations.AlterField(
model_name='workorderadditionalcoststable',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
),
migrations.AlterField(
model_name='workorderadditionalcoststable',
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='workorderadditionalcoststable',
name='is_active',
field=models.BooleanField(default=True, verbose_name='Aktivan zapis'),
),
migrations.AlterField(
model_name='workorderadditionalcoststable',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
),
]

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2.15 on 2026-07-20 03:05
import uuid
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('task_management', '0010_alter_tasktemplate_status'),
('fleet', '0031_alter_workorderadditionalcoststable_created_at_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ServiceContextNote',
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')),
('audience_source', models.CharField(choices=[('self', 'Samo autor'), ('member', 'Odabrani član tima'), ('team', 'Svi članovi tima')], default='self', max_length=16, verbose_name='Publika')),
('note', models.TextField(verbose_name='Bilješka')),
('note_date', models.DateField(blank=True, db_index=True, null=True, verbose_name='Datum podsjetnika')),
('is_closed', models.BooleanField(default=False, verbose_name='Ručno zatvoreno')),
('closed_at', models.DateTimeField(blank=True, null=True, verbose_name='Vrijeme zatvaranja')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_service_context_notes', to=settings.AUTH_USER_MODEL, verbose_name='Kreirao')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_context_notes', to=settings.AUTH_USER_MODEL, verbose_name='Primatelj')),
('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_context_notes', to='task_management.task', verbose_name='Radni zadatak')),
('work_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_context_notes', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Bilješka servisnog konteksta',
'verbose_name_plural': 'Bilješke servisnog konteksta',
'ordering': ['is_closed', 'note_date', '-created_at'],
},
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.15 on 2026-07-21 11:50
import uuid
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fleet', '0032_servicecontextnote'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EmailDispatchLog',
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')),
('dispatch_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_record', 'Servisni zapis')], db_index=True, max_length=32, verbose_name='Tip slanja')),
('recipients', models.JSONField(blank=True, default=list, verbose_name='Primatelji')),
('subject', models.CharField(blank=True, max_length=255, verbose_name='Naslov')),
('message', models.TextField(blank=True, verbose_name='Poruka')),
('attachments', models.JSONField(blank=True, default=list, verbose_name='Privitci')),
('status', models.CharField(choices=[('queued', 'U obradi'), ('sent', 'Poslano'), ('failed', 'Neuspješno')], db_index=True, default='queued', max_length=16, verbose_name='Status')),
('error_message', models.TextField(blank=True, verbose_name='Greška')),
('metadata', models.JSONField(blank=True, default=dict, verbose_name='Metapodaci')),
('sent_at', models.DateTimeField(blank=True, null=True, verbose_name='Poslano u')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_email_dispatch_logs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
('service_record', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_dispatch_logs', to='fleet.vehicleservicerecord', verbose_name='Servisni zapis')),
('work_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_dispatch_logs', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Log slanja emaila',
'verbose_name_plural': 'Logovi slanja emaila',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,45 @@
# Generated by Django 5.2.15 on 2026-07-31 12:30
import uuid
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fleet', '0033_emaildispatchlog'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MonthlyServicerDayEntry',
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')),
('entry_date', models.DateField(db_index=True, verbose_name='Datum')),
('entry_type', models.CharField(choices=[('office', 'Rad u uredu'), ('sick_leave', 'Bolovanje'), ('vacation', 'Godišnji odmor')], max_length=24, verbose_name='Vrsta unosa')),
('description', models.CharField(blank=True, max_length=255, verbose_name='Opis posla')),
('location', models.CharField(blank=True, max_length=255, verbose_name='Mjesto rada')),
('start_time', models.TimeField(blank=True, null=True, verbose_name='Početak rada')),
('end_time', models.TimeField(blank=True, null=True, verbose_name='Kraj rada')),
('regular_hours', models.DecimalField(decimal_places=2, default=Decimal('8.00'), max_digits=5, verbose_name='Redovan rad')),
('overtime_hours', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=5, verbose_name='Prekovremeni rad')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_servicer_day_entries', to=settings.AUTH_USER_MODEL, verbose_name='Serviser')),
],
options={
'verbose_name': 'Dnevni unos mjesečnog izvještaja servisera',
'verbose_name_plural': 'Dnevni unosi mjesečnog izvještaja servisera',
'ordering': ['entry_date'],
},
),
migrations.AddConstraint(
model_name='monthlyservicerdayentry',
constraint=models.UniqueConstraint(fields=('user', 'entry_date', 'is_active'), name='uniq_monthly_servicer_day_entry_user_date_active'),
),
]

View File

@@ -1,5 +1,6 @@
import uuid
import re
from datetime import time
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -45,6 +46,24 @@ def _work_order_invoice_upload_to(instance, filename):
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
def _work_order_photo_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_photos/{unique_name}"
def _generated_work_order_pdf_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}/generated_pdfs/{unique_name}"
class VehicleServicePhoto(BaseModel):
"""
@@ -149,7 +168,7 @@ class Vehicle(BaseModel):
)
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(
assigned_servicer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name='assigned_vehicles',
@@ -232,6 +251,7 @@ class WorkOrder(BaseModel):
('defektaza', _("Defektaža")),
('kontrola', _("Kontrola")),
('redovni_pregled', _("Redovni pregled")),
('popravak', _("Popravak")),
]
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo"))
@@ -266,9 +286,17 @@ class WorkOrder(BaseModel):
blank=True,
verbose_name=_("Vozilo servisera - trošak goriva (EUR)"),
)
origin_location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija ishodišta"))
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"))
display_code = models.CharField(
max_length=8,
blank=True,
db_index=True,
verbose_name=_("Prikaz putnog naloga"),
help_text=_("Format: MT070626 (2 slova + 6 znamenki)."),
)
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"))
@@ -304,6 +332,12 @@ class WorkOrder(BaseModel):
def clean(self):
super().clean()
if self.display_code:
self.display_code = str(self.display_code).strip().upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", self.display_code):
raise ValidationError({
"display_code": _("Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).")
})
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.")})
@@ -319,11 +353,11 @@ class WorkOrderInvoice(BaseModel):
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(
image = models.FileField(
upload_to=_work_order_invoice_upload_to,
null=True,
blank=True,
verbose_name=_("Slika računa"),
verbose_name=_("Datoteka računa"),
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -344,6 +378,108 @@ class WorkOrderInvoice(BaseModel):
return f"{self.naziv_racuna} ({self.work_order_id})"
class WorkOrderAdditionalCostsTable(BaseModel):
work_order = models.OneToOneField(
WorkOrder,
on_delete=models.CASCADE,
related_name='additional_costs_table',
verbose_name=_("Putni nalog"),
)
data = models.JSONField(
null=True,
blank=True,
default=None,
verbose_name=_("Podaci tablice dodatnih troškova"),
)
total_for_payout = models.DecimalField(
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Ukupno za isplatu"),
)
class Meta:
ordering = ['-updated_at']
verbose_name = _("Tablica dodatnih troškova putnog naloga")
verbose_name_plural = _("Tablice dodatnih troškova putnog naloga")
def __str__(self):
return f"Additional costs table for work order {self.work_order_id}"
class WorkOrderPhoto(BaseModel):
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.CASCADE,
related_name='photos',
verbose_name=_("Putni nalog"),
)
image = models.ImageField(
upload_to=_work_order_photo_upload_to,
verbose_name=_("Fotografija putnog naloga"),
)
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_work_order_photos',
verbose_name=_("Postavio"),
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
class Meta:
verbose_name = _("Fotografija putnog naloga")
verbose_name_plural = _("Fotografije putnog naloga")
ordering = ['-created_at']
def __str__(self):
return f"WorkOrderPhoto {self.pk} ({self.work_order_id})"
class GeneratedWorkOrderPdf(BaseModel):
PDF_TYPE_CHOICES = [
('work_order', _("Putni nalog")),
('service_records', _("Servisni zapisi")),
('invoices', _("Računi")),
]
STATUS_CHOICES = [
('pending', _("U obradi")),
('ready', _("Spremno")),
('failed', _("Neuspješno")),
]
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.CASCADE,
related_name='generated_pdfs',
verbose_name=_("Putni nalog"),
)
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='requested_generated_work_order_pdfs',
verbose_name=_("Zatražio"),
)
pdf_type = models.CharField(max_length=32, choices=PDF_TYPE_CHOICES, db_index=True, verbose_name=_("Tip PDF-a"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='pending', db_index=True, verbose_name=_("Status"))
file = models.FileField(upload_to=_generated_work_order_pdf_upload_to, null=True, blank=True, verbose_name=_("PDF datoteka"))
filename = models.CharField(max_length=255, blank=True, verbose_name=_("Naziv datoteke"))
expires_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name=_("Vrijedi do"))
error_message = models.TextField(blank=True, verbose_name=_("Poruka greške"))
class Meta:
verbose_name = _("Generirani PDF putnog naloga")
verbose_name_plural = _("Generirani PDF-ovi putnih naloga")
ordering = ['-created_at']
def __str__(self):
return f"{self.pdf_type}:{self.work_order_id}:{self.status}"
class VehicleServiceRecord(BaseModel):
"""
Zapisi o servisima/popravcima na vozilu.
@@ -430,4 +566,235 @@ class VehicleNotification(BaseModel):
ordering = ['-created_at']
def __str__(self):
return f"{self.title} -> {self.recipient} ({self.level})"
return f"{self.title} -> {self.recipient} ({self.level})"
class EmailDispatchLog(BaseModel):
DISPATCH_TYPE_CHOICES = [
('work_order', _("Putni nalog")),
('service_record', _("Servisni zapis")),
]
STATUS_CHOICES = [
('queued', _("U obradi")),
('sent', _("Poslano")),
('failed', _("Neuspješno")),
]
dispatch_type = models.CharField(max_length=32, choices=DISPATCH_TYPE_CHOICES, db_index=True, verbose_name=_("Tip slanja"))
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='requested_email_dispatch_logs',
verbose_name=_("Zatražio"),
)
work_order = models.ForeignKey(
'fleet.WorkOrder',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='email_dispatch_logs',
verbose_name=_("Putni nalog"),
)
service_record = models.ForeignKey(
'fleet.VehicleServiceRecord',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='email_dispatch_logs',
verbose_name=_("Servisni zapis"),
)
recipients = models.JSONField(default=list, blank=True, verbose_name=_("Primatelji"))
subject = models.CharField(max_length=255, blank=True, verbose_name=_("Naslov"))
message = models.TextField(blank=True, verbose_name=_("Poruka"))
attachments = models.JSONField(default=list, blank=True, verbose_name=_("Privitci"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='queued', db_index=True, verbose_name=_("Status"))
error_message = models.TextField(blank=True, verbose_name=_("Greška"))
metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci"))
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Poslano u"))
class Meta:
verbose_name = _("Log slanja emaila")
verbose_name_plural = _("Logovi slanja emaila")
ordering = ['-created_at']
def __str__(self):
return f"{self.dispatch_type} -> {len(self.recipients or [])} ({self.status})"
def clean(self):
super().clean()
if self.dispatch_type == 'work_order' and not self.work_order_id:
raise ValidationError({'work_order': _("Putni nalog je obavezan za ovaj tip slanja.")})
if self.dispatch_type == 'service_record' and not self.service_record_id:
raise ValidationError({'service_record': _("Servisni zapis je obavezan za ovaj tip slanja.")})
class ServiceContextNote(BaseModel):
AUDIENCE_SOURCE_CHOICES = [
('self', 'Samo autor'),
('member', 'Odabrani član tima'),
('team', 'Svi članovi tima'),
]
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='created_service_context_notes',
verbose_name=_("Kreirao"),
)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='service_context_notes',
verbose_name=_("Primatelj"),
)
audience_source = models.CharField(
max_length=16,
choices=AUDIENCE_SOURCE_CHOICES,
default='self',
verbose_name=_("Publika"),
)
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='service_context_notes',
verbose_name=_("Putni nalog"),
)
task = models.ForeignKey(
'task_management.Task',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='service_context_notes',
verbose_name=_("Radni zadatak"),
)
note = models.TextField(verbose_name=_("Bilješka"))
note_date = models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name=_("Datum podsjetnika"),
)
is_closed = models.BooleanField(default=False, verbose_name=_("Ručno zatvoreno"))
closed_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Vrijeme zatvaranja"))
class Meta:
verbose_name = _("Bilješka servisnog konteksta")
verbose_name_plural = _("Bilješke servisnog konteksta")
ordering = ['is_closed', 'note_date', '-created_at']
def __str__(self):
return f"Note {self.pk} -> {self.recipient_id}"
def clean(self):
super().clean()
if self.task_id and self.work_order_id and self.task and self.task.work_order_id:
if self.task.work_order_id != self.work_order_id:
raise ValidationError({
'task': _("Radni zadatak mora biti povezan s istim putnim nalogom kao bilješka."),
})
class MonthlyServicerDayEntry(BaseModel):
ENTRY_TYPE_CHOICES = [
('office', _('Rad u uredu')),
('sick_leave', _('Bolovanje')),
('vacation', _('Godišnji odmor')),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='monthly_servicer_day_entries',
verbose_name=_('Serviser'),
)
entry_date = models.DateField(db_index=True, verbose_name=_('Datum'))
entry_type = models.CharField(
max_length=24,
choices=ENTRY_TYPE_CHOICES,
verbose_name=_('Vrsta unosa'),
)
description = models.CharField(max_length=255, blank=True, verbose_name=_('Opis posla'))
location = models.CharField(max_length=255, blank=True, verbose_name=_('Mjesto rada'))
start_time = models.TimeField(null=True, blank=True, verbose_name=_('Početak rada'))
end_time = models.TimeField(null=True, blank=True, verbose_name=_('Kraj rada'))
regular_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('8.00'),
verbose_name=_('Redovan rad'),
)
overtime_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_('Prekovremeni rad'),
)
class Meta:
verbose_name = _('Dnevni unos mjesečnog izvještaja servisera')
verbose_name_plural = _('Dnevni unosi mjesečnog izvještaja servisera')
ordering = ['entry_date']
constraints = [
models.UniqueConstraint(
fields=['user', 'entry_date', 'is_active'],
name='uniq_monthly_servicer_day_entry_user_date_active',
),
]
def __str__(self):
return f"{self.user_id} @ {self.entry_date} ({self.entry_type})"
def _calculate_regular_hours(self, start_time, end_time):
if start_time is None or end_time is None:
return Decimal('8.00')
start_minutes = (start_time.hour * 60) + start_time.minute + (start_time.second / 60)
end_minutes = (end_time.hour * 60) + end_time.minute + (end_time.second / 60)
if end_minutes <= start_minutes:
raise ValidationError({
'end_time': _('Kraj rada mora biti nakon početka rada.'),
})
hours = Decimal(str((end_minutes - start_minutes) / 60)).quantize(Decimal('0.01'))
return hours
def apply_defaults(self):
start_time = self.start_time
end_time = self.end_time
if self.entry_type == 'office':
if not self.description:
self.description = 'Rad u uredu'
if not self.location:
self.location = 'Zagreb (ured)'
if start_time is None:
start_time = time(8, 0)
if end_time is None:
end_time = time(16, 0)
elif self.entry_type == 'vacation':
if not self.description:
self.description = 'Godišnji odmor'
self.location = self.location or ''
elif self.entry_type == 'sick_leave':
if not self.description:
self.description = 'Bolovanje'
self.location = self.location or ''
else:
self.location = self.location or ''
self.start_time = start_time
self.end_time = end_time
self.regular_hours = self._calculate_regular_hours(start_time, end_time)
self.overtime_hours = Decimal('0.00')
def clean(self):
super().clean()
self.apply_defaults()
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)

View File

@@ -0,0 +1,128 @@
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
DEFAULT_MARGIN = 28
DEFAULT_HEADER_H = 86
DEFAULT_FOOTER_H = 72
def register_unicode_fonts():
if "Vera" in pdfmetrics.getRegisteredFontNames():
return
fonts_dir = os.path.join(os.path.dirname(__import__("reportlab").__file__), "fonts")
pdfmetrics.registerFont(TTFont("Vera", os.path.join(fonts_dir, "Vera.ttf")))
pdfmetrics.registerFont(TTFont("Vera-Bold", os.path.join(fonts_dir, "VeraBd.ttf")))
pdfmetrics.registerFont(TTFont("Vera-Italic", os.path.join(fonts_dir, "VeraIt.ttf")))
def draw_standard_header_footer(
pdf,
*,
page_num,
client_name,
manufacturer,
model,
serial,
upgrade_hours,
chassis_hours,
mileage,
work_order_number,
generated_date,
report_title="Izvještaj servisera",
page_size=A4,
margin=DEFAULT_MARGIN,
header_h=DEFAULT_HEADER_H,
footer_h=DEFAULT_FOOTER_H,
):
w, h = page_size
pdf.setStrokeColor(colors.black)
pdf.setLineWidth(0.7)
pdf.rect(margin, h - header_h, w - 2 * margin, header_h - 4, stroke=1, fill=0)
pdf.setFont("Vera-Bold", 11)
pdf.drawString(margin + 6, h - 22, str(client_name or "-")[:44])
pdf.drawCentredString(w / 2, h - 22, report_title)
pdf.setFont("Vera", 9)
pdf.drawRightString(w - margin - 6, h - 22, f"Stranica {page_num}")
pdf.setLineWidth(0.5)
pdf.line(margin, h - 36, w - margin, h - 36)
labels = [
"Model:",
"Serijski broj:",
"Radni sati nadogradnje:",
"Radni sati podvozja:",
"Km:",
"Broj naloga:",
]
values = [
(manufacturer, model),
serial,
upgrade_hours,
chassis_hours,
mileage,
work_order_number,
]
col_w = (w - 2 * margin) / len(labels)
pdf.setFont("Vera", 7.2)
for i, (lbl, val) in enumerate(zip(labels, values)):
x = margin + i * col_w + 3
pdf.drawString(x, h - 49, lbl)
value_text = str(val or "-")
value_font_size = 9
if i == 0 and isinstance(val, tuple):
manufacturer_text = str(val[0] or "-")[:24]
model_text = str(val[1] or "-")[:24]
pdf.setFont("Vera-Bold", 8.2)
pdf.drawString(x, h - 59, manufacturer_text)
pdf.setFont("Vera", 7.6)
pdf.drawString(x, h - 68, model_text)
elif i == len(labels) - 1:
value_font_size = 7
max_value_width = col_w - 8
while (
pdf.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width
and value_font_size > 5.5
):
value_font_size -= 0.3
if pdf.stringWidth(value_text, "Vera-Bold", value_font_size) > max_value_width:
trimmed = value_text
while (
len(trimmed) > 3
and pdf.stringWidth(f"{trimmed}...", "Vera-Bold", value_font_size) > max_value_width
):
trimmed = trimmed[:-1]
value_text = f"{trimmed}..."
else:
value_text = value_text[:24]
if not (i == 0 and isinstance(val, tuple)):
pdf.setFont("Vera-Bold", value_font_size)
pdf.drawString(x, h - 61, value_text)
pdf.setFont("Vera", 7.2)
if i > 0:
pdf.line(margin + i * col_w, h - 36, margin + i * col_w, h - header_h + 4)
footer_y = footer_h
pdf.setLineWidth(0.5)
pdf.rect(margin, footer_y + 42, w - 2 * margin, 18, stroke=1, fill=0)
sig_labels = [f"Datum: {generated_date}", "Potpis servisera", "Pečat i potpis klijenta *1", "Pregledao"]
sig_w = (w - 2 * margin) / 4
pdf.setFont("Vera", 7.2)
for i, lbl in enumerate(sig_labels):
x = margin + i * sig_w
pdf.drawString(x + 4, footer_y + 45, lbl)
if i > 0:
pdf.line(x, footer_y + 42, x, footer_y + 60)
pdf.setFont("Vera", 6.5)
pdf.drawString(margin, footer_y + 26, "*1- potpisom klijent potvrđuje da je suglasan s podacima u radnom nalogu")
pdf.drawCentredString(
w / 2,
footer_y + 14,
"Ovlašteni servis LIEBHERR Werk-Ehingen GmbH, LIEBHERR-Werk Nenzing GmbH, Liebherr-MCCTech Rostock GmbH",
)

View File

@@ -1,10 +1,20 @@
from rest_framework import serializers
import re
from pathlib import Path
from decimal import Decimal, InvalidOperation
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from modules.task_management.models import Task
from .models import (
Crane,
Vehicle,
ServiceContextNote,
MonthlyServicerDayEntry,
VehicleNotification,
WorkOrder,
WorkOrderAdditionalCostsTable,
WorkOrderPhoto,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
@@ -12,6 +22,8 @@ from .models import (
)
from . import services
User = get_user_model()
class VehicleSerializer(serializers.ModelSerializer):
# Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu
@@ -27,7 +39,7 @@ class VehicleSerializer(serializers.ModelSerializer):
'is_company_vehicle', 'client', 'client_name',
'assigned_servicer', 'is_active',
]
read_only_fields = ['id', 'current_mileage', 'client_name']
read_only_fields = ['id', 'client_name']
def get_client_name(self, obj):
if obj.client_id:
@@ -89,6 +101,161 @@ class VehicleNotificationSerializer(serializers.ModelSerializer):
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 ServiceContextNoteSerializer(serializers.ModelSerializer):
created_by_name = serializers.SerializerMethodField()
recipient_name = serializers.SerializerMethodField()
work_order_label = serializers.SerializerMethodField()
task_title = serializers.SerializerMethodField()
class Meta:
model = ServiceContextNote
fields = [
'id',
'created_by',
'created_by_name',
'recipient',
'recipient_name',
'audience_source',
'work_order',
'work_order_label',
'task',
'task_title',
'note',
'note_date',
'is_closed',
'closed_at',
'created_at',
'updated_at',
]
read_only_fields = fields
def get_created_by_name(self, obj):
return obj.created_by.get_full_name() or obj.created_by.email
def get_recipient_name(self, obj):
return obj.recipient.get_full_name() or obj.recipient.email
def get_work_order_label(self, obj):
if not obj.work_order_id:
return None
display_code = str(getattr(obj.work_order, 'display_code', '') or '').strip().upper()
return display_code or f"WO-{obj.work_order_id}"
def get_task_title(self, obj):
if not obj.task_id:
return None
return obj.task.title
class ServiceContextNoteCreateSerializer(serializers.Serializer):
AUDIENCE_CHOICES = ['self', 'member', 'team']
note = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
note_date = serializers.DateField(required=False, allow_null=True)
audience = serializers.ChoiceField(choices=AUDIENCE_CHOICES, default='self')
target_user = serializers.PrimaryKeyRelatedField(
queryset=User.objects.filter(is_active=True, is_team_member=True),
required=False,
allow_null=True,
)
target_users = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True, is_team_member=True)),
required=False,
allow_empty=False,
)
work_order = serializers.PrimaryKeyRelatedField(
queryset=WorkOrder.objects.filter(is_active=True),
required=False,
allow_null=True,
)
task = serializers.PrimaryKeyRelatedField(
queryset=Task.objects.filter(is_active=True),
required=False,
allow_null=True,
)
def validate(self, attrs):
request = self.context.get('request')
user = getattr(request, 'user', None)
if user is None:
raise serializers.ValidationError('Autentikacija je obavezna.')
audience = attrs.get('audience', 'self')
target_user = attrs.get('target_user')
target_users = attrs.get('target_users') or []
work_order = attrs.get('work_order')
task = attrs.get('task')
note = str(attrs.get('note', '') or '').strip()
can_manage_recipients = bool(
getattr(user, 'is_serviser', False)
or getattr(user, 'is_staff', False)
or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False))
)
if not note:
raise serializers.ValidationError({'note': 'Bilješka ne može biti prazna.'})
if not can_manage_recipients and audience != 'self':
raise serializers.ValidationError({'audience': 'Samo supervisor ili serviser može slati bilješke drugim članovima tima.'})
if audience == 'member' and not target_user and len(target_users) == 0:
raise serializers.ValidationError({'target_users': 'Odaberite barem jednog servisera.'})
if audience in {'self', 'team'} and target_user:
raise serializers.ValidationError({'target_user': 'Odabrani tip publike ne koristi ciljano polje člana tima.'})
if audience in {'self', 'team'} and len(target_users) > 0:
raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu članova tima.'})
if target_user and not getattr(target_user, 'is_team_member', False):
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo članu tima.'})
for selected_user in target_users:
if not getattr(selected_user, 'is_team_member', False):
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo članovima tima.'})
if work_order:
if not work_order.is_active or str(work_order.status or '').lower() != 'open':
raise serializers.ValidationError({'work_order': 'Moguće je povezati samo aktivni otvoreni putni nalog.'})
if task:
status = str(task.status or '').lower()
if status in {'zavrsen', 'neaktivan'}:
raise serializers.ValidationError({'task': 'Moguće je povezati samo aktivni radni zadatak.'})
if task and work_order and task.work_order_id and str(task.work_order_id) != str(work_order.id):
raise serializers.ValidationError({'task': 'Radni zadatak ne pripada odabranom putnom nalogu.'})
attrs['note'] = note
if task and not work_order and task.work_order_id:
attrs['work_order'] = task.work_order
if audience == 'member' and len(target_users) == 0 and target_user:
attrs['target_users'] = [target_user]
return attrs
class MonthlyServicerDayEntrySerializer(serializers.ModelSerializer):
class Meta:
model = MonthlyServicerDayEntry
fields = [
'id',
'entry_date',
'entry_type',
'description',
'location',
'start_time',
'end_time',
'regular_hours',
'overtime_hours',
'created_at',
'updated_at',
]
read_only_fields = [
'id',
'description',
'location',
'regular_hours',
'overtime_hours',
'created_at',
'updated_at',
]
class WorkOrderSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
crane_label = serializers.SerializerMethodField()
@@ -96,12 +263,12 @@ class WorkOrderSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrder
fields = [
'id', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
'id', 'display_code', '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',
'origin_location', 'location', 'travel_start_at', 'travel_end_at',
'purpose', 'notes', 'status'
]
read_only_fields = ['id', 'date']
@@ -110,11 +277,13 @@ class WorkOrderSerializer(serializers.ModelSerializer):
'creator': {'required': False},
'start_mileage': {'required': False, 'allow_null': True},
'end_mileage': {'required': False, 'allow_null': True},
'display_code': {'required': False, 'allow_blank': False},
'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},
'origin_location': {'required': False, 'allow_blank': True},
'location': {'required': False, 'allow_blank': True},
'travel_start_at': {'required': False, 'allow_null': True},
'travel_end_at': {'required': False, 'allow_null': True},
@@ -144,6 +313,14 @@ class WorkOrderSerializer(serializers.ModelSerializer):
if user and 'creator' not in validated_data:
validated_data['creator'] = user
if not validated_data.get('display_code'):
creator = validated_data.get('creator')
generated_display_code = self._generate_display_code(creator, timezone.localdate())
if not generated_display_code:
raise serializers.ValidationError({
'display_code': 'Serviser mora imati ime i prezime za automatsko generiranje oznake. Unesite oznaku ručno u formatu MT070626.'
})
validated_data['display_code'] = generated_display_code
return services.VehicleService.create_work_order(validated_data)
@@ -180,23 +357,159 @@ class WorkOrderSerializer(serializers.ModelSerializer):
raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'})
return attrs
def validate_display_code(self, value):
normalized = str(value or '').strip().upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", normalized):
raise serializers.ValidationError('Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).')
return normalized
@staticmethod
def _generate_display_code(user, target_date):
if user is None:
return None
first_name = str(getattr(user, 'first_name', '') or '').strip()
last_name = str(getattr(user, 'last_name', '') or '').strip()
if not first_name or not last_name:
return None
return f"{first_name[0].upper()}{last_name[0].upper()}{target_date.strftime('%d%m%y')}"
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
image_content_type = serializers.SerializerMethodField()
work_order_display_code = serializers.SerializerMethodField()
class Meta:
model = WorkOrderInvoice
fields = [
'id',
'work_order',
'work_order_display_code',
'naziv_racuna',
'lokacija',
'datum',
'opis',
'image',
'image_url',
'image_content_type',
'created_by',
'created_at',
]
read_only_fields = ['id', 'created_by', 'created_at']
def get_image_url(self, obj):
request = self.context.get('request')
if not obj.image:
return None
if request is None:
return None
base = request.build_absolute_uri(
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
)
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return base
return f"{base}?w=1280&q=80&fmt=jpeg"
def get_image_content_type(self, obj):
if not obj.image:
return None
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return 'application/pdf'
if suffix in {'.jpg', '.jpeg'}:
return 'image/jpeg'
if suffix == '.png':
return 'image/png'
if suffix == '.webp':
return 'image/webp'
if suffix == '.gif':
return 'image/gif'
return 'application/octet-stream'
def get_work_order_display_code(self, obj):
work_order = getattr(obj, 'work_order', None)
if work_order is None:
return None
display_code = str(getattr(work_order, 'display_code', '') or '').strip()
if display_code:
return display_code
return str(getattr(work_order, 'id', '') or '').strip() or None
def validate_image(self, value):
if not value:
return value
suffix = Path(getattr(value, 'name', '')).suffix.lower()
allowed_suffixes = {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf'}
if suffix not in allowed_suffixes:
raise serializers.ValidationError('Datoteka računa mora biti slika (.jpg, .jpeg, .png, .webp, .gif) ili PDF (.pdf).')
return value
class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrderAdditionalCostsTable
fields = ['id', 'work_order', 'data', 'total_for_payout', 'created_at', 'updated_at']
read_only_fields = ['id', 'total_for_payout', 'created_at', 'updated_at']
@staticmethod
def _parse_decimal(value):
if value in (None, ''):
return Decimal('0.00')
try:
normalized = str(value).strip().replace('', '').replace(' ', '').replace(',', '.')
return Decimal(normalized)
except (InvalidOperation, ValueError, TypeError):
return Decimal('0.00')
def _normalize_row(self, row):
return {
'naziv': str(row.get('naziv', '') or '').strip(),
'broj_racuna': str(row.get('broj_racuna', '') or '').strip(),
'ukupan_iznos': str(row.get('ukupan_iznos', '') or '').strip(),
'prilog': str(row.get('prilog', '') or '').strip(),
'source_invoice_id': str(row.get('source_invoice_id', '') or '').strip(),
}
def validate_data(self, value):
if value is None:
return {'rows': []}
if not isinstance(value, dict):
raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt.")
rows = value.get('rows', [])
if not isinstance(rows, list):
raise serializers.ValidationError({"rows": "Polje 'rows' mora biti lista redaka."})
normalized_rows = []
for row in rows[:100]:
if not isinstance(row, dict):
raise serializers.ValidationError({"rows": "Svaki redak mora biti JSON objekt."})
normalized_rows.append(self._normalize_row(row))
return {'rows': normalized_rows}
def _calculate_total_for_payout(self, data):
rows = data.get('rows', []) if isinstance(data, dict) else []
total = sum((self._parse_decimal(row.get('ukupan_iznos')) for row in rows), Decimal('0.00'))
return total.quantize(Decimal('0.01'))
def create(self, validated_data):
validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data.get('data'))
return super().create(validated_data)
def update(self, instance, validated_data):
data = validated_data.get('data', instance.data or {'rows': []})
validated_data['total_for_payout'] = self._calculate_total_for_payout(data)
return super().update(instance, validated_data)
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrderPhoto
fields = ['id', 'work_order', 'image', 'description', 'uploaded_by', 'created_at']
read_only_fields = ['id', 'uploaded_by', 'created_at']
extra_kwargs = {
'work_order': {'required': False},
}
class VehicleServiceRecordSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
@@ -207,12 +520,13 @@ class VehicleServiceRecordSerializer(serializers.ModelSerializer):
performed_by_name = serializers.SerializerMethodField()
vehicle_registration = serializers.SerializerMethodField()
task_label = serializers.SerializerMethodField()
crane_serial_number = serializers.SerializerMethodField()
class Meta:
model = VehicleServiceRecord
fields = [
'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration',
'task', 'task_label',
'task', 'task_label', 'crane_serial_number',
'performed_by', 'performed_by_name',
'service_title', 'description', 'parts', 'cost',
'service_date', 'mileage', 'next_service_due_at',
@@ -261,6 +575,12 @@ class VehicleServiceRecordSerializer(serializers.ModelSerializer):
return None
return obj.task.title
def get_crane_serial_number(self, obj):
# Provjeri je li povezano vozilo/dizalica postoji i vrati serijski broj
if hasattr(obj, 'vehicle') and obj.vehicle:
return getattr(obj.vehicle, 'crane_serial_number', None)
return None
def create(self, validated_data):
request = self.context.get('request')
user = getattr(request, 'user', None)

View File

@@ -126,6 +126,7 @@ class VehicleService:
@staticmethod
def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
previous_status = instance.status
for k, v in data.items():
setattr(instance, k, v)
try:
@@ -134,8 +135,17 @@ class VehicleService:
raise ValidationError({"detail": str(e)})
with transaction.atomic():
instance.save()
# Ako je nalog zatvoren, obavijestimo kreatora/servisera
if instance.status == 'closed':
transitioned_to_closed = previous_status != 'closed' and instance.status == 'closed'
if transitioned_to_closed:
from modules.task_management.models import Task
Task.objects.filter(
is_active=True,
work_order=instance,
status__in=('aktivan', 'servis'),
).update(status='spreman_za_zavrsetak')
# Ako je nalog upravo zatvoren, obavijestimo kreatora/servisera
if transitioned_to_closed:
try:
NotificationService.create_notification(
recipient=instance.creator,

View File

@@ -1,17 +1,45 @@
# backend/modules/fleet/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from django.core.mail import EmailMessage
from django.conf import settings
from django.utils import timezone
from django.core.files.base import ContentFile
import logging
from io import BytesIO
from io import StringIO
from pathlib import Path
import base64
import re
import csv
import mimetypes
from decimal import Decimal, InvalidOperation
from smtplib import SMTPSenderRefused
from datetime import timedelta
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from PIL import Image, UnidentifiedImageError
from django.db import transaction
from pypdf import PdfReader
from pypdf.errors import PdfReadError
import pytesseract
from pytesseract import TesseractNotFoundError
from reportlab.lib.pagesizes import A4
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from .models import VehicleNotification
from .models import VehicleNotification, GeneratedWorkOrderPdf
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
from .email_utils import append_user_signature
logger = logging.getLogger(__name__)
register_unicode_fonts()
def _work_order_display_code(work_order):
normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper()
if normalized:
return normalized
return 'NALOG'
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
@@ -74,65 +102,565 @@ def send_email_task(recipient_email, subject, message, notif_id):
logger.error(f"Async mail failed: {exc}")
def _guess_content_type(filename):
mime, _ = mimetypes.guess_type(str(filename or ''))
return mime or 'application/octet-stream'
def _file_attachment(file_field, fallback_name):
if not file_field:
return None
file_field.open('rb')
try:
content = file_field.read()
finally:
file_field.close()
if not content:
return None
filename = Path(str(getattr(file_field, 'name', '') or fallback_name)).name or fallback_name
return (filename, content, _guess_content_type(filename))
def _build_image_attachments_for_work_order(work_order):
from .models import WorkOrderPhoto, WorkOrderInvoice
attachments = []
photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
for index, photo in enumerate(photos, start=1):
attachment = _file_attachment(photo.image, fallback_name=f"work-order-photo-{index}.bin")
if attachment:
attachments.append(attachment)
invoices = WorkOrderInvoice.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
for index, invoice in enumerate(invoices, start=1):
attachment = _file_attachment(invoice.image, fallback_name=f"work-order-invoice-{index}.bin")
if attachment:
attachments.append(attachment)
return attachments
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 _build_monthly_tasks_csv_attachment(work_order, *, year, month, month_key):
from modules.task_management.models import Task
tasks = (
Task.objects
.filter(
is_active=True,
vehicle_id=work_order.vehicle_id,
scheduled_date__year=year,
scheduled_date__month=month,
)
.select_related('assigned_to', 'work_order')
.order_by('scheduled_date', 'created_at')
)
csv_buffer = StringIO()
writer = csv.writer(csv_buffer)
writer.writerow(['Datum', 'Naslov zadatka', 'Status', 'Serviser', 'Putni nalog', 'Opis'])
for task in tasks:
assignee_name = _user_display_name(task.assigned_to) or '-'
work_order_label = str(getattr(getattr(task, 'work_order', None), 'display_code', '') or '').strip().upper() or '-'
writer.writerow([
task.scheduled_date.isoformat() if task.scheduled_date else '',
task.title or '',
task.status or '',
assignee_name,
work_order_label,
task.description or '',
])
csv_content = csv_buffer.getvalue().encode('utf-8')
filename = f"{_work_order_display_code(work_order)}.monthly-tasks-{month_key}.csv"
return (filename, csv_content, 'text/csv'), tasks.count()
def _resolve_from_email():
candidates = [
getattr(settings, 'DEFAULT_FROM_EMAIL', None),
getattr(settings, 'EMAIL_HOST_USER', None),
]
for candidate in candidates:
if not candidate:
continue
normalized = str(candidate).strip()
if not normalized:
continue
try:
validate_email(normalized)
return normalized
except DjangoValidationError:
continue
return None
def _detect_generated_pdf_type(filename):
normalized = str(filename or '').strip().lower()
if normalized.endswith('.work-order-invoices.pdf'):
return 'invoices'
if normalized.endswith('.work-order-service-records.pdf'):
return 'service_records'
if normalized.endswith('.work-order.pdf'):
return 'work_order'
return None
def _cache_pdf_attachments_for_download(*, work_order, requested_by, attachments):
cached_payload = []
ttl_hours = int(getattr(settings, 'GENERATED_PDF_TTL_HOURS', 24))
expires_at = timezone.now() + timedelta(hours=ttl_hours)
for filename, content, content_type in attachments:
if str(content_type or '').lower() != 'application/pdf':
continue
pdf_type = _detect_generated_pdf_type(filename)
if not pdf_type:
continue
generated = GeneratedWorkOrderPdf.objects.create(
work_order=work_order,
requested_by=requested_by,
pdf_type=pdf_type,
status='ready',
filename=filename,
expires_at=expires_at,
)
generated.file.save(filename, ContentFile(content), save=False)
generated.save(update_fields=['file', 'updated_at'])
cached_payload.append(
{
'generated_pdf_id': str(generated.pk),
'pdf_type': pdf_type,
'filename': filename,
'download_url': f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/",
'expires_at': expires_at.isoformat(),
}
)
return cached_payload
@shared_task
def send_work_order_email_bundle_task(
*,
work_order_id,
requested_by_id,
recipients,
subject='',
message='',
include_work_order_pdf=True,
include_service_records_pdf=False,
include_invoices_pdf=False,
include_images=False,
include_monthly_tasks=False,
month_year=None,
month_number=None,
month_key='',
service_note_id=None,
dispatch_log_id=None,
):
from django.contrib.auth import get_user_model
from .models import WorkOrder, ServiceContextNote, EmailDispatchLog
from .services import NotificationService
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
User = get_user_model()
requested_by = User.objects.filter(pk=requested_by_id, is_active=True).first()
dispatch_log = EmailDispatchLog.objects.filter(pk=dispatch_log_id).first() if dispatch_log_id else None
work_order = WorkOrder.objects.select_related('vehicle', 'creator', 'vehicle__client').filter(pk=work_order_id).first()
if work_order is None:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'Putni nalog nije pronađen.'
dispatch_log.save(update_fields=['status', 'error_message', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='Putni nalog za slanje emaila nije pronađen.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order_id),
},
)
return {'status': 'failed', 'error': 'work-order-not-found'}
body_lines = [str(message or 'U prilogu je dokumentacija putnog naloga.')]
if service_note_id:
note = ServiceContextNote.objects.select_related('created_by').filter(
pk=service_note_id,
is_active=True,
).first()
if note:
creator_name = _user_display_name(note.created_by) or '-'
note_date = note.note_date.isoformat() if note.note_date else '-'
body_lines.append("")
body_lines.append("Odabrana bilješka servisnog konteksta:")
body_lines.append(f"- Autor: {creator_name}")
body_lines.append(f"- Datum bilješke: {note_date}")
body_lines.append(f"- Tekst: {note.note}")
attachments = []
if include_work_order_pdf:
attachments.append((
f"{_work_order_display_code(work_order)}.work-order.pdf",
_build_work_order_pdf(work_order),
'application/pdf',
))
if include_service_records_pdf:
attachments.append((
f"{_work_order_display_code(work_order)}.work-order-service-records.pdf",
_build_work_order_service_records_pdf(work_order),
'application/pdf',
))
if include_invoices_pdf:
attachments.append((
f"{_work_order_display_code(work_order)}.work-order-invoices.pdf",
_build_work_order_invoices_pdf(work_order),
'application/pdf',
))
if include_images:
image_attachments = _build_image_attachments_for_work_order(work_order)
attachments.extend(image_attachments)
body_lines.append("")
body_lines.append(f"Dodano slika/datoteka: {len(image_attachments)}.")
monthly_tasks_count = 0
if include_monthly_tasks and month_year and month_number and month_key:
monthly_attachment, monthly_tasks_count = _build_monthly_tasks_csv_attachment(
work_order,
year=month_year,
month=month_number,
month_key=month_key,
)
attachments.append(monthly_attachment)
body_lines.append("")
body_lines.append(f"Priložen je popis mjesečnih radnih taskova za {month_key} (ukupno: {monthly_tasks_count}).")
cached_pdf_payload = []
if attachments:
cached_pdf_payload = _cache_pdf_attachments_for_download(
work_order=work_order,
requested_by=requested_by,
attachments=attachments,
)
from_email = _resolve_from_email()
if not from_email:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'SMTP pošiljatelj nije valjan.'
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='SMTP pošiljatelj nije valjan. Provjerite DEFAULT_FROM_EMAIL/EMAIL_HOST_USER postavke.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': 'invalid-from-email'}
total_attachment_bytes = sum(len(content) for _, content, _ in attachments)
max_attachment_bytes = int(getattr(settings, 'WORK_ORDER_EMAIL_MAX_ATTACHMENT_BYTES', 17 * 1024 * 1024))
if total_attachment_bytes > max_attachment_bytes:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'Ukupna veličina privitaka je prevelika.'
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'attachments_bytes': total_attachment_bytes,
'max_attachments_bytes': max_attachment_bytes,
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Email nije poslan — preveliki privitci',
message='Ukupna veličina privitaka je prevelika za slanje. Smanjite broj/veličinu privitaka.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'attachments_bytes': total_attachment_bytes,
'max_attachments_bytes': max_attachment_bytes,
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': 'attachments-too-large'}
if not attachments:
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = 'Nije odabran nijedan privitak.'
dispatch_log.save(update_fields=['status', 'error_message', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='Nije odabran nijedan PDF/prilog za slanje.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order_id),
},
)
return {'status': 'failed', 'error': 'no-attachments'}
email_message = EmailMessage(
subject=subject or f"Putni nalog {_work_order_display_code(work_order)}",
body=append_user_signature("\n".join(body_lines).strip(), requested_by),
from_email=from_email,
to=recipients or [],
)
for filename, content, content_type in attachments:
email_message.attach(filename, content, content_type)
if dispatch_log:
dispatch_log.attachments = [
{
'filename': filename,
'content_type': content_type,
'size_bytes': len(content),
}
for filename, content, content_type in attachments
]
dispatch_log.message = email_message.body
dispatch_log.save(update_fields=['attachments', 'message', 'updated_at'])
try:
email_message.send(fail_silently=False)
except SMTPSenderRefused as exc:
logger.exception("SMTP sender refused for work order email bundle: %s", exc)
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = f'SMTP sender refused ({exc.smtp_code}).'
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'smtp_code': exc.smtp_code,
'from_email': from_email,
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message='SMTP server je odbio pošiljatelja. Provjerite email konfiguraciju.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'smtp_code': exc.smtp_code,
'from_email': from_email,
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': 'smtp-sender-refused'}
except Exception as exc:
logger.exception("Greška kod slanja work order email bundle: %s", exc)
if dispatch_log:
dispatch_log.status = 'failed'
dispatch_log.error_message = str(exc)
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'cached_pdfs': cached_pdf_payload,
}
dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at'])
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Greška kod slanja emaila',
message=f'Slanje emaila za putni nalog {_work_order_display_code(work_order)} nije uspjelo.',
level='warning',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'failed',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients or []),
'cached_pdfs': cached_pdf_payload,
},
)
return {'status': 'failed', 'error': str(exc)}
if requested_by:
NotificationService.create_notification(
recipient=requested_by,
title='Email uspješno poslan',
message=f'Email za putni nalog {_work_order_display_code(work_order)} je uspješno poslan.',
level='success',
send_email=False,
metadata={
'entity_type': 'work_order_email',
'stage': 'completed',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients or []),
'attachments_count': len(attachments),
'monthly_tasks_count': monthly_tasks_count,
},
)
if dispatch_log:
dispatch_log.status = 'sent'
dispatch_log.error_message = ''
dispatch_log.sent_at = timezone.now()
dispatch_log.metadata = {
**(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}),
'recipients_count': len(recipients or []),
'attachments_count': len(attachments),
'monthly_tasks_count': monthly_tasks_count,
}
dispatch_log.save(update_fields=['status', 'error_message', 'sent_at', 'metadata', 'updated_at'])
return {
'status': 'ok',
'work_order_id': str(work_order.pk),
'recipients_count': len(recipients or []),
'attachments_count': len(attachments),
}
def _build_work_order_invoices_pdf(work_order):
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
y = height - 50
margin = 28
header_h = 86
footer_h = 72
content_top = height - header_h - 10
content_bottom = footer_h + 52
client_name = getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-"
manufacturer = str(work_order.vehicle.make or "-")
model_line = str(work_order.vehicle.model or "-")
serial_line = str(getattr(work_order.vehicle, "crane_serial_number", None) or "-")
upgrade_hours = str(getattr(work_order.vehicle, "superstructure_working_hours", "-") or "-")
chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-")
mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-")
generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y")
display_code = _work_order_display_code(work_order)
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}")
def draw_header_footer(page_num):
draw_standard_header_footer(
pdf,
page_num=page_num,
client_name=client_name,
manufacturer=manufacturer,
model=model_line,
serial=serial_line,
upgrade_hours=upgrade_hours,
chassis_hours=chassis_hours,
mileage=mileage,
work_order_number=display_code,
generated_date=generated_date,
report_title="Računi putnog naloga",
page_size=A4,
margin=margin,
header_h=header_h,
footer_h=footer_h,
)
page_num = 1
draw_header_footer(page_num)
y = content_top
y -= 8
pdf.setFont("Vera-Bold", 13)
pdf.drawString(margin, y, f"Računi putnog naloga {display_code}")
y -= 20
pdf.setFont("Vera", 10)
pdf.drawString(margin, 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.")
y -= 20
pdf.drawString(margin, 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}")
page_num += 1
draw_header_footer(page_num)
y = content_top
y -= 8
pdf.setFont("Vera-Bold", 13)
pdf.drawString(margin, y, f"Račun #{index}")
y -= 22
pdf.setFont("Helvetica", 10)
pdf.drawString(40, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
pdf.setFont("Vera", 10)
pdf.drawString(margin, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}")
y -= 16
pdf.drawString(40, y, f"lokacija: {invoice.lokacija or '-'}")
pdf.drawString(margin, y, f"lokacija: {invoice.lokacija or '-'}")
y -= 16
pdf.drawString(40, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}")
pdf.drawString(margin, 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]}")
pdf.drawString(margin, y, f"opis: {(invoice.opis or '-')[:140]}")
y -= 20
if not invoice.image:
pdf.drawString(40, y, "slika: Nema slike.")
pdf.drawString(margin, y, "slika: Nema slike.")
continue
suffix = Path(invoice.image.name or '').suffix.lower()
if suffix == '.pdf':
pdf.drawString(margin, y, "prilog: PDF račun (otvorite datoteku računa za pregled).")
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
max_width = width - (2 * margin)
max_height = y - content_bottom
if max_height < 120:
pdf.showPage()
y = height - 60
max_height = y - 60
page_num += 1
draw_header_footer(page_num)
y = content_top
max_height = y - content_bottom
# Kompresiraj u JPEG u memoriji radi manje veličine PDF-a
compress_w = min(image.width, 1280)
if image.width > compress_w:
ratio_c = compress_w / float(image.width)
image = image.resize((compress_w, max(1, int(image.height * ratio_c))), Image.LANCZOS)
buf = BytesIO()
image.save(buf, format='JPEG', quality=75, optimize=True)
buf.seek(0)
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)
image_reader = ImageReader(buf)
pdf.drawImage(
image_reader,
40,
margin,
y - draw_height,
width=draw_width,
height=draw_height,
@@ -140,7 +668,7 @@ def _build_work_order_invoices_pdf(work_order):
mask='auto',
)
except (UnidentifiedImageError, OSError):
pdf.drawString(40, y, "slika: Slika nije dostupna ili je oštećena.")
pdf.drawString(margin, y, "slika: Slika nije dostupna ili je oštećena.")
finally:
invoice.image.close()
@@ -158,8 +686,252 @@ def build_work_order_invoices_pdf_task(work_order_id):
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"
filename = f"{_work_order_display_code(work_order)}.work-order-invoices.pdf"
return {
"filename": filename,
"pdf_base64": pdf_b64,
}
}
def _parse_decimal(value):
if value in (None, ''):
return Decimal('0.00')
try:
normalized = str(value).strip().replace('', '').replace(' ', '').replace(',', '.')
return Decimal(normalized)
except (InvalidOperation, ValueError, TypeError):
return Decimal('0.00')
def _extract_invoice_text(invoice):
if not invoice.image:
return ''
suffix = Path(invoice.image.name or '').suffix.lower()
if suffix == '.pdf':
invoice.image.open('rb')
try:
reader = PdfReader(invoice.image)
return '\n'.join((page.extract_text() or '') for page in reader.pages)
finally:
invoice.image.close()
invoice.image.open('rb')
try:
with Image.open(invoice.image) as source:
image = source.convert('RGB')
return pytesseract.image_to_string(image, lang='hrv+eng')
finally:
invoice.image.close()
def _extract_invoice_number(text, fallback):
if not text:
return fallback
patterns = [
r'(?i)(?:broj\s*računa|račun\s*broj|invoice\s*no\.?)\s*[:#]?\s*([A-Z0-9\/\-\._]+)',
r'(?i)(?:broj)\s*[:#]?\s*([A-Z0-9\/\-\._]{4,})',
]
for pattern in patterns:
match = re.search(pattern, text)
if match:
candidate = (match.group(1) or '').strip()
if candidate:
return candidate
return fallback
def _extract_total_amount(text):
if not text:
return Decimal('0.00')
def _line_candidates(line):
number_pattern = r'(?<!\d)(\d{1,3}(?:[.\s]\d{3})*(?:,\d{2})|\d+(?:[.,]\d{2}))(?!\d)'
return [_parse_decimal(match) for match in re.findall(number_pattern, line)]
preferred_lines = []
for line in text.splitlines():
lower = line.lower()
if any(token in lower for token in ('ukupno', 'za platiti', 'iznos', 'total')):
preferred_lines.append(line)
for line in preferred_lines:
candidates = [value for value in _line_candidates(line) if value > Decimal('0.00')]
if candidates:
return max(candidates)
all_candidates = []
for line in text.splitlines():
all_candidates.extend([value for value in _line_candidates(line) if value > Decimal('0.00')])
if all_candidates:
return max(all_candidates)
return Decimal('0.00')
@shared_task
def process_work_order_invoice_ocr(invoice_id):
from .models import WorkOrderInvoice, WorkOrderAdditionalCostsTable, GeneratedWorkOrderPdf
invoice = (
WorkOrderInvoice.objects
.select_related('work_order')
.filter(pk=invoice_id, is_active=True)
.first()
)
if invoice is None or invoice.work_order_id is None:
return {"status": "skipped", "reason": "invoice-not-found"}
try:
text = _extract_invoice_text(invoice)
except (UnidentifiedImageError, OSError, TesseractNotFoundError, ValueError, RuntimeError, PdfReadError):
text = ''
attachment_name = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else ''
row = {
'naziv': (invoice.naziv_racuna or '').strip() or attachment_name or f'Račun {invoice.pk}',
'broj_racuna': _extract_invoice_number(text, fallback=str(invoice.pk)),
'ukupan_iznos': f"{_extract_total_amount(text):.2f}",
'prilog': attachment_name,
'source_invoice_id': str(invoice.pk),
}
with transaction.atomic():
table, _ = WorkOrderAdditionalCostsTable.objects.select_for_update().get_or_create(
work_order=invoice.work_order,
defaults={'data': {'rows': []}},
)
existing_rows = table.data.get('rows', []) if isinstance(table.data, dict) else []
existing_row = next(
(
item for item in existing_rows
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) == str(invoice.pk)
),
None,
)
normalized_rows = [
item for item in existing_rows
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) != str(invoice.pk)
]
if existing_row and row['ukupan_iznos'] == '0.00':
existing_amount = str(existing_row.get('ukupan_iznos', '') or '').strip()
if existing_amount:
row['ukupan_iznos'] = existing_amount
if existing_row and not row['broj_racuna']:
row['broj_racuna'] = str(existing_row.get('broj_racuna', '') or '').strip()
normalized_rows.append(row)
total = sum((_parse_decimal(item.get('ukupan_iznos')) for item in normalized_rows if isinstance(item, dict)), Decimal('0.00'))
table.data = {'rows': normalized_rows}
table.total_for_payout = total.quantize(Decimal('0.01'))
table.save(update_fields=['data', 'total_for_payout', 'updated_at'])
GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order=invoice.work_order,
pdf_type__in=['work_order', 'invoices'],
).update(
is_active=False,
status='failed',
error_message='PDF cache invalidiran zbog OCR ažuriranja dodatnih troškova.',
)
return {"status": "ok", "invoice_id": str(invoice.pk)}
@shared_task
def cleanup_expired_generated_pdfs_task():
now = timezone.now()
expired = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
expires_at__isnull=False,
expires_at__lte=now,
)
deleted = 0
for item in expired:
if item.file:
item.file.delete(save=False)
item.is_active = False
item.status = 'failed'
item.error_message = 'PDF cache istekao.'
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
deleted += 1
return {"deleted": deleted}
@shared_task
def build_work_order_pdf_cached_task(generated_pdf_id):
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
from .services import NotificationService
generated = (
GeneratedWorkOrderPdf.objects
.select_related('work_order', 'work_order__vehicle', 'requested_by')
.filter(pk=generated_pdf_id, is_active=True)
.first()
)
if generated is None:
return {"error": "Generated PDF zapis nije pronađen."}
work_order = generated.work_order
try:
if generated.pdf_type == 'invoices':
payload = build_work_order_invoices_pdf_task(str(work_order.pk))
if not isinstance(payload, dict) or payload.get('error'):
raise ValueError(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 ValueError('PDF sadržaj nije dostupan.')
pdf_bytes = base64.b64decode(pdf_b64)
elif generated.pdf_type == 'service_records':
pdf_bytes = _build_work_order_service_records_pdf(work_order)
else:
pdf_bytes = _build_work_order_pdf(work_order)
filename = generated.filename or f"{_work_order_display_code(work_order)}.{generated.pdf_type}.pdf"
generated.file.save(filename, ContentFile(pdf_bytes), save=False)
generated.status = 'ready'
generated.error_message = ''
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
if generated.requested_by:
NotificationService.create_notification(
recipient=generated.requested_by,
title="PDF spreman",
message=f"PDF dokument je uspješno generiran za putni nalog {_work_order_display_code(work_order)}.",
level="success",
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": generated.pdf_type,
"stage": "completed",
"generated_pdf_id": str(generated.pk),
"download_url": f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/",
"filename": generated.filename or filename,
"expires_at": generated.expires_at.isoformat() if generated.expires_at else None,
},
)
return {"status": "ready", "generated_pdf_id": str(generated.pk)}
except Exception as exc:
generated.status = 'failed'
generated.error_message = str(exc)
generated.save(update_fields=['status', 'error_message', 'updated_at'])
if generated.requested_by:
NotificationService.create_notification(
recipient=generated.requested_by,
title="Greška kod PDF-a",
message=f"Generiranje PDF dokumenta nije uspjelo za putni nalog {_work_order_display_code(work_order)}.",
level="warning",
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": generated.pdf_type,
"stage": "failed",
"generated_pdf_id": str(generated.pk),
},
)
logger.exception("Greška kod build_work_order_pdf_cached_task: %s", exc)
return {"status": "failed", "error": str(exc)}

View File

@@ -4,14 +4,12 @@ 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
def test_vehicle_servicer_many_to_one_allows_multiple_vehicles(test_user):
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)
Vehicle.objects.create(registration_number="ZG-222-BB", assigned_servicer=test_user)
assert Vehicle.objects.filter(assigned_servicer=test_user).count() == 2
@pytest.mark.django_db
def test_vehicle_next_service_logic():

View File

@@ -116,7 +116,7 @@ class WorkOrderAndServiceRecordNotificationTests(TestCase):
"creator": self.creator,
"start_mileage": 10000,
"end_mileage": 10120,
"purpose": "Routine visit"
"purpose": "kontrola"
}
wo = VehicleService.create_work_order(data)

View File

@@ -0,0 +1,169 @@
import uuid
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from modules.fleet.models import ServiceContextNote, Vehicle, WorkOrder
from modules.task_management.models import Task
class ServiceContextNotesApiTests(TestCase):
def setUp(self):
user_model = get_user_model()
suffix = uuid.uuid4().hex[:8]
self.supervisor = user_model.objects.create_user(
username=f'supervisor-{suffix}',
email=f'supervisor-{suffix}@example.test',
password='test1234',
is_team_member=True,
is_serviser=False,
)
self.servicer = user_model.objects.create_user(
username=f'servicer-{suffix}',
email=f'servicer-{suffix}@example.test',
password='test1234',
is_team_member=True,
is_serviser=True,
)
self.other = user_model.objects.create_user(
username=f'other-{suffix}',
email=f'other-{suffix}@example.test',
password='test1234',
is_team_member=False,
is_serviser=False,
)
self.second_servicer = user_model.objects.create_user(
username=f'servicer2-{suffix}',
email=f'servicer2-{suffix}@example.test',
password='test1234',
is_team_member=True,
is_serviser=True,
)
self.team_member_non_servicer = user_model.objects.create_user(
username=f'team-{suffix}',
email=f'team-{suffix}@example.test',
password='test1234',
is_team_member=True,
is_serviser=False,
)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number=f'SN-{suffix[:6].upper()}',
crane_serial_number='654321',
assigned_servicer=self.servicer,
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.supervisor,
purpose='kontrola',
status='open',
)
self.task = Task.objects.create(
title='Aktivni servis',
assigned_to=self.servicer,
vehicle=self.vehicle,
work_order=self.work_order,
status='aktivan',
)
self.client = APIClient()
def test_supervisor_can_create_team_note_for_active_context(self):
self.client.force_authenticate(user=self.supervisor)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Provjeriti opremu prije izlaska na teren.',
'note_date': '2026-07-21',
'audience': 'team',
'work_order': str(self.work_order.id),
'task': str(self.task.id),
},
format='json',
)
self.assertEqual(response.status_code, 201, response.content)
self.assertGreaterEqual(int(response.data.get('created_count', 0)), 2)
self.client.force_authenticate(user=self.servicer)
list_response = self.client.get(reverse('service-context-note-list'))
self.assertEqual(list_response.status_code, 200, list_response.content)
self.assertEqual(len(list_response.data), 1)
self.assertEqual(list_response.data[0]['audience_source'], 'team')
self.assertEqual(str(list_response.data[0]['task']), str(self.task.id))
def test_servicer_can_create_member_note_for_other_servicer(self):
self.client.force_authenticate(user=self.servicer)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Serviser šalje bilješku drugom serviseru.',
'audience': 'member',
'target_user': str(self.second_servicer.id),
},
format='json',
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(int(response.data.get('created_count', 0)), 1)
def test_recipient_can_close_personal_note(self):
note = ServiceContextNote.objects.create(
created_by=self.supervisor,
recipient=self.servicer,
audience_source='member',
note='Osobna bilješka',
)
self.client.force_authenticate(user=self.servicer)
close_url = reverse('service-context-note-close', kwargs={'pk': note.id})
close_response = self.client.post(close_url, data={}, format='json')
self.assertEqual(close_response.status_code, 200, close_response.content)
note.refresh_from_db()
self.assertTrue(note.is_closed)
list_response = self.client.get(reverse('service-context-note-list'))
self.assertEqual(list_response.status_code, 200)
self.assertEqual(len(list_response.data), 0)
def test_supervisor_can_target_multiple_servicers(self):
self.client.force_authenticate(user=self.supervisor)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Zajednička bilješka za dva servisera.',
'audience': 'member',
'target_users': [str(self.servicer.id), str(self.second_servicer.id)],
},
format='json',
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(int(response.data.get('created_count', 0)), 2)
def test_supervisor_can_create_member_note_for_non_servicer_team_member(self):
self.client.force_authenticate(user=self.supervisor)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Bilješka za team člana koji nije serviser.',
'audience': 'member',
'target_users': [str(self.team_member_non_servicer.id)],
},
format='json',
)
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(int(response.data.get('created_count', 0)), 1)
def test_member_note_rejects_non_team_member_target(self):
self.client.force_authenticate(user=self.supervisor)
response = self.client.post(
reverse('service-context-note-list'),
data={
'note': 'Bilješka ne-smije ići van tima.',
'audience': 'member',
'target_users': [str(self.other.id)],
},
format='json',
)
self.assertEqual(response.status_code, 400, response.content)
self.assertIn('target_users', response.data)

View File

@@ -3,6 +3,9 @@ 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
from modules.fleet.serializers import WorkOrderSerializer
from modules.task_management.models import Task
from rest_framework.test import APIRequestFactory
class FleetServiceTests(TestCase):
@@ -19,7 +22,7 @@ class FleetServiceTests(TestCase):
)
def test_create_work_order_via_service(self):
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "Delivery"}
data = {"vehicle": self.vehicle, "creator": self.user, "start_mileage": 5000, "end_mileage": 5200, "purpose": "kontrola"}
wo = VehicleService.create_work_order(data)
self.vehicle.refresh_from_db()
self.assertEqual(self.vehicle.current_mileage, 5200)
@@ -49,4 +52,80 @@ class FleetServiceTests(TestCase):
# Nalog sa završnom kilometražom
wo.end_mileage = 1100
assert wo.distance == 100
assert wo.distance == 100
def test_closing_work_order_moves_task_to_ready_for_completion(self):
work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose="kontrola",
status="open",
start_mileage=5000,
end_mileage=5200,
)
task = Task.objects.create(
title="Servis",
description="",
status="servis",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=work_order,
)
VehicleService.update_work_order(work_order, {"status": "closed"})
task.refresh_from_db()
self.assertEqual(task.status, "spreman_za_zavrsetak")
def test_closing_work_order_moves_active_task_to_ready_for_completion(self):
work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose="kontrola",
status="open",
start_mileage=5000,
end_mileage=5200,
)
task = Task.objects.create(
title="Preventiva",
description="",
status="aktivan",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=work_order,
)
VehicleService.update_work_order(work_order, {"status": "closed"})
task.refresh_from_db()
self.assertEqual(task.status, "spreman_za_zavrsetak")
def test_work_order_serializer_autogenerates_display_code(self):
self.user.first_name = "Mario"
self.user.last_name = "Tkalac"
self.user.save(update_fields=["first_name", "last_name"])
request = APIRequestFactory().post('/api/fleet/work-orders/', {})
request.user = self.user
serializer = WorkOrderSerializer(
data={
"vehicle": str(self.vehicle.id),
"purpose": "kontrola",
},
context={"request": request},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
work_order = serializer.save()
self.assertRegex(work_order.display_code, r'^MT[0-9]{6}$')
def test_work_order_serializer_rejects_invalid_display_code(self):
request = APIRequestFactory().post('/api/fleet/work-orders/', {})
request.user = self.user
serializer = WorkOrderSerializer(
data={
"vehicle": str(self.vehicle.id),
"purpose": "kontrola",
"display_code": "BAD-CODE",
},
context={"request": request},
)
self.assertFalse(serializer.is_valid())
self.assertIn("display_code", serializer.errors)

View File

@@ -0,0 +1,83 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase, override_settings
from modules.fleet.models import GeneratedWorkOrderPdf, Vehicle, VehicleNotification, WorkOrder
from modules.fleet.tasks import send_work_order_email_bundle_task
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
DEFAULT_FROM_EMAIL='no-reply@example.test',
)
class WorkOrderEmailTaskCachedPdfTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.user = user_model.objects.create_user(
username='task-mail-user',
email='task-mail-user@example.test',
password='test1234',
)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number='TASK-CACHE-001',
crane_serial_number='112233',
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT010126',
purpose='kontrola',
)
@patch('django.core.mail.message.EmailMessage.send', side_effect=Exception('smtp-fail'))
def test_failed_email_creates_cached_pdf_links_in_notification(self, _mock_send):
result = send_work_order_email_bundle_task(
work_order_id=str(self.work_order.pk),
requested_by_id=str(self.user.pk),
recipients=['client@example.test'],
include_work_order_pdf=True,
include_service_records_pdf=False,
include_invoices_pdf=False,
include_images=False,
include_monthly_tasks=False,
)
self.assertEqual(result.get('status'), 'failed')
cached = GeneratedWorkOrderPdf.objects.filter(work_order=self.work_order, pdf_type='work_order', status='ready')
self.assertTrue(cached.exists())
notif = VehicleNotification.objects.filter(
recipient=self.user,
metadata__entity_type='work_order_email',
metadata__stage='failed',
).order_by('-created_at').first()
self.assertIsNotNone(notif)
cached_pdfs = notif.metadata.get('cached_pdfs') if isinstance(notif.metadata, dict) else None
self.assertTrue(isinstance(cached_pdfs, list) and len(cached_pdfs) > 0)
self.assertIn('download_url', cached_pdfs[0])
def test_success_email_appends_requester_signature(self):
mail.outbox.clear()
result = send_work_order_email_bundle_task(
work_order_id=str(self.work_order.pk),
requested_by_id=str(self.user.pk),
recipients=['client@example.test'],
message='Test poruka bez potpisa.',
include_work_order_pdf=True,
include_service_records_pdf=False,
include_invoices_pdf=False,
include_images=False,
include_monthly_tasks=False,
)
self.assertEqual(result.get('status'), 'ok')
self.assertEqual(len(mail.outbox), 1)
sent_body = mail.outbox[0].body
self.assertIn('Test poruka bez potpisa.', sent_body)
self.assertIn('S poštovanjem / Freundliche Grüße / Kind regards', sent_body)
self.assertIn(self.user.email, sent_body)

View File

@@ -0,0 +1,273 @@
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
import uuid
from io import BytesIO
from datetime import timedelta
import zipfile
from PIL import Image
from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, GeneratedWorkOrderPdf
from modules.task_management.models import Task
def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
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',
)
def create_test_pdf(filename='test.pdf'):
content = b'%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF'
return SimpleUploadedFile(
name=filename,
content=content,
content_type='application/pdf',
)
class WorkOrderImagesEndpointTests(TestCase):
def setUp(self):
user_model = get_user_model()
suffix = uuid.uuid4().hex[:8]
self.user = user_model.objects.create_user(
username=f'wo-img-user-{suffix}',
email=f'wo-img-user-{suffix}@example.test',
password='test1234',
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number='WO-IMG-001',
crane_serial_number='123456',
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose='kontrola',
)
self.task = Task.objects.create(
title='Test servisni zadatak',
assigned_to=self.user,
vehicle=self.vehicle,
work_order=self.work_order,
)
self.service_record = VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=self.task,
performed_by=self.user,
description='Servisni zapis za test izolacije fotografija.',
service_title='Test servis',
)
def test_work_order_images_endpoint_isolated_from_service_record_photos(self):
# Existing service photo must NOT appear in work-order images response
VehicleServicePhoto.objects.create(
service_record=self.service_record,
image=create_test_image('service.jpg'),
description='Service photo',
uploaded_by=self.user,
)
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
response = self.client.post(
upload_url,
data={
'image': create_test_image('workorder.jpg'),
'description': 'Work order photo',
},
format='multipart',
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(str(response.data.get('work_order')), str(self.work_order.pk))
list_response = self.client.get(upload_url)
self.assertEqual(list_response.status_code, 200)
images = list_response.data.get('images', [])
self.assertEqual(len(images), 1)
self.assertEqual(str(images[0].get('work_order_id')), str(self.work_order.pk))
def test_service_record_files_endpoint_does_not_return_work_order_images(self):
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
self.client.post(
upload_url,
data={
'image': create_test_image('workorder-2.jpg'),
'description': 'WO isolated',
},
format='multipart',
)
service_photos_url = f"{reverse('service-photo-list')}?service_record_id={self.service_record.pk}"
response = self.client.get(service_photos_url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 0)
def test_work_order_images_endpoint_returns_api_file_urls(self):
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
create_response = self.client.post(
upload_url,
data={
'image': create_test_image('workorder-url.jpg'),
'description': 'URL test',
},
format='multipart',
)
self.assertEqual(create_response.status_code, 201, create_response.data)
list_response = self.client.get(upload_url)
self.assertEqual(list_response.status_code, 200)
images = list_response.data.get('images', [])
self.assertEqual(len(images), 1)
photo_id = images[0]['id']
self.assertIn('/api/fleet/work-orders/', images[0]['original_url'])
self.assertIn(f'/images/{photo_id}/file', images[0]['original_url'])
file_response = self.client.get(images[0]['original_url'])
self.assertEqual(file_response.status_code, 200)
self.assertTrue(file_response['Content-Type'].startswith('image/'))
def test_work_order_invoice_serializer_exposes_image_api_url(self):
invoice = WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='Test račun',
image=create_test_image('invoice.jpg'),
created_by=self.user,
)
list_url = f"{reverse('work-order-invoice-list')}?work_order_id={self.work_order.pk}"
list_response = self.client.get(list_url)
self.assertEqual(list_response.status_code, 200)
self.assertEqual(len(list_response.data), 1)
image_url = list_response.data[0].get('image_url')
self.assertIsNotNone(image_url)
self.assertIn(f'/api/fleet/work-order-invoices/{invoice.pk}/image/', image_url)
image_response = self.client.get(image_url)
self.assertEqual(image_response.status_code, 200)
self.assertTrue(image_response['Content-Type'].startswith('image/'))
def test_work_order_invoice_serializer_supports_pdf_file(self):
invoice = WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='PDF račun',
image=create_test_pdf('invoice.pdf'),
created_by=self.user,
)
list_url = f"{reverse('work-order-invoice-list')}?work_order_id={self.work_order.pk}"
list_response = self.client.get(list_url)
self.assertEqual(list_response.status_code, 200)
self.assertEqual(len(list_response.data), 1)
image_url = list_response.data[0].get('image_url')
self.assertIsNotNone(image_url)
self.assertIn(f'/api/fleet/work-order-invoices/{invoice.pk}/image/', image_url)
self.assertEqual(list_response.data[0].get('image_content_type'), 'application/pdf')
file_response = self.client.get(image_url)
self.assertEqual(file_response.status_code, 200)
self.assertEqual(file_response['Content-Type'], 'application/pdf')
def test_additional_costs_table_can_be_saved_and_total_is_computed(self):
url = f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/"
response = self.client.put(
url,
data={
"rows": [
{"naziv": "Cestarina", "broj_racuna": "R-100", "ukupan_iznos": "12,50", "prilog": "racun-1.pdf"},
{"naziv": "Gorivo", "broj_racuna": "R-101", "ukupan_iznos": "7.50", "prilog": "racun-2.jpg"},
]
},
format='json',
)
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
self.assertEqual(payload.get('total_for_payout'), '20.00')
rows = payload.get('data', {}).get('rows', [])
self.assertEqual(len(rows), 2)
def test_task_service_context_contains_additional_costs_table(self):
table_url = f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/"
save_response = self.client.put(
table_url,
data={"rows": [{"naziv": "Parking", "broj_racuna": "P-1", "ukupan_iznos": "5.00", "prilog": "parking.pdf"}]},
format='json',
)
self.assertEqual(save_response.status_code, 200, save_response.content)
context_response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/task-service-context/")
self.assertEqual(context_response.status_code, 200, context_response.content)
payload = context_response.json()
self.assertIn('additional_costs_table', payload)
self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00')
def test_invoice_upload_immediately_appears_in_additional_costs_table(self):
create_response = self.client.post(
reverse('work-order-invoice-list'),
data={
'work_order': str(self.work_order.pk),
'naziv_racuna': 'Test2',
'datum': '2026-07-19',
'image': create_test_image('test2.jpg'),
},
format='multipart',
)
self.assertEqual(create_response.status_code, 201, create_response.content)
table_response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/")
self.assertEqual(table_response.status_code, 200, table_response.content)
rows = table_response.json().get('data', {}).get('rows', [])
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].get('naziv'), 'Test2')
def test_additional_costs_update_invalidates_work_order_pdf_cache(self):
cached = GeneratedWorkOrderPdf.objects.create(
work_order=self.work_order,
requested_by=self.user,
pdf_type='work_order',
status='ready',
filename='MT010101.work-order.pdf',
expires_at=timezone.now() + timedelta(hours=1),
)
response = self.client.put(
f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/",
data={"rows": [{"naziv": "Test", "broj_racuna": "R-1", "ukupan_iznos": "10.00", "prilog": "r1.pdf"}]},
format='json',
)
self.assertEqual(response.status_code, 200, response.content)
cached.refresh_from_db()
self.assertFalse(cached.is_active)
self.assertEqual(cached.status, 'failed')
def test_service_records_docx_contains_embedded_service_photos(self):
VehicleServicePhoto.objects.create(
service_record=self.service_record,
image=create_test_image('service-record-docx.jpg', size=(320, 200), color='blue'),
description='DOCX photo',
uploaded_by=self.user,
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(
response['Content-Type'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
)
archive = zipfile.ZipFile(BytesIO(response.content))
media_files = [name for name in archive.namelist() if name.startswith('word/media/')]
self.assertTrue(media_files, archive.namelist())
settings_xml = archive.read('word/settings.xml').decode('utf-8')
self.assertNotIn('documentProtection', settings_xml)
self.assertNotIn('writeProtection', settings_xml)

View File

@@ -0,0 +1,128 @@
import uuid
from datetime import date
from io import BytesIO
from PIL import Image
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework.test import APIClient
from unittest.mock import patch
from modules.fleet.models import ServiceContextNote, Vehicle, WorkOrder, WorkOrderInvoice, WorkOrderPhoto
from modules.task_management.models import Task
def _test_image(filename='test.jpg', size=(40, 40), color='blue'):
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',
)
@override_settings(
EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
DEFAULT_FROM_EMAIL='no-reply@example.test',
)
class WorkOrderSendEmailTests(TestCase):
def setUp(self):
user_model = get_user_model()
suffix = uuid.uuid4().hex[:8]
self.user = user_model.objects.create_user(
username=f'wo-mail-user-{suffix}',
email=f'wo-mail-user-{suffix}@example.test',
password='test1234',
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.vehicle = Vehicle.objects.create(
asset_type='crane',
registration_number='WO-MAIL-001',
crane_serial_number='654321',
)
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT010126',
purpose='kontrola',
)
def test_send_email_supports_multiple_recipients_and_all_requested_attachments(self):
WorkOrderPhoto.objects.create(
work_order=self.work_order,
image=_test_image('work-order-photo.jpg'),
description='Work order image',
uploaded_by=self.user,
)
WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='Račun 1',
image=_test_image('invoice-image.jpg'),
created_by=self.user,
)
note = ServiceContextNote.objects.create(
created_by=self.user,
recipient=self.user,
audience_source='self',
work_order=self.work_order,
note='Odabrana test bilješka.',
)
Task.objects.create(
title='Mjesečni task',
description='Provjera mjesečnog task popisa.',
assigned_to=self.user,
vehicle=self.vehicle,
work_order=self.work_order,
scheduled_date=date(2026, 7, 10),
)
with patch('modules.fleet.views.send_work_order_email_bundle_task.delay') as mocked_delay:
response = self.client.post(
reverse('workorder-send-email', kwargs={'pk': self.work_order.pk}),
data={
'recipients': ['client@example.test', 'supervisor@example.test'],
'subject': 'Test slanje dokumentacije',
'message': 'U privitku su svi traženi dokumenti.',
'include_work_order_pdf': True,
'include_service_records_pdf': True,
'include_invoices_pdf': True,
'include_images': True,
'include_monthly_tasks': True,
'month': '2026-07',
'service_note_id': str(note.pk),
},
format='json',
)
self.assertEqual(response.status_code, 202, response.data)
self.assertTrue(response.data.get('queued'))
self.assertEqual(len(response.data.get('recipients', [])), 2)
mocked_delay.assert_called_once()
call_kwargs = mocked_delay.call_args.kwargs
self.assertEqual(call_kwargs.get('work_order_id'), str(self.work_order.pk))
self.assertEqual(call_kwargs.get('requested_by_id'), str(self.user.id))
self.assertEqual(call_kwargs.get('month_key'), '2026-07')
self.assertEqual(call_kwargs.get('service_note_id'), str(note.pk))
def test_send_email_keeps_backward_compatible_single_recipient_payload(self):
with patch('modules.fleet.views.send_work_order_email_bundle_task.delay') as mocked_delay:
response = self.client.post(
reverse('workorder-send-email', kwargs={'pk': self.work_order.pk}),
data={
'recipient': 'single@example.test',
'subject': 'Legacy send',
},
format='json',
)
self.assertEqual(response.status_code, 202, response.data)
self.assertTrue(response.data.get('queued'))
self.assertEqual(response.data.get('recipients'), ['single@example.test'])
mocked_delay.assert_called_once()

View File

@@ -2,7 +2,8 @@ from rest_framework.routers import DefaultRouter
from django.urls import path
from .views import (
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
WorkOrderInvoiceViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
monthly_servicer_report_docx, monthly_costs_report_docx, MonthlyServicerDayEntryViewSet,
)
router = DefaultRouter()
@@ -10,13 +11,17 @@ 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-notes', ServiceContextNoteViewSet, basename='service-context-note')
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')
router.register(r'monthly-servicer-day-entries', MonthlyServicerDayEntryViewSet, basename='monthly-servicer-day-entry')
urlpatterns = router.urls
urlpatterns += [
path('pusher-auth/', pusher_auth, name='pusher-auth'),
path('reports/monthly-servicer/', monthly_servicer_report_docx, name='monthly-servicer-report'),
path('reports/monthly-costs/', monthly_costs_report_docx, name='monthly-costs-report'),
]

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,13 @@ from modules.crm.models import Client
logger = logging.getLogger(__name__)
class InvoiceService:
@staticmethod
def create_full_invoice(*args, **kwargs):
from modules.invoicing.services import InvoiceService as _InvoiceService
return _InvoiceService.create_full_invoice(*args, **kwargs)
@shared_task(bind=True, max_retries=3)
def process_paperless_document_task(self, document_id):
"""
@@ -57,8 +64,6 @@ def process_paperless_document_task(self, document_id):
"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()

View File

@@ -1,21 +1,69 @@
# backend/modules/task_management/admin.py
# backend/modules/task_management/admin.py
from django.contrib import admin
from .models import Task
from .models import Task, TaskCalendar, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
class TaskWorkEntryInline(admin.TabularInline):
model = TaskWorkEntry
extra = 0
fields = ('sort_order', 'title', 'work_description', 'active_faults', 'explanation_hr', 'template_entry')
readonly_fields = ('template_entry',)
class TaskWorkHoursTableInline(admin.StackedInline):
model = TaskWorkHoursTable
extra = 0
fields = ('data', 'is_active')
class TaskTemplateEntryInline(admin.TabularInline):
model = TaskTemplateEntry
extra = 0
fields = ('sort_order', 'title', 'work_description', 'active_faults', 'explanation_hr', 'is_active')
@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
list_display = ('title', 'status', 'scheduled_date', 'assigned_to', 'vehicle', 'work_order', 'created_at')
list_filter = ('status', 'scheduled_date', 'created_at', 'work_order')
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')
readonly_fields = ('id', 'created_at', 'updated_at')
inlines = (TaskWorkHoursTableInline, TaskWorkEntryInline)
@admin.register(TaskCalendar)
class TaskCalendarAdmin(admin.ModelAdmin):
list_display = ('scheduled_date', 'title', 'status', 'assigned_to', 'vehicle', 'work_order')
list_filter = ('scheduled_date', 'status', 'assigned_to')
search_fields = ('title', 'assigned_to__email', 'vehicle__registration_number', 'work_order__display_code')
readonly_fields = ('id', 'created_at', 'updated_at')
ordering = ('scheduled_date', 'assigned_to__first_name', 'assigned_to__last_name')
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset.filter(is_active=True, scheduled_date__isnull=False)
@admin.register(TaskTemplate)
class TaskTemplateAdmin(admin.ModelAdmin):
list_display = ('code', 'title', 'status', 'is_active', 'created_at')
list_filter = ('status', 'is_active', 'created_at')
search_fields = ('code', 'title', 'description')
inlines = (TaskTemplateEntryInline,)
@admin.register(TaskWorkEntry)
class TaskWorkEntryAdmin(admin.ModelAdmin):
list_display = ('task', 'sort_order', 'title', 'template_entry', 'created_at')
list_filter = ('created_at',)
search_fields = ('task__title', 'title', 'active_faults', 'explanation_hr')
readonly_fields = ('id', 'created_at', 'updated_at')
@admin.register(TaskWorkHoursTable)
class TaskWorkHoursTableAdmin(admin.ModelAdmin):
list_display = ('task', 'updated_at', 'is_active')
search_fields = ('task__title', 'task__vehicle__registration_number')
readonly_fields = ('id', 'created_at', 'updated_at')

View File

@@ -0,0 +1,7 @@
from rest_framework.exceptions import APIException
class OpenWorkOrderBlockingTaskClose(APIException):
status_code = 409
default_code = 'open_work_order_blocking_task_close'
default_detail = 'Povezani putni nalog je još otvoren.'

View File

@@ -0,0 +1,72 @@
# Generated by Django 5.2.15 on 2026-07-12
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0003_task_vehicle'),
]
operations = [
migrations.CreateModel(
name='TaskTemplate',
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')),
('code', models.SlugField(max_length=64, unique=True)),
('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)),
],
options={
'ordering': ['title'],
},
),
migrations.CreateModel(
name='TaskTemplateEntry',
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')),
('sort_order', models.PositiveSmallIntegerField(default=1)),
('title', models.CharField(max_length=255)),
('work_description', models.TextField(blank=True)),
('active_faults', models.TextField(blank=True, help_text='Aktivne greške.')),
('explanation_hr', models.TextField(blank=True, help_text='Obrazloženje na hrvatskom jeziku.')),
('task_template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='task_management.tasktemplate')),
],
options={
'ordering': ['sort_order', 'created_at'],
},
),
migrations.CreateModel(
name='TaskWorkEntry',
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')),
('sort_order', models.PositiveSmallIntegerField(default=1)),
('title', models.CharField(max_length=255)),
('work_description', models.TextField(blank=True)),
('active_faults', models.TextField(blank=True)),
('explanation_hr', models.TextField(blank=True)),
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_entries', to='task_management.task')),
('template_entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='task_work_entries', to='task_management.tasktemplateentry')),
],
options={
'ordering': ['sort_order', 'created_at'],
},
),
migrations.AddConstraint(
model_name='tasktemplateentry',
constraint=models.UniqueConstraint(fields=('task_template', 'sort_order'), name='task_template_entry_sort_order_unique'),
),
]

View File

@@ -0,0 +1,100 @@
# Generated by Django 5.2.15 on 2026-07-12
from django.db import migrations
def seed_task_templates(apps, schema_editor):
TaskTemplate = apps.get_model('task_management', 'TaskTemplate')
TaskTemplateEntry = apps.get_model('task_management', 'TaskTemplateEntry')
templates = [
{
'code': 'zamjena-ulja-500h',
'title': 'Zamjena ulja nakon 500h',
'description': 'Redovni servis uljnog sustava nakon 500 radnih sati.',
'status': 'aktivan',
'entries': [
('Provjera razine i stanja ulja', 'Izvršena kontrola razine i vizualna kontrola kontaminacije ulja.', 'Nema aktivnih grešaka.', 'Sustav uredan, bez upozorenja.'),
('Ispuštanje starog ulja', 'Sigurno ispušteno staro ulje prema servisnoj proceduri.', 'Nema aktivnih grešaka.', 'Ulje zamijenjeno prema uputama proizvođača.'),
('Zamjena filtera ulja', 'Demontiran stari i ugrađen novi filter ulja.', 'Nema aktivnih grešaka.', 'Filter promijenjen i sustav pripremljen za punjenje.'),
('Punjenje i test sustava', 'Sustav napunjen novim uljem i odrađen probni rad.', 'Nema aktivnih grešaka.', 'Nakon testa nema curenja ni grešaka.'),
],
},
{
'code': 'zamjena-ulja-1000h',
'title': 'Zamjena ulja nakon 1000h',
'description': 'Veliki servis uljnog sustava nakon 1000 radnih sati.',
'status': 'aktivan',
'entries': [
('Dijagnostika sustava prije servisa', 'Očitani parametri rada i zabilježeno početno stanje.', 'Provjeriti povremeni pad tlaka.', 'Prijevod poruke greške i potvrda simptoma na hrvatskom.'),
('Zamjena ulja i glavnih filtera', 'Zamijenjeno ulje i svi pripadajući filter elementi.', 'Nema kritičnih aktivnih grešaka.', 'Servisni interval resetiran.'),
('Kontrola brtvi i spojeva', 'Pregledani spojevi i brtve na mogućnost curenja.', 'Nema aktivnih grešaka.', 'Spojevi su suhi i ispravni.'),
('Test pod opterećenjem', 'Izvršen funkcionalni test pod radnim opterećenjem.', 'Nema aktivnih grešaka.', 'Sustav stabilan pri opterećenju.'),
],
},
{
'code': 'promjena-senzora',
'title': 'Promjena senzora',
'description': 'Zamjena neispravnog senzora i verifikacija rada.',
'status': 'aktivan',
'entries': [
('Identifikacija neispravnog senzora', 'Dijagnostikom potvrđena neispravnost senzora.', 'Aktivna greška senzora.', 'Greška prevedena s engleskog i objašnjena korisniku.'),
('Demontaža i montaža novog senzora', 'Neispravan senzor uklonjen i ugrađen novi originalni dio.', 'Nema novih aktivnih grešaka.', 'Montaža izvedena prema specifikaciji.'),
('Kalibracija senzora', 'Izvršena kalibracija i sinkronizacija s upravljačkom jedinicom.', 'Nema aktivnih grešaka.', 'Kalibracija uspješna.'),
('Završno testiranje', 'Provjeren odziv sustava tijekom rada.', 'Nema aktivnih grešaka.', 'Senzor radi unutar tolerancije.'),
],
},
{
'code': 'aktivna-osovina-ukoso',
'title': 'Aktivna osovina ide ukoso',
'description': 'Dijagnostika i otklanjanje kvara aktivne osovine.',
'status': 'aktivan',
'entries': [
('Dijagnostika geometrije osovine', 'Provjereni kutovi i odstupanja geometrije osovine.', 'Aktivna greška upravljanja osovinom.', 'Opis greške preveden i pojašnjen na hrvatskom.'),
('Pregled hidrauličkih komponenti', 'Provjeren tlak i stanje hidrauličkih vodova.', 'Mogući pad tlaka na upravljačkom krugu.', 'Predložena zamjena kritičnog ventila.'),
('Korekcija i podešavanje', 'Izvršeno mehaničko/hidrauličko podešavanje osovine.', 'Nema aktivnih grešaka nakon podešavanja.', 'Osovina vraćena u radne tolerancije.'),
('Probna vožnja i završna potvrda', 'Izvršena probna vožnja i kontrola stabilnosti pravca.', 'Nema aktivnih grešaka.', 'Kvar otklonjen, vozilo spremno za rad.'),
],
},
]
for template_data in templates:
entries = template_data.pop('entries')
template, _ = TaskTemplate.objects.update_or_create(
code=template_data['code'],
defaults=template_data,
)
TaskTemplateEntry.objects.filter(task_template=template).delete()
for idx, entry in enumerate(entries, start=1):
title, work_description, active_faults, explanation_hr = entry
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=idx,
title=title,
work_description=work_description,
active_faults=active_faults,
explanation_hr=explanation_hr,
)
def reverse_seed_task_templates(apps, schema_editor):
TaskTemplate = apps.get_model('task_management', 'TaskTemplate')
TaskTemplate.objects.filter(
code__in=[
'zamjena-ulja-500h',
'zamjena-ulja-1000h',
'promjena-senzora',
'aktivna-osovina-ukoso',
]
).delete()
class Migration(migrations.Migration):
dependencies = [
('task_management', '0004_tasktemplate_taskworkentry'),
]
operations = [
migrations.RunPython(seed_task_templates, reverse_seed_task_templates),
]

View File

@@ -0,0 +1,39 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0005_seed_task_templates'),
]
operations = [
migrations.AlterField(
model_name='task',
name='status',
field=models.CharField(
choices=[
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
],
default='aktivan',
max_length=20,
),
),
migrations.AlterField(
model_name='tasktemplate',
name='status',
field=models.CharField(
choices=[
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
],
default='aktivan',
max_length=20,
),
),
]

View File

@@ -0,0 +1,29 @@
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0006_add_finished_task_status'),
]
operations = [
migrations.CreateModel(
name='TaskWorkHoursTable',
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')),
('data', models.JSONField(blank=True, default=None, null=True, verbose_name='Podaci tablice radnih sati')),
('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='work_hours_table', to='task_management.task', verbose_name='Radni zadatak')),
],
options={
'verbose_name': 'Tablica radnih sati',
'verbose_name_plural': 'Tablice radnih sati',
'ordering': ['-updated_at'],
},
),
]

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0007_taskworkhourstable'),
]
operations = [
migrations.AlterField(
model_name='task',
name='status',
field=models.CharField(
choices=[
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('spreman_za_zavrsetak', 'Spreman za završetak'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
],
default='aktivan',
max_length=20,
),
),
]

View File

@@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0008_task_add_ready_for_completion_status'),
]
operations = [
migrations.AddField(
model_name='task',
name='scheduled_date',
field=models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name='Planirani datum izvršenja',
help_text='Datum kada je planirano izvršenje radnog zadatka.',
),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-19 17:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0009_task_scheduled_date'),
]
operations = [
migrations.AlterField(
model_name='tasktemplate',
name='status',
field=models.CharField(choices=[('aktivan', 'Aktivan'), ('servis', 'U tijeku'), ('spreman_za_zavrsetak', 'Spreman za završetak'), ('zavrsen', 'Završen'), ('neaktivan', 'Otkazano')], default='aktivan', max_length=20),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.2.15 on 2026-07-21 11:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('task_management', '0010_alter_tasktemplate_status'),
]
operations = [
migrations.CreateModel(
name='TaskCalendar',
fields=[
],
options={
'verbose_name': 'Kalendar zadatka',
'verbose_name_plural': 'Kalendar zadataka',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('task_management.task',),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('task_management', '0011_taskcalendar'),
]
operations = [
migrations.AddField(
model_name='task',
name='service_report_note',
field=models.TextField(
blank=True,
default='',
verbose_name='Napomena izvještaja servisera',
help_text='Tekst napomene za PDF izvještaj servisera za ovaj zadatak.',
),
),
]

View File

@@ -1,10 +1,11 @@
# backend/modules/task_management/models.py
# 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
from core.base_models import BaseModel
class Task(BaseModel):
"""
@@ -13,6 +14,8 @@ class Task(BaseModel):
STATUS_CHOICES = [
('aktivan', 'Aktivan'),
('servis', 'U tijeku'),
('spreman_za_zavrsetak', 'Spreman za završetak'),
('zavrsen', 'Završen'),
('neaktivan', 'Otkazano'),
]
title = models.CharField(max_length=255)
@@ -23,9 +26,9 @@ class Task(BaseModel):
default='aktivan'
)
assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='tasks'
)
vehicle = models.ForeignKey(
@@ -44,6 +47,19 @@ class Task(BaseModel):
related_name='work_order_tasks',
verbose_name=_("Putni nalog"),
)
scheduled_date = models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name=_("Planirani datum izvršenja"),
help_text=_("Datum kada je planirano izvršenje radnog zadatka."),
)
service_report_note = models.TextField(
blank=True,
default='',
verbose_name=_("Napomena izvještaja servisera"),
help_text=_("Tekst napomene za PDF izvještaj servisera za ovaj zadatak."),
)
class Meta:
ordering = ['-created_at']
@@ -53,7 +69,109 @@ class Task(BaseModel):
def clean(self):
super().clean()
if self.status == 'neaktivan' and not self.work_order_id:
if self.status in {'neaktivan', 'zavrsen'} and not self.work_order_id:
raise ValidationError({
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
})
})
class TaskCalendar(Task):
class Meta:
proxy = True
verbose_name = _("Kalendar zadatka")
verbose_name_plural = _("Kalendar zadataka")
class TaskWorkHoursTable(BaseModel):
"""Interaktivna tablica radnih sati povezana s radnim zadatkom."""
task = models.OneToOneField(
Task,
on_delete=models.CASCADE,
related_name='work_hours_table',
verbose_name=_("Radni zadatak"),
)
data = models.JSONField(
null=True,
blank=True,
default=None,
verbose_name=_("Podaci tablice radnih sati"),
)
class Meta:
ordering = ['-updated_at']
verbose_name = _("Tablica radnih sati")
verbose_name_plural = _("Tablice radnih sati")
def __str__(self):
return f"Work hours table for task {self.task_id}"
class TaskTemplate(BaseModel):
"""Predložak za brzo kreiranje servisnog radnog zadatka."""
code = models.SlugField(max_length=64, unique=True)
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
status = models.CharField(max_length=20, choices=Task.STATUS_CHOICES, default='aktivan')
class Meta:
ordering = ['title']
def __str__(self):
return f"{self.title} ({self.code})"
class TaskTemplateEntry(BaseModel):
"""Predlošci radnih unosa koji se generiraju prilikom kreiranja Taska."""
task_template = models.ForeignKey(
TaskTemplate,
on_delete=models.CASCADE,
related_name='entries',
)
sort_order = models.PositiveSmallIntegerField(default=1)
title = models.CharField(max_length=255)
work_description = models.TextField(blank=True)
active_faults = models.TextField(blank=True, help_text=_("Aktivne greške."))
explanation_hr = models.TextField(blank=True, help_text=_("Obrazloženje na hrvatskom jeziku."))
class Meta:
ordering = ['sort_order', 'created_at']
constraints = [
models.UniqueConstraint(
fields=['task_template', 'sort_order'],
name='task_template_entry_sort_order_unique',
)
]
def __str__(self):
return f"{self.task_template.code} #{self.sort_order} - {self.title}"
class TaskWorkEntry(BaseModel):
"""Radni unos na konkretnom Tasku (generiran iz templatea ili ručno)."""
task = models.ForeignKey(
Task,
on_delete=models.CASCADE,
related_name='work_entries',
)
template_entry = models.ForeignKey(
TaskTemplateEntry,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='task_work_entries',
)
sort_order = models.PositiveSmallIntegerField(default=1)
title = models.CharField(max_length=255)
work_description = models.TextField(blank=True)
active_faults = models.TextField(blank=True)
explanation_hr = models.TextField(blank=True)
class Meta:
ordering = ['sort_order', 'created_at']
def __str__(self):
return f"{self.task_id} #{self.sort_order} - {self.title}"

View File

@@ -1,50 +1,161 @@
# backend/modules/task_management/serializers.py
# backend/modules/task_management/serializers.py
from rest_framework import serializers
from .models import Task
from .models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry, TaskWorkHoursTable
WORK_HOURS_ROW_FIELDS = [
'day',
'date',
'work_time_from',
'work_time_to',
'travel_time_from',
'travel_time_to',
'break_hours',
'work_hours',
'travel_hours',
'departure_place',
'arrival_place',
'vehicle_km',
]
class TaskWorkHoursTableSerializer(serializers.ModelSerializer):
class Meta:
model = TaskWorkHoursTable
fields = ['id', 'task', 'data', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at']
def _normalize_row(self, row):
normalized = {}
for field in WORK_HOURS_ROW_FIELDS:
value = row.get(field, '')
normalized[field] = '' if value is None else str(value).strip()
return normalized
def validate_data(self, value):
if value is None:
return None
if not isinstance(value, dict):
raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt ili null.")
rows = value.get('rows', [])
if not isinstance(rows, list):
raise serializers.ValidationError({"rows": "Polje 'rows' mora biti lista redaka."})
normalized_rows = []
for row in rows[:30]:
if not isinstance(row, dict):
raise serializers.ValidationError({"rows": "Svaki redak mora biti JSON objekt."})
normalized_rows.append(self._normalize_row(row))
return {'rows': normalized_rows}
class TaskWorkEntrySerializer(serializers.ModelSerializer):
class Meta:
model = TaskWorkEntry
fields = [
'id', 'task', 'template_entry', 'sort_order', 'title',
'work_description', 'active_faults', 'explanation_hr',
'created_at',
]
read_only_fields = ['id', 'created_at']
class TaskTemplateEntrySerializer(serializers.ModelSerializer):
class Meta:
model = TaskTemplateEntry
fields = [
'id', 'sort_order', 'title', 'work_description',
'active_faults', 'explanation_hr',
]
read_only_fields = ['id']
class TaskTemplateSerializer(serializers.ModelSerializer):
entries = TaskTemplateEntrySerializer(many=True, read_only=True)
class Meta:
model = TaskTemplate
fields = ['id', 'code', 'title', 'description', 'status', 'entries']
read_only_fields = ['id']
class TaskSerializer(serializers.ModelSerializer):
assigned_to_name = serializers.SerializerMethodField()
work_order_label = serializers.SerializerMethodField()
vehicle_registration = serializers.SerializerMethodField()
vehicle_make = serializers.SerializerMethodField()
vehicle_model = serializers.SerializerMethodField()
vehicle_owner_name = serializers.SerializerMethodField()
vehicle_asset_type = serializers.SerializerMethodField()
crane_serial_number = serializers.SerializerMethodField()
template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
auto_close_work_order = serializers.BooleanField(write_only=True, required=False)
class Meta:
model = Task
fields = [
'id', 'title', 'description', 'status',
'assigned_to', 'assigned_to_name',
'vehicle', 'vehicle_registration',
'vehicle', 'vehicle_registration', 'vehicle_make', 'vehicle_model',
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number',
'work_order', 'work_order_label',
'scheduled_date',
'service_report_note',
'template_id', 'auto_close_work_order',
'created_at',
]
read_only_fields = ['id', 'created_at']
extra_kwargs = {
'title': {'required': False, 'allow_blank': True},
'description': {'required': False, 'allow_blank': True},
'status': {'required': False},
'work_order': {'required': False, 'allow_null': True},
'vehicle': {'required': False, 'allow_null': True},
'scheduled_date': {'required': False, 'allow_null': True},
'service_report_note': {'required': False, 'allow_blank': True},
}
def validate_title(self, value):
"""Primjer custom validacije: naziv ne smije biti prekratak."""
if not value:
return value
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)
template_id = attrs.get('template_id')
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))
title = (attrs.get('title', getattr(current, 'title', '')) or '').strip()
if template_id:
template = TaskTemplate.objects.filter(id=template_id, is_active=True).first()
if not template:
raise serializers.ValidationError({'template_id': 'Odabrani template ne postoji ili nije aktivan.'})
if current is None and not vehicle:
raise serializers.ValidationError({
'vehicle': "Dizalica je obavezna pri kreiranju taska."
})
if current is None and not template_id and not title:
raise serializers.ValidationError({
'title': "Naslov zadatka je obavezan ako se task ne kreira iz templatea."
})
# If task has no crane yet, auto-derive it from the work order's crane (backward compat).
# Cross-crane assignment is intentionally allowed: a servicer can share one work order
# across tasks for multiple cranes on the same day.
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:
if status_value in {'neaktivan', 'zavrsen'} and not work_order:
raise serializers.ValidationError({
'work_order': "Putni nalog je obavezan prije zatvaranja zadatka."
})
@@ -53,7 +164,10 @@ class TaskSerializer(serializers.ModelSerializer):
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()}"
display_code = str(getattr(obj.work_order, 'display_code', '') or '').strip().upper()
if display_code:
return display_code
return None
def get_assigned_to_name(self, obj):
if not obj.assigned_to_id:
@@ -72,4 +186,29 @@ class TaskSerializer(serializers.ModelSerializer):
def get_vehicle_registration(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.registration_number
return obj.vehicle.registration_number
def get_vehicle_make(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.make or None
def get_vehicle_model(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.model or None
def get_vehicle_owner_name(self, obj):
if not obj.vehicle_id or not obj.vehicle.client_id:
return None
return obj.vehicle.client.name
def get_vehicle_asset_type(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.asset_type
def get_crane_serial_number(self, obj):
if not obj.vehicle_id:
return None
return obj.vehicle.crane_serial_number or None

View File

@@ -1,11 +1,32 @@
# backend/modules/task_management/services.py
# backend/modules/task_management/services.py
from .models import Task
from django.db import transaction, models
from rest_framework.exceptions import ValidationError
from django.core.exceptions import ValidationError as DjangoValidationError
from django.contrib.auth import get_user_model
import logging
from .models import Task, TaskTemplate, TaskWorkEntry
from .exceptions import OpenWorkOrderBlockingTaskClose
from modules.fleet.models import VehicleServiceRecord
logger = logging.getLogger(__name__)
User = get_user_model()
def _task_overload_recipients(task):
recipients = []
if task.assigned_to_id:
recipients.append(task.assigned_to)
supervisors = User.objects.filter(is_active=True).filter(
models.Q(is_staff=True) | (models.Q(is_team_member=True) & models.Q(is_serviser=False))
)
recipients.extend(supervisors)
unique = {}
for recipient in recipients:
unique[str(recipient.id)] = recipient
return list(unique.values())
class TaskService:
@staticmethod
@@ -13,11 +34,29 @@ class TaskService:
"""
Univerzalna metoda: prihvaća podatke i opcionalnog korisnika.
"""
# Poslovna pravila
if data.get('status') == 'DONE' and (user and not user.is_staff):
payload = dict(data)
template_id = payload.pop('template_id', None)
if payload.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)
template = None
if template_id:
template = TaskTemplate.objects.filter(id=template_id, is_active=True).prefetch_related('entries').first()
if not template:
raise ValidationError({"template_id": "Odabrani template nije pronađen."})
payload['title'] = template.title
payload['description'] = template.description
payload['status'] = 'aktivan'
payload.setdefault('work_order', None)
title = (payload.get('title') or '').strip()
if title and len(title) < 3:
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
if not title:
raise ValidationError({"title": "Naslov zadatka je obavezan."})
if user and 'assigned_to' not in payload:
payload['assigned_to'] = user
@@ -27,22 +66,144 @@ class TaskService:
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()
with transaction.atomic():
entry.save()
TaskService._notify_if_servicer_overloaded(entry)
if template:
TaskService._create_work_entries_from_template(task=entry, template=template)
TaskService._create_service_records_from_template(task=entry, template=template, user=user)
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)
def _notify_if_servicer_overloaded(entry: Task):
if not entry.assigned_to_id or not entry.scheduled_date:
return
if not getattr(entry.assigned_to, 'is_serviser', False):
return
task_count = Task.objects.filter(
is_active=True,
assigned_to=entry.assigned_to,
scheduled_date=entry.scheduled_date,
status__in=('aktivan', 'servis', 'spreman_za_zavrsetak'),
).count()
if task_count <= 2:
return
from modules.fleet.services import NotificationService
schedule_label = entry.scheduled_date.strftime('%d.%m.%Y')
title = f"Preopterećenje rasporeda: {entry.assigned_to.get_full_name() or entry.assigned_to.email}"
message = (
f"Serviseru {entry.assigned_to.get_full_name() or entry.assigned_to.email} "
f"je dodijeljeno {task_count} zadatka za {schedule_label}. "
f"Sustav je dopustio i dodatni zadatak."
)
metadata = {
"entity_type": "task_overload",
"assigned_to_id": str(entry.assigned_to_id),
"task_id": str(entry.id),
"scheduled_date": entry.scheduled_date.isoformat(),
"task_count": task_count,
"section": "dashboard",
}
for recipient in _task_overload_recipients(entry):
NotificationService.create_notification(
recipient=recipient,
title=title,
message=message,
level='warning',
send_email=False,
metadata=metadata,
)
@staticmethod
def _create_work_entries_from_template(*, task: Task, template: TaskTemplate):
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
for idx, source in enumerate(template_entries, start=1):
TaskWorkEntry.objects.create(
task=task,
template_entry=source,
sort_order=idx,
title=source.title,
work_description=source.work_description,
active_faults=source.active_faults,
explanation_hr=source.explanation_hr,
)
@staticmethod
def _create_service_records_from_template(*, task: Task, template: TaskTemplate, user):
if not user:
raise ValidationError({
"template_id": "Kreiranje taska iz templatea zahtijeva prijavljenog korisnika za servisne zapise."
})
if not task.vehicle_id:
raise ValidationError({
"vehicle": "Task iz templatea mora imati dodijeljenu dizalicu za generiranje servisnih zapisa."
})
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
for source in template_entries:
description_parts = []
if source.work_description:
description_parts.append(source.work_description.strip())
if source.explanation_hr:
description_parts.append(f"Obrazloženje (HR): {source.explanation_hr.strip()}")
if source.active_faults:
description_parts.append(f"Aktivne greške: {source.active_faults.strip()}")
description = "\n\n".join([part for part in description_parts if part]).strip() or source.title
VehicleServiceRecord.objects.create(
vehicle=task.vehicle,
task=task,
performed_by=user,
service_title=source.title,
description=description,
parts=(source.active_faults or '').strip(),
cost=0,
)
@staticmethod
def update_task_entry(instance, data, user, auto_close_work_order=False):
if not user.is_staff and instance.assigned_to != user:
raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.")
# Spremanje
target_status = data.get('status', instance.status)
target_work_order = data.get('work_order', instance.work_order)
closing_statuses = {'neaktivan', 'zavrsen'}
if target_status in closing_statuses:
if not target_work_order:
raise ValidationError({"work_order": "Putni nalog je obavezan prije zatvaranja zadatka."})
if target_work_order.status == 'open':
if not auto_close_work_order:
work_order_label = f"WO-{str(target_work_order.id).split('-')[0].upper()}"
raise OpenWorkOrderBlockingTaskClose(detail={
"code": "OPEN_WORK_ORDER_BLOCKING_TASK_CLOSE",
"message": f"Putni nalog {work_order_label} je još otvoren. Želite li ga automatski zatvoriti?",
"work_order_id": str(target_work_order.id),
"work_order_label": work_order_label,
})
if target_work_order.end_mileage is None:
raise ValidationError({
"work_order": "Automatsko zatvaranje putnog naloga nije moguće bez završne kilometraže."
})
from modules.fleet.services import VehicleService
target_work_order = VehicleService.update_work_order(target_work_order, {"status": "closed"})
data['work_order'] = target_work_order
for attr, value in data.items():
if attr == 'template_id':
continue
setattr(instance, attr, value)
if 'title' in data and len(str(data.get('title') or '').strip()) < 3:
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
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
return instance

View File

@@ -1,10 +1,64 @@
# backend/modules/task_management/tasks.py
import logging
from celery import shared_task
from .models import Task
from django.contrib.auth import get_user_model
from modules.fleet.models import Vehicle, WorkOrder
logger = logging.getLogger(__name__)
@shared_task
def notify_upcoming_tasks():
"""
Dnevna Celery periodik task koji šalje Pusher + email notifikacije
za radne zadatke čiji je planirani datum izvršenja sutra.
"""
from datetime import date, timedelta
from modules.fleet.services import NotificationService
tomorrow = date.today() + timedelta(days=1)
upcoming = Task.objects.filter(
is_active=True,
scheduled_date=tomorrow,
status__in=('aktivan', 'servis'),
).select_related('assigned_to', 'vehicle')
notified = 0
for task in upcoming:
recipient = task.assigned_to
if not recipient:
continue
vehicle_label = (
task.vehicle.registration_number if task.vehicle else 'nepoznato'
)
try:
NotificationService.create_notification(
recipient=recipient,
title=f"Sutra planirani zadatak: {task.title}",
message=(
f"Radni zadatak '{task.title}' na dizalici {vehicle_label} "
f"je planiran za sutra ({tomorrow.strftime('%d.%m.%Y')})."
),
level="info",
send_email=True,
metadata={
"entity_type": "task",
"task_id": str(task.id),
"vehicle_id": str(task.vehicle_id) if task.vehicle_id else None,
"section": "dashboard",
"scheduled_date": tomorrow.isoformat(),
},
)
notified += 1
except Exception:
logger.exception("Greška pri slanju notifikacije za task %s", task.id)
logger.info("notify_upcoming_tasks: obaviješteno %d servisera za datum %s", notified, tomorrow)
return {"notified": notified, "date": tomorrow.isoformat()}
@shared_task
def create_task_from_invoice(invoice_data):
"""
@@ -25,7 +79,7 @@ def create_task_from_invoice(invoice_data):
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']}",
@@ -34,4 +88,4 @@ def create_task_from_invoice(invoice_data):
status='aktivan',
vehicle=vehicle,
work_order=work_order,
)
)

View File

@@ -1,5 +1,6 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
import datetime
from modules.task_management.models import Task
@@ -9,4 +10,25 @@ class TaskModelTests(TestCase):
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
assert "aktivan" in str(task) or "(" in str(task)
def test_scheduled_date_defaults_to_null(self):
user = get_user_model().objects.create_user(username="u2", email="u2@example.test", password="pass")
task = Task.objects.create(title="Task bez datuma", assigned_to=user)
assert task.scheduled_date is None
def test_scheduled_date_is_saved_and_retrieved(self):
user = get_user_model().objects.create_user(username="u3", email="u3@example.test", password="pass")
target = datetime.date(2026, 8, 15)
task = Task.objects.create(title="Planirani task", assigned_to=user, scheduled_date=target)
task.refresh_from_db()
assert task.scheduled_date == target
def test_scheduled_date_can_be_updated_to_null(self):
user = get_user_model().objects.create_user(username="u4", email="u4@example.test", password="pass")
target = datetime.date(2026, 8, 15)
task = Task.objects.create(title="Task s datumom", assigned_to=user, scheduled_date=target)
task.scheduled_date = None
task.save()
task.refresh_from_db()
assert task.scheduled_date is None

View File

@@ -4,29 +4,153 @@ 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
from modules.crm.models import Client
class TaskSerializerTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="task-user-base", email="task-user-base@example.test", password="pass")
self.client = Client.objects.create(name="Klijent zadatka")
self.vehicle = Vehicle.objects.create(
registration_number="ZG-TSK-01",
asset_type='crane',
make='Liebherr',
model='LTM',
crane_serial_number='123456',
client=self.client,
is_company_vehicle=False,
)
def test_validate_title_too_short_raises(self):
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan"})
serializer = TaskSerializer(data={"title": "ab", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
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"})
serializer = TaskSerializer(data={"title": "Valid title", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)})
assert serializer.is_valid(), serializer.errors
def test_cannot_close_without_work_order(self):
serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan"})
serializer = TaskSerializer(data={"title": "Task 1", "description": "", "status": "neaktivan", "vehicle": str(self.vehicle.id)})
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)
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
task = Task.objects.create(title="Task 2", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task, data={"status": "neaktivan", "work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors
assert serializer.is_valid(), serializer.errors
def test_cannot_mark_finished_without_work_order(self):
serializer = TaskSerializer(data={"title": "Task 3", "description": "", "status": "zavrsen", "vehicle": str(self.vehicle.id)})
with self.assertRaises(ValidationError):
serializer.is_valid(raise_exception=True)
def test_can_mark_finished_with_work_order(self):
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='kontrola')
task = Task.objects.create(title="Task 4", description="", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task, data={"status": "zavrsen", "work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors
def test_scheduled_date_is_optional_on_create(self):
serializer = TaskSerializer(data={"title": "Task planned", "status": "aktivan", "vehicle": str(self.vehicle.id)})
assert serializer.is_valid(), serializer.errors
assert 'scheduled_date' not in serializer.errors
def test_scheduled_date_is_included_in_serialized_output(self):
import datetime
task = Task.objects.create(
title="Task with date",
assigned_to=self.user,
vehicle=self.vehicle,
scheduled_date=datetime.date(2026, 9, 1),
)
serializer = TaskSerializer(instance=task)
assert 'scheduled_date' in serializer.data
assert str(serializer.data['scheduled_date']) == '2026-09-01'
def test_scheduled_date_can_be_null_in_serialized_output(self):
task = Task.objects.create(title="Task no date", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task)
assert serializer.data['scheduled_date'] is None
def test_vehicle_details_are_included_in_serialized_output(self):
task = Task.objects.create(title="Task crane details", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task)
assert serializer.data['vehicle_make'] == 'Liebherr'
assert serializer.data['vehicle_model'] == 'LTM'
assert serializer.data['crane_serial_number'] == '123456'
assert serializer.data['vehicle_owner_name'] == 'Klijent zadatka'
assert serializer.data['vehicle_asset_type'] == 'crane'
def test_scheduled_date_accepts_valid_date(self):
serializer = TaskSerializer(
data={"title": "Task mit datum", "status": "aktivan",
"vehicle": str(self.vehicle.id), "scheduled_date": "2026-12-25"}
)
assert serializer.is_valid(), serializer.errors
def test_scheduled_date_rejects_invalid_date_string(self):
serializer = TaskSerializer(
data={"title": "Task bad date", "status": "aktivan",
"vehicle": str(self.vehicle.id), "scheduled_date": "not-a-date"}
)
assert not serializer.is_valid()
assert 'scheduled_date' in serializer.errors
def test_can_assign_work_order_from_different_crane(self):
"""Task može dobiti putni nalog koji pripada drugoj dizalici (cross-crane dijeljenje naloga)."""
other_vehicle = Vehicle.objects.create(
registration_number="ZG-TSK-02",
asset_type='crane',
make='Manitowoc',
model='Grove',
client=self.client,
is_company_vehicle=False,
)
work_order = WorkOrder.objects.create(vehicle=other_vehicle, creator=self.user, purpose='kontrola')
task = Task.objects.create(title="Cross-crane task", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(instance=task, data={"work_order": str(work_order.id)}, partial=True)
assert serializer.is_valid(), serializer.errors
updated_task = serializer.save()
assert updated_task.work_order_id == work_order.id
assert updated_task.vehicle_id == self.vehicle.id # task zadržava svoju dizalicu
def test_can_close_task_with_cross_crane_work_order(self):
"""Task se može zatvoriti putnim nalogom koji pripada drugoj dizalici."""
other_vehicle = Vehicle.objects.create(
registration_number="ZG-TSK-03",
asset_type='crane',
make='Tadano',
model='GR',
client=self.client,
is_company_vehicle=False,
)
work_order = WorkOrder.objects.create(vehicle=other_vehicle, creator=self.user, purpose='popravak')
task = Task.objects.create(title="Task for close", status="servis", assigned_to=self.user, vehicle=self.vehicle)
serializer = TaskSerializer(
instance=task,
data={"status": "zavrsen", "work_order": str(work_order.id)},
partial=True
)
assert serializer.is_valid(), serializer.errors
updated_task = serializer.save()
assert updated_task.status == 'zavrsen'
assert updated_task.work_order_id == work_order.id
def test_work_order_auto_derives_vehicle_when_task_has_none(self):
"""Ako task nema dizalicu a dobiva WO, dizalica se preuzima iz WO (backward compat)."""
work_order = WorkOrder.objects.create(vehicle=self.vehicle, creator=self.user, purpose='defektaza')
serializer = TaskSerializer(
data={"title": "Auto-vehicle task", "status": "aktivan", "work_order": str(work_order.id)},
)
# vehicle je obavezan pri kreiranju, ali ako task nije instanca ovo bi trebalo failati
# Provjeri da vehicle derivation iz WO radi samo ako task već postoji (partial update)
task = Task.objects.create(title="Temp task", status="aktivan", assigned_to=self.user, vehicle=self.vehicle)
serializer2 = TaskSerializer(instance=task, data={"work_order": str(work_order.id)}, partial=True)
assert serializer2.is_valid(), serializer2.errors

View File

@@ -1,9 +1,12 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from unittest.mock import patch
from datetime import date
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
from modules.task_management.exceptions import OpenWorkOrderBlockingTaskClose
from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry, TaskWorkEntry
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
class TaskServiceTests(TestCase):
@@ -42,7 +45,14 @@ class TaskServiceTests(TestCase):
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')
work_order = WorkOrder.objects.create(
vehicle=vehicle,
creator=self.user,
purpose='defektaza',
status='closed',
start_mileage=100,
end_mileage=120,
)
task = Task.objects.create(title="Za zatvaranje", description="", assigned_to=self.user, status='servis')
updated = TaskService.update_task_entry(
instance=task,
@@ -50,4 +60,210 @@ class TaskServiceTests(TestCase):
user=self.user,
)
assert updated.status == "neaktivan"
assert updated.work_order == work_order
assert updated.work_order == work_order
def test_update_task_entry_rejects_finished_without_work_order(self):
task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis')
with self.assertRaises(ValidationError):
TaskService.update_task_entry(instance=task, data={"status": "zavrsen"}, user=self.user)
def test_update_task_entry_allows_finished_with_work_order(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01A", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle,
creator=self.user,
purpose='kontrola',
status='closed',
start_mileage=100,
end_mileage=150,
)
task = Task.objects.create(title="Za završetak", description="", assigned_to=self.user, status='servis', vehicle=vehicle)
updated = TaskService.update_task_entry(
instance=task,
data={"status": "zavrsen", "work_order": work_order},
user=self.user,
)
assert updated.status == "zavrsen"
assert updated.work_order == work_order
def test_update_task_entry_blocks_closing_when_work_order_open(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01B", asset_type='crane')
work_order = WorkOrder.objects.create(vehicle=vehicle, creator=self.user, purpose='kontrola', status='open')
task = Task.objects.create(
title="Integritet",
description="",
assigned_to=self.user,
status='servis',
vehicle=vehicle,
work_order=work_order,
)
with self.assertRaises(OpenWorkOrderBlockingTaskClose):
TaskService.update_task_entry(
instance=task,
data={"status": "zavrsen"},
user=self.user,
)
def test_update_task_entry_can_auto_close_open_work_order(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-01C", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle,
creator=self.user,
purpose='kontrola',
status='open',
start_mileage=100,
end_mileage=150,
)
task = Task.objects.create(
title="Auto close",
description="",
assigned_to=self.user,
status='servis',
vehicle=vehicle,
work_order=work_order,
)
updated = TaskService.update_task_entry(
instance=task,
data={"status": "zavrsen"},
user=self.user,
auto_close_work_order=True,
)
work_order.refresh_from_db()
self.assertEqual(updated.status, "zavrsen")
self.assertEqual(work_order.status, "closed")
def test_create_task_entry_from_template_creates_service_records(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-02", asset_type='crane')
template = TaskTemplate.objects.create(
code='template-servis-zapisi',
title='Template servis',
description='Opis templatea',
status='aktivan',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=1,
title='Pregled sustava',
work_description='Provjera hidraulike',
active_faults='Greška E101',
explanation_hr='Prevedeno i objašnjeno.',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=2,
title='Zamjena senzora',
work_description='Zamijenjen senzor kuta',
active_faults='',
explanation_hr='Senzor kalibriran.',
)
created = TaskService.create_task_entry(
data={'template_id': template.id, 'vehicle': vehicle},
user=self.user,
)
self.assertEqual(created.vehicle_id, vehicle.id)
self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 2)
records = VehicleServiceRecord.objects.filter(task=created).order_by('created_at')
self.assertEqual(records.count(), 2)
self.assertEqual(records.first().service_title, 'Pregled sustava')
self.assertIn('Obrazloženje (HR): Prevedeno i objašnjeno.', records.first().description)
self.assertIn('Aktivne greške: Greška E101', records.first().description)
self.assertEqual(records.first().performed_by_id, self.user.id)
def test_create_task_entry_from_template_limits_service_records_to_ten(self):
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-03", asset_type='crane')
template = TaskTemplate.objects.create(
code='template-limit-10',
title='Template limit',
description='Opis',
status='aktivan',
)
for index in range(1, 13):
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=index,
title=f'Korak {index}',
work_description=f'Opis {index}',
)
created = TaskService.create_task_entry(
data={'template_id': template.id, 'vehicle': vehicle},
user=self.user,
)
self.assertEqual(TaskWorkEntry.objects.filter(task=created).count(), 10)
self.assertEqual(VehicleServiceRecord.objects.filter(task=created).count(), 10)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_create_task_entry_sends_overload_notifications_after_two_tasks(self, mock_notify):
servicer = get_user_model().objects.create_user(
username='servicer-overload',
email='servicer-overload@example.test',
password='test1234',
is_serviser=True,
is_team_member=True,
)
supervisor = get_user_model().objects.create_user(
username='supervisor-overload',
email='supervisor-overload@example.test',
password='test1234',
is_team_member=True,
is_serviser=False,
)
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-OVER1", asset_type='crane')
scheduled = date(2026, 7, 21)
Task.objects.create(title="Postojeći 1", assigned_to=servicer, vehicle=vehicle, status='aktivan', scheduled_date=scheduled)
Task.objects.create(title="Postojeći 2", assigned_to=servicer, vehicle=vehicle, status='servis', scheduled_date=scheduled)
TaskService.create_task_entry(
data={
"title": "Treći task",
"status": "aktivan",
"assigned_to": servicer,
"vehicle": vehicle,
"scheduled_date": scheduled,
},
user=self.staff,
)
recipients = [call.kwargs.get('recipient') for call in mock_notify.call_args_list]
recipient_ids = {str(recipient.id) for recipient in recipients if recipient}
self.assertIn(str(servicer.id), recipient_ids)
self.assertIn(str(supervisor.id), recipient_ids)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_create_task_entry_does_not_send_overload_notifications_when_two_or_less(self, mock_notify):
servicer = get_user_model().objects.create_user(
username='servicer-normal-load',
email='servicer-normal-load@example.test',
password='test1234',
is_serviser=True,
is_team_member=True,
)
vehicle = Vehicle.objects.create(registration_number="ZG-TASK-OVER2", asset_type='crane')
scheduled = date(2026, 7, 21)
TaskService.create_task_entry(
data={
"title": "Prvi task",
"status": "aktivan",
"assigned_to": servicer,
"vehicle": vehicle,
"scheduled_date": scheduled,
},
user=self.staff,
)
TaskService.create_task_entry(
data={
"title": "Drugi task",
"status": "aktivan",
"assigned_to": servicer,
"vehicle": vehicle,
"scheduled_date": scheduled,
},
user=self.staff,
)
self.assertEqual(mock_notify.call_count, 0)

View File

@@ -0,0 +1,133 @@
import datetime
from unittest.mock import patch
from django.test import TestCase
from django.contrib.auth import get_user_model
from modules.task_management.tasks import notify_upcoming_tasks
from modules.task_management.models import Task
from modules.fleet.models import Vehicle, WorkOrder
class NotifyUpcomingTasksTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="serviser-notif",
email="serviser@example.test",
password="pass",
)
self.vehicle = Vehicle.objects.create(
registration_number="ZG-NOTIF-01",
asset_type='crane',
)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_notifies_for_task_scheduled_tomorrow(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(
title="Sutrašnji zadatak",
assigned_to=self.user,
vehicle=self.vehicle,
status='aktivan',
scheduled_date=tomorrow,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 1)
mock_notify.assert_called_once()
call_kwargs = mock_notify.call_args.kwargs
self.assertEqual(call_kwargs['recipient'], self.user)
self.assertIn('Sutrašnji zadatak', call_kwargs['title'])
self.assertEqual(call_kwargs['level'], 'info')
self.assertTrue(call_kwargs.get('send_email'))
@patch('modules.fleet.services.NotificationService.create_notification')
def test_notifies_multiple_upcoming_tasks(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(title="Task A", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=tomorrow)
Task.objects.create(title="Task B", assigned_to=self.user, vehicle=self.vehicle,
status='servis', scheduled_date=tomorrow)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 2)
self.assertEqual(mock_notify.call_count, 2)
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_without_scheduled_date(self, mock_notify):
Task.objects.create(
title="Bez datuma", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=None,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_scheduled_today(self, mock_notify):
today = datetime.date.today()
Task.objects.create(
title="Danas", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=today,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_scheduled_in_past(self, mock_notify):
yesterday = datetime.date.today() - datetime.timedelta(days=1)
Task.objects.create(
title="Jučer", assigned_to=self.user, vehicle=self.vehicle,
status='aktivan', scheduled_date=yesterday,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_finished_task(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
vehicle2 = Vehicle.objects.create(registration_number="ZG-NOTIF-02", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle2, creator=self.user, purpose='defektaza',
status='closed', start_mileage=100, end_mileage=200,
)
Task.objects.create(
title="Završeni", assigned_to=self.user, vehicle=vehicle2,
status='zavrsen', scheduled_date=tomorrow, work_order=work_order,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_cancelled_task(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
vehicle3 = Vehicle.objects.create(registration_number="ZG-NOTIF-03", asset_type='crane')
work_order = WorkOrder.objects.create(
vehicle=vehicle3, creator=self.user, purpose='kontrola',
status='closed', start_mileage=50, end_mileage=100,
)
Task.objects.create(
title="Otkazani", assigned_to=self.user, vehicle=vehicle3,
status='neaktivan', scheduled_date=tomorrow, work_order=work_order,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_skips_task_without_assigned_user(self, mock_notify):
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
Task.objects.create(
title="Bez servisera", assigned_to=None, vehicle=self.vehicle,
status='aktivan', scheduled_date=tomorrow,
)
result = notify_upcoming_tasks()
self.assertEqual(result['notified'], 0)
mock_notify.assert_not_called()
@patch('modules.fleet.services.NotificationService.create_notification')
def test_result_includes_date_string(self, mock_notify):
result = notify_upcoming_tasks()
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
self.assertEqual(result['date'], tomorrow.isoformat())
self.assertEqual(result['notified'], 0)

View File

@@ -1,10 +1,12 @@
from django.test import TestCase, RequestFactory
from django.contrib.auth import get_user_model
from unittest.mock import patch
from rest_framework.test import APIClient
from modules.task_management.views import TaskViewSet
from modules.task_management.serializers import TaskSerializer
from modules.task_management.models import Task
from modules.task_management.models import Task, TaskTemplate, TaskTemplateEntry
from modules.fleet.models import Vehicle
class TaskViewSetTests(TestCase):
@@ -15,10 +17,13 @@ class TaskViewSetTests(TestCase):
self.view = TaskViewSet()
self.view.request = self.factory.post("/")
self.view.request.user = self.user
self.vehicle = Vehicle.objects.create(registration_number="ZG-TV-01", asset_type='crane')
self.api_client = APIClient()
self.api_client.force_authenticate(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"}
data = {"title": "Task from view", "description": "", "status": "aktivan", "vehicle": str(self.vehicle.id)}
serializer = TaskSerializer(data=data, context={"request": self.view.request})
assert serializer.is_valid(), serializer.errors
@@ -42,7 +47,12 @@ class TaskViewSetTests(TestCase):
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)
mock_update.assert_called_once_with(
instance=task,
data=serializer.validated_data,
user=self.view.request.user,
auto_close_work_order=False,
)
# confirm instance saved with new title
task.refresh_from_db()
assert task.title == "Updated"
@@ -51,4 +61,32 @@ class TaskViewSetTests(TestCase):
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
assert task.is_active is False
def test_template_created_task_is_visible_in_service_records_endpoint(self):
template = TaskTemplate.objects.create(
code='template-api-service-records',
title='Template API',
description='Opis',
status='aktivan',
)
TaskTemplateEntry.objects.create(
task_template=template,
sort_order=1,
title='Korak API',
work_description='Opis API koraka',
)
response = self.api_client.post(
"/api/tasks/tasks/",
data={"template_id": str(template.id), "vehicle": str(self.vehicle.id)},
format="json",
)
self.assertEqual(response.status_code, 201, response.content)
created_task_id = str(response.json()["id"])
service_records_response = self.api_client.get("/api/fleet/service-records/")
self.assertEqual(service_records_response.status_code, 200, service_records_response.content)
payload = service_records_response.json()
rows = payload if isinstance(payload, list) else payload.get("results", [])
self.assertTrue(any(str(row.get("task") or "") == created_task_id for row in rows))

View File

@@ -0,0 +1,97 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
from modules.task_management.models import Task, TaskWorkHoursTable
class TaskWorkHoursTableTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="wh-user", email="wh-user@example.test", password="Pass123!abc")
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.vehicle = Vehicle.objects.create(registration_number="ZG-WH-01", asset_type='crane')
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
purpose='kontrola',
location='Zagreb',
)
self.task = Task.objects.create(
title="Task tablica sati",
description="",
status="aktivan",
assigned_to=self.user,
vehicle=self.vehicle,
work_order=self.work_order,
)
def test_put_work_hours_table_accepts_null_data(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data=None,
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertIsNone(response.json().get('data'))
def test_put_work_hours_table_accepts_partial_row_and_normalizes(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data={"rows": [{"day": "NED", "travel_hours": "2,5", "departure_place": "Zagreb"}]},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
rows = payload.get('data', {}).get('rows', [])
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].get('day'), 'NED')
self.assertEqual(rows[0].get('travel_hours'), '2,5')
self.assertEqual(rows[0].get('departure_place'), 'Zagreb')
self.assertIn('arrival_place', rows[0])
self.assertEqual(rows[0].get('arrival_place'), '')
def test_put_work_hours_table_rejects_invalid_json_shape(self):
response = self.client.put(
f"/api/tasks/tasks/{self.task.id}/work-hours-table/",
data="invalid-shape",
format="json",
)
self.assertEqual(response.status_code, 400, response.content)
def test_task_service_context_includes_work_hours_table_data(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={"rows": [{"day": "NED", "date": "12.07.2026"}]},
)
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=self.task,
performed_by=self.user,
service_title="Servis zapis",
description="Opis",
cost=0,
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.id}/task-service-context/")
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
tasks = payload.get('tasks', [])
self.assertEqual(len(tasks), 1)
self.assertEqual(tasks[0].get('work_hours_table'), {"rows": [{"day": "NED", "date": "12.07.2026"}]})
def test_service_records_pdf_works_with_empty_work_hours_table(self):
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=self.task,
performed_by=self.user,
service_title="Servis zapis",
description="Opis",
cost=0,
)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.id}/service-records-pdf/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response["Content-Type"], "application/pdf")

View File

@@ -1,15 +1,18 @@
# backend/modules/task_management/views.py
# backend/modules/task_management/views.py
import logging
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.exceptions import ValidationError
from .models import Task
from .serializers import TaskSerializer
from .models import Task, TaskTemplate
from .serializers import TaskSerializer, TaskTemplateSerializer, TaskWorkHoursTableSerializer
from .services import TaskService
# Postavljanje loggera za modul
logger = logging.getLogger(__name__)
class TaskViewSet(viewsets.ModelViewSet):
"""
ViewSet koji koristi TaskService za rukovanje operacijama.
@@ -19,46 +22,76 @@ class TaskViewSet(viewsets.ModelViewSet):
def get_queryset(self):
qs = Task.objects.filter(is_active=True).select_related(
'assigned_to', 'vehicle', 'work_order', 'work_order__vehicle'
'assigned_to', 'vehicle', 'vehicle__client', 'work_order', 'work_order__vehicle', 'work_hours_table'
)
vehicle_id = self.request.query_params.get('vehicle')
if vehicle_id:
qs = qs.filter(vehicle_id=vehicle_id)
scheduled_date = self.request.query_params.get('scheduled_date')
if scheduled_date:
qs = qs.filter(scheduled_date=scheduled_date)
work_order_id = self.request.query_params.get('work_order')
if work_order_id:
qs = qs.filter(work_order_id=work_order_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)
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
auto_close_work_order = serializer.validated_data.pop('auto_close_work_order', False)
TaskService.update_task_entry(
instance=serializer.instance,
data=serializer.validated_data,
user=self.request.user
user=self.request.user,
auto_close_work_order=auto_close_work_order,
)
# 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}")
@action(detail=False, methods=['get'], url_path='templates')
def templates(self, request):
templates = TaskTemplate.objects.filter(is_active=True).prefetch_related('entries').order_by('title')
serializer = TaskTemplateSerializer(templates, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get', 'put', 'patch'], url_path='work-hours-table')
def work_hours_table(self, request, pk=None):
task = self.get_object()
table = getattr(task, 'work_hours_table', None)
if request.method.lower() == 'get':
if table:
serializer = TaskWorkHoursTableSerializer(table)
return Response(serializer.data)
return Response({'task': str(task.id), 'data': None})
if isinstance(request.data, dict) and 'data' in request.data:
payload_data = request.data.get('data')
else:
payload_data = request.data
if payload_data == {}:
payload_data = None
serializer = TaskWorkHoursTableSerializer(
table,
data={
'task': str(task.id),
'data': payload_data,
},
partial=bool(table),
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(task=task)
return Response(TaskWorkHoursTableSerializer(instance).data)

View File

@@ -1,3 +1,4 @@
[pytest]
DJANGO_SETTINGS_MODULE = core.settings
python_files = tests.py test_*.py *_tests.py
python_files = tests.py test_*.py *_tests.py
addopts = --import-mode=importlib

View File

@@ -50,6 +50,7 @@ pytesseract==0.3.13
pytest>=9.1.1
pytest-django>=4.12.0
python-dateutil==2.9.0.post0
python-docx==1.2.0
python-dotenv==1.2.2
pusher>=3.3.4
redis==8.0.0

View File

@@ -22,15 +22,24 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
command: npm run dev -- --host 0.0.0.0 --force
working_dir: /app
command: sh -c "rm -rf /app/.astro /app/node_modules/.vite && 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
- CHOKIDAR_USEPOLLING=true
- CHOKIDAR_INTERVAL=1000
- WATCHPACK_POLLING=true
- PUBLIC_ALLOWED_HOSTS=localhost,127.0.0.1
- PUBLIC_HMR_HOST=localhost
- PUBLIC_HMR_PROTOCOL=ws
- PUBLIC_HMR_CLIENT_PORT=4321
- PUBLIC_API_URL=http://localhost:4321/api/
- PUBLIC_API_PROXY_TARGET=http://backend:8000
flower:
container_name: 004erpflower
@@ -46,6 +55,22 @@ services:
- redis
- worker
beat:
container_name: 004erpbeat
build:
context: ./backend
dockerfile: Dockerfile
command: celery -A core beat --loglevel=info
user: "1000:1000"
volumes:
- ./backend:/app
- media_volume:/app/media
env_file:
- .env
depends_on:
- redis
- worker
volumes:
media_volume:
external: true # Ako je definiran u glavnom file-u, ovo ga povezuje
external: true # Ako je definiran u glavnom file-u, ovo ga povezuje

121
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,121 @@
services:
db:
restart: unless-stopped
redis:
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpbackend
restart: unless-stopped
env_file:
- .env
environment:
DJANGO_SETTINGS_MODULE: core.settings.production
command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120
volumes:
- media_volume:/app/media
ports:
- "8001:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
worker:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpworker
restart: unless-stopped
env_file:
- .env
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core worker --loglevel=info
volumes:
- media_volume:/app/media
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
beat:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpbeat
restart: unless-stopped
env_file:
- .env
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core beat --loglevel=info
volumes:
- media_volume:/app/media
depends_on:
redis:
condition: service_started
worker:
condition: service_started
flower:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpflower
restart: unless-stopped
env_file:
- .env
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core flower --port=5555
ports:
- "5555:5555"
depends_on:
redis:
condition: service_started
worker:
condition: service_started
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: 004erpfrontend
restart: unless-stopped
environment:
NODE_ENV: production
PUBLIC_API_URL: ${PUBLIC_API_URL}
command: sh -c "npm run build && node ./dist/server/entry.mjs"
ports:
- "4321:4321"
depends_on:
backend:
condition: service_started
webhook:
build: /opt/erp/deploy-webhook
container_name: 004erpwebhook
restart: unless-stopped
environment:
WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET}
ports:
- "9001:9001"
volumes:
- /opt/erp/app:/opt/erp/app
- /var/run/docker.sock:/var/run/docker.sock
volumes:
postgres_data:
redis_data:
media_volume:
external: true

View File

@@ -27,7 +27,7 @@ services:
context: ./backend
dockerfile: Dockerfile
command: celery -A core worker --loglevel=info
user: "1000:1000"
user: "1001:1001"
volumes:
- ./backend:/app
- media_volume:/app/media
@@ -44,4 +44,4 @@ services:
volumes:
postgres_data:
redis_data:
media_volume: # Ovo je ključno za tvoj ERP
media_volume: # Ovo je ključno za tvoj ERP

View File

@@ -0,0 +1,237 @@
# Preuzimanje produkcijske baze podataka na lokalni PC
Vodič za lokalni razvoj s kopijom produkcijskog PostgreSQL-a bez mijenjanja produkcijske baze.
---
## Preduvjeti
- SSH pristup produkcijskom serveru
- Docker Desktop instaliran lokalno
- `scp` dostupan u PowerShell / terminalu
- Lokalni `.env` s `DEBUG=True` i **drugačijim** `SECRET_KEY` od produkcije
---
## Korak 1 — Na produkcijskom serveru: napravi dump
SSH-aj na produkcijski server, zatim pokreni:
```bash
docker exec 004erpdb pg_dump \
-U $POSTGRES_USER \
-d $POSTGRES_DB \
--no-owner \
--no-acl \
-Fc \
-f /tmp/prod_dump.dump
```
> **`-Fc`** — custom format (komprimiran, pogodniji za `pg_restore`)
> **`--no-owner --no-acl`** — izostavlja produkcijske usere i privilegije
Ako env varijable nisu dostupne u shell sesiji, napiši vrijednosti direktno:
```bash
docker exec 004erpdb sh -c \
"pg_dump -U TVOJ_USER TVOJA_BAZA --no-owner --no-acl -Fc -f /tmp/prod_dump.dump"
```
Ako baza ima desetke gigabajta, pg_dump može opteretiti disk produkcije. U tom slučaju, dumpaj u komprimirani format **--exclude-table-data='log_*'**:
```bash
docker exec 004erpdb pg_dump \
-U $POSTGRES_USER \
-d $POSTGRES_DB \
--no-owner \
--no-acl \
--exclude-table-data='log_*' \
-Fc \
-f /tmp/prod_dump.dump
```
```bash
docker exec 004erpdb sh -c 'pg_dump
-U "$TVOJ_USER"
-d "$TVOJA_BAZA"
--no-owner
--no-acl
--exclude-table-data='log_*'
-Fc
-f /tmp/prod_dump.dump'
```
---
## Korak 2 — Kopiraj dump na lokalni PC
Iz docker servera na host server:
```bash
docker cp 004erpdb:/tmp/prod_dump.dump /tmp/prod_dump.dump
```
```bash
Iz lokalnog PowerShell terminala:
```powershell
scp tvoj_user@IP_SERVERA:/tmp/prod_dump.dump C:\Users\mario.tkalac\Downloads\prod_dump.dump
```
S SSH ključem:
```powershell
scp -i C:\Users\mario.tkalac\.ssh\id_rsa `
tvoj_user@IP_SERVERA:/tmp/prod_dump.dump `
C:\Users\mario.tkalac\Downloads\prod_dump.dump
```
Brisanje dump-a s produkcijskog servera (opcionalno):
```powershell
ssh tvoj_user@IP_SERVERA "rm /tmp/prod_dump.dump"
```
---
## Korak 3 — Pokreni lokalnu bazu
```powershell
cd C:\Users\mario.tkalac\workspace\aplikacija\004.erp.generic
docker compose -f docker-compose.yml up -d db
```
Pričekaj dok container postane `healthy`:
```powershell
docker ps --filter "name=004erpdb"
```
---
## Korak 4 — Restoriraj dump u lokalnu bazu
```powershell
# Kopiraj dump file u container
docker cp C:\Users\mario.tkalac\Downloads\prod_dump.dump 004erpdb:/tmp/prod_dump.dump
# Dropaj lokalnu bazu i kreiraj novu praznu (BRIŠE lokalne podatke!)
docker exec 004erpdb dropdb -U TVOJ_USER TVOJA_BAZA --if-exists
docker exec 004erpdb createdb -U TVOJ_USER TVOJA_BAZA
# Restoriraj
docker exec 004erpdb sh -c 'pg_restore
-U "$TVOJ_USER"
-d "$TVOJA_BAZA"
--no-owner
--no-acl
-Fc
-f /tmp/prod_dump.dump'
```
> ⚠️ Zamijeni `TVOJ_USER` i `TVOJA_BAZA` s vrijednostima iz lokalnog `.env` fajla:
> `POSTGRES_USER` i `POSTGRES_DB`.
---
## Korak 5 — Primijeni nove migracije
Pokrenuti odmah nakon restore-a kako bi lokalna shema bila u sinkronizaciji s kodom:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml `
run --rm backend python manage.py migrate
```
Migracije na postojećim podacima su sigurne — dodaju tablice/stupce, ne brišu podatke.
---
## Korak 6 — Postavi lokalnog admin korisnika
Produkcijske lozinke su hashirane i ne mogu se koristiti direktno. Kreiraj novog superusera:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml `
run --rm backend python manage.py createsuperuser
```
Ili resetiraj lozinku postojećem accountu:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml `
run --rm backend python manage.py changepassword tvoj@email.com
```
---
## Korak 7 — Pokreni cijeli dev stack
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d backend frontend
```
Provjeri:
- Backend: `http://localhost:8001/api/`
- Frontend: `http://localhost:4321/`
---
## Napomene o sigurnosti
| Tema | Akcija |
|------|--------|
| **Produkcijska baza** | `pg_dump` je read-only operacija — produkcijska baza nije dirnuta |
| **SECRET_KEY** | Lokalni `.env` **mora** imati drugačiji `SECRET_KEY` od produkcije |
| **DEBUG** | Lokalni `.env` mora imati `DEBUG=True` |
| **Media fajlovi** | Dump ne uključuje fajlove iz `media_volume`; kopirati ručno ako su potrebni |
| **Osobni podaci** | Baza može sadržavati osobne podatke klijenata — koristiti samo na zaštićenom lokalnom stroju |
---
## Media fajlovi (opcionalno)
Ako trebaju priloženi fajlovi (slike, PDF-ovi):
```bash
# Na produkcijskom serveru — arhiviraj media
docker run --rm \
-v media_volume:/data \
alpine tar czf /tmp/media.tar.gz -C /data .
# Kopiraj lokalno
scp tvoj_user@IP_SERVERA:/tmp/media.tar.gz C:\Users\mario.tkalac\Downloads\media.tar.gz
```
Raspakiraj u lokalni Docker volume:
```powershell
# Kopiraj arhivu u privremeni container
docker run --rm `
-v 004erpgeneric_media_volume:/data `
-v C:\Users\mario.tkalac\Downloads:/backup `
alpine sh -c "tar xzf /backup/media.tar.gz -C /data"
```
---
## Brzi pregled naredbi (cheat sheet)
```bash
# 1. Dump na serveru
docker exec 004erpdb pg_dump -U USER DB --no-owner --no-acl -Fc -f /tmp/prod_dump.dump
# 2. Kopiraj lokalno
scp user@IP:/tmp/prod_dump.dump ~/Downloads/prod_dump.dump
# 3. Restore lokalno
docker cp ~/Downloads/prod_dump.dump 004erpdb:/tmp/prod_dump.dump
docker exec 004erpdb dropdb -U USER DB --if-exists
docker exec 004erpdb createdb -U USER DB
docker exec 004erpdb pg_restore -U USER -d DB --no-owner --no-acl -Fc /tmp/prod_dump.dump
# 4. Migracije
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm backend python manage.py migrate
```

View File

@@ -13,12 +13,13 @@ const envHosts = (process.env.PUBLIC_ALLOWED_HOSTS || '')
.filter(Boolean);
const allowedHosts = Array.from(
new Set(['localhost', '127.0.0.1', ...envHosts])
new Set(['localhost', '127.0.0.1', 'serviseri-004.captain.mitteworkspace.cloud', ...envHosts])
);
const hmrHost = process.env.PUBLIC_HMR_HOST || 'localhost';
const hmrProtocol = process.env.PUBLIC_HMR_PROTOCOL || 'ws';
const hmrClientPort = Number(process.env.PUBLIC_HMR_CLIENT_PORT || 4321);
const apiProxyTarget = process.env.PUBLIC_API_PROXY_TARGET || 'http://localhost:8001';
export default defineConfig({
output: 'server',
@@ -59,9 +60,20 @@ export default defineConfig({
host: hmrHost,
clientPort: hmrClientPort,
},
proxy: {
'/api': {
target: apiProxyTarget,
changeOrigin: true,
},
},
watch: {
usePolling: true,
interval: 100,
interval: 1000,
awaitWriteFinish: {
stabilityThreshold: 1200,
pollInterval: 200,
},
ignored: ['**/.git/**', '**/node_modules/**', '**/.astro/**', '**/dist/**'],
},
}
: {
@@ -70,4 +82,4 @@ export default defineConfig({
},
integrations: [preact()],
});
});

View File

@@ -1,7 +1,147 @@
const CACHE_NAME = 'erp-shell-v1';
const CACHE_NAME = 'erp-shell-v2';
const API_CACHE_NAME = 'erp-api-v1';
const WRITE_QUEUE_DB_NAME = 'erp-write-queue-db';
const WRITE_QUEUE_STORE = 'requests';
const WRITE_QUEUE_SYNC_TAG = 'erp-write-queue-sync';
const OFFLINE_URL = '/offline.html';
const PRECACHE_URLS = ['/', '/index.html', '/manifest.webmanifest', '/pwa-icon.svg', OFFLINE_URL];
const API_CACHE_BLOCKLIST = [
'/api/token/',
'/api/token/refresh/',
'/api/fleet/pusher-auth/',
];
function isCacheableApiGet(request, url) {
if (request.method !== 'GET') return false;
if (url.origin !== self.location.origin) return false;
if (!url.pathname.startsWith('/api/')) return false;
return !API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked));
}
function isQueueableApiWrite(request, url) {
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) return false;
if (url.origin !== self.location.origin) return false;
if (!url.pathname.startsWith('/api/')) return false;
if (API_CACHE_BLOCKLIST.some((blocked) => url.pathname.startsWith(blocked))) return false;
const contentType = (request.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('multipart/form-data')) return false;
return true;
}
function openWriteQueueDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(WRITE_QUEUE_DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(WRITE_QUEUE_STORE)) {
db.createObjectStore(WRITE_QUEUE_STORE, { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function serializeHeaders(headers) {
const serialized = {};
headers.forEach((value, key) => {
serialized[key] = value;
});
return serialized;
}
function getAllQueuedRequests(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction(WRITE_QUEUE_STORE, 'readonly');
const store = tx.objectStore(WRITE_QUEUE_STORE);
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
function addQueuedRequest(db, queuedRequest) {
return new Promise((resolve, reject) => {
const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite');
const store = tx.objectStore(WRITE_QUEUE_STORE);
const request = store.add(queuedRequest);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function deleteQueuedRequest(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction(WRITE_QUEUE_STORE, 'readwrite');
const store = tx.objectStore(WRITE_QUEUE_STORE);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async function queueWriteRequest(request) {
const body = request.method === 'DELETE' ? null : await request.clone().text();
const db = await openWriteQueueDb();
const payload = {
url: request.url,
method: request.method,
headers: serializeHeaders(request.headers),
body,
createdAt: Date.now(),
};
await addQueuedRequest(db, payload);
}
function createQueuedResponse() {
return new Response(
JSON.stringify({
queued: true,
offline: true,
detail: 'Zahtjev je spremljen i bit će sinkroniziran kad veza bude dostupna.',
}),
{
status: 202,
headers: { 'Content-Type': 'application/json' },
}
);
}
async function scheduleWriteQueueSync() {
if (!self.registration || !self.registration.sync) return;
await self.registration.sync.register(WRITE_QUEUE_SYNC_TAG);
}
async function replayQueuedRequests() {
const db = await openWriteQueueDb();
const queuedRequests = await getAllQueuedRequests(db);
for (const queued of queuedRequests) {
const headers = new Headers(queued.headers || {});
const init = {
method: queued.method,
headers,
credentials: 'same-origin',
};
if (queued.body != null && queued.method !== 'DELETE') {
init.body = queued.body;
}
try {
const response = await fetch(queued.url, init);
if (response.ok) {
await deleteQueuedRequest(db, queued.id);
continue;
}
if (response.status >= 500) {
throw new Error(`Replay failed with status ${response.status}`);
}
await deleteQueuedRequest(db, queued.id);
} catch (error) {
throw error;
}
}
}
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
@@ -11,22 +151,76 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
)
Promise.all([
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== API_CACHE_NAME)
.map((name) => caches.delete(name))
)
),
replayQueuedRequests().catch((error) => {
console.error('Initial write-queue replay failed.', error);
}),
])
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (isQueueableApiWrite(request, url)) {
event.respondWith(
fetch(request.clone()).catch(async () => {
try {
await queueWriteRequest(request);
} catch (error) {
console.error('Failed to queue offline write request.', error);
return new Response(
JSON.stringify({
queued: false,
offline: true,
detail: 'Zahtjev nije moguće spremiti offline.',
}),
{
status: 503,
headers: { 'Content-Type': 'application/json' },
}
);
}
try {
await scheduleWriteQueueSync();
} catch (error) {
console.warn('Background sync registration failed.', error);
}
return createQueuedResponse();
})
);
return;
}
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (isCacheableApiGet(request, url)) {
event.respondWith(
caches.open(API_CACHE_NAME).then(async (cache) => {
const cached = await cache.match(request);
const networkFetch = fetch(request)
.then((response) => {
if (response && response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => cached);
return cached || networkFetch;
})
);
return;
}
if (url.origin !== self.location.origin) return;
if (request.mode === 'navigate') {
@@ -60,3 +254,13 @@ self.addEventListener('fetch', (event) => {
);
}
});
self.addEventListener('sync', (event) => {
if (event.tag !== WRITE_QUEUE_SYNC_TAG) return;
event.waitUntil(replayQueuedRequests());
});
self.addEventListener('message', (event) => {
if (event.data !== 'FLUSH_WRITE_QUEUE') return;
event.waitUntil(replayQueuedRequests());
});

View File

@@ -2,7 +2,8 @@ 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 { useEffect, useRef, useState } from 'preact/hooks';
import { useSpring, animated } from '@react-spring/web';
import { hydrateAuthFromStorage } from '../stores/authStore';
const ITEMS = [
@@ -13,30 +14,85 @@ const ITEMS = [
{ id: 'clients', label: 'Klijenti', href: '/clients' },
];
function normalizePath(p) {
const value = String(p || '/');
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
return value;
}
function getActiveIndex(path) {
const idx = ITEMS.findIndex((item) => item.href === path);
return idx >= 0 ? idx : 0;
}
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]
// Navbar ostaje montiran (transition:persist) — pratimo promjene pathname preko astro:page-load
const [pathname, setPathname] = useState(() =>
typeof window !== 'undefined' ? normalizePath(window.location.pathname) : '/'
);
const itemRefs = useRef([]);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
// Na prvom renderu preskočimo animaciju (pill se pojavljuje na ispravnoj poziciji bez slide-a od 0)
const [skipAnimation, setSkipAnimation] = useState(true);
const activeIndex = getActiveIndex(pathname);
const measureIndicator = (currentIndex) => {
const idx = currentIndex ?? activeIndex;
const el = itemRefs.current[idx];
if (!el) return;
const parent = el.closest('ul');
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
setIndicatorStyle({
left: elRect.left - parentRect.left,
width: elRect.width,
});
};
// Mount: izmjeri početnu poziciju bez animacije, pa uključi animaciju za buduće navigacije
useEffect(() => {
hydrateAuthFromStorage();
const timer = setTimeout(() => {
measureIndicator();
setSkipAnimation(false);
}, 30);
// Sluša Astro view-transition navigaciju (Navbar ostaje montiran zahvaljujući transition:persist)
const handlePageLoad = () => {
setPathname(normalizePath(window.location.pathname));
};
document.addEventListener('astro:page-load', handlePageLoad);
return () => {
clearTimeout(timer);
document.removeEventListener('astro:page-load', handlePageLoad);
};
}, []);
// Kad se promijeni activeIndex (nova stranica), animiraj pill na novu poziciju
useEffect(() => {
if (!skipAnimation) {
measureIndicator();
}
}, [activeIndex]);
// react-spring: animirani klizač
// immediate=true na prvom renderu → pill se ne animira od lijevog ruba, već skače na pravo mjesto
const springStyle = useSpring({
left: indicatorStyle.left,
width: indicatorStyle.width,
immediate: skipAnimation,
config: { tension: 340, friction: 28 },
});
const currentItem = ITEMS[activeIndex];
return (
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur">
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur overflow-visible">
<div className="flex flex-wrap items-center justify-between gap-2">
{/* Logo + korisnik */}
<a href="/" className="flex flex-col hover:opacity-80">
@@ -44,16 +100,29 @@ export default function Navbar({ minimal = false }) {
{!minimal && <UserDisplay field="ime_prezime" className="text-xs text-text-muted" />}
</a>
{/* Navigacijski linkovi */}
<ul className="hidden items-center gap-1 text-sm sm:flex">
{ITEMS.map((item) => (
<li key={item.id}>
{/* Navigacijski linkovi s react-spring klizačem */}
<ul className="relative hidden items-center gap-1 text-sm sm:flex">
{/* Animirani pozadinski klizač */}
<animated.li
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
bottom: 0,
borderRadius: '0.5rem',
backgroundColor: 'rgb(238 242 255)',
pointerEvents: 'none',
...springStyle,
}}
/>
{ITEMS.map((item, idx) => (
<li key={item.id} ref={(el) => (itemRefs.current[idx] = el)}>
<a
href={item.href}
className={
normalizedPath === item.href
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
pathname === item.href
? 'relative z-10 block rounded-lg px-3 py-2 font-medium text-indigo-700'
: 'relative z-10 block rounded-lg px-3 py-2 text-text-main hover:text-indigo-600'
}
>
{item.label}
@@ -64,11 +133,13 @@ export default function Navbar({ minimal = false }) {
{/* Desna strana */}
<div className="flex items-center gap-2">
{/* Mobilni dropdown */}
<details className="relative sm:hidden">
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-deep">
{currentItem?.label || 'Izbornik'}
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline bg-canvas-base px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep select-none">
{currentItem?.label || 'Izbornik'}
</summary>
<div className="absolute right-0 mt-2 min-w-44 rounded-lg border border-border-hairline bg-canvas-elevated shadow-lg">
{/* Dropdown je positioniran fixed-like kroz visoki z-index kako ne bi bio odsječen */}
<div className="absolute left-0 top-full mt-1 z-50 min-w-52 rounded-lg border border-border-hairline bg-canvas-elevated shadow-xl">
<div className="border-b border-border-hairline px-3 py-2 text-[11px] uppercase tracking-wide text-text-muted">
Navigacija
</div>
@@ -78,11 +149,12 @@ export default function Navbar({ minimal = false }) {
<a
href={item.href}
className={
normalizedPath === item.href
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
pathname === item.href
? 'flex items-center gap-2 bg-indigo-50 px-3 py-2.5 text-sm font-medium text-indigo-700'
: 'flex items-center gap-2 px-3 py-2.5 text-sm text-text-main hover:bg-canvas-deep'
}
>
{pathname === item.href && <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />}
{item.label}
</a>
</li>

View File

@@ -1,13 +0,0 @@
import { showToast } from '../stores/toastStore';
export default function ToastTrigger() {
return (
<button
type="button"
onClick={() => showToast('Sustav je spreman za rad.', 'info', 2500)}
className="fixed bottom-4 right-4 z-50 rounded-full border border-border-hairline bg-canvas-elevated px-4 py-2 text-xs font-semibold text-text-main shadow-sm hover:bg-canvas-deep"
>
Test Toast
</button>
);
}

View File

@@ -131,23 +131,20 @@ export default function ClientsSection({ onSetServiceContext = null }) {
headClassName="text-xs uppercase tracking-wide text-text-muted"
bodyClassName="divide-y divide-border-hairline"
rowClassName="hover:bg-canvas-deep"
isRowSelected={(item) => String(item.id) === String(selectedClientCraneId)}
tableClassName="min-w-full text-sm"
wrapperClassName="overflow-x-auto"
renderRow={(item) => {
const isSelected = String(item.id) === String(selectedClientCraneId);
const cellClassName = isSelected ? 'bg-emerald-50' : '';
const firstCellClassName = isSelected ? 'bg-emerald-50 text-emerald-800' : '';
return (
renderRow={(item) => (
<>
<td className={`py-2 pr-4 font-medium cursor-pointer ${firstCellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td>
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td>
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td>
<td className={`py-2 pr-4 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
<td className="py-2 pr-4 font-medium cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.registration_number}</td>
<td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.make} {item.model} {item.year ? `(${item.year})` : ''}</td>
<td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>{item.current_mileage?.toLocaleString('hr-HR')} km</td>
<td className="py-2 pr-4 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>
{item.service_interval_km
? `${(Math.floor(item.current_mileage / item.service_interval_km) + 1) * item.service_interval_km} km`
: '—'}
</td>
<td className={`py-2 cursor-pointer ${cellClassName}`} onClick={() => setSelectedClientCraneId(item.id)}>
<td className="py-2 cursor-pointer" onClick={() => setSelectedClientCraneId(item.id)}>
<span className={item.is_company_vehicle
? 'rounded-full bg-indigo-50 px-2 py-0.5 text-xs text-indigo-700'
: 'rounded-full bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700'
@@ -156,7 +153,7 @@ export default function ClientsSection({ onSetServiceContext = null }) {
</span>
</td>
</>
);}}
)}
/>
)}
</div>

View File

@@ -1,32 +1,18 @@
import { showToast } from '../../stores/toastStore';
import ServiceContextSelector from './ServiceContextSelector';
const NAV_ITEMS = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'work-orders', label: 'Putni nalozi' },
{ key: 'service-records', label: 'Servisni zapisi' },
{ key: 'cranes', label: 'Dizalice' },
{ key: 'clients', label: 'Klijenti' },
];
export default function DashboardTopbar({
activeSection,
onSectionChange,
onOpenWorkOrderModal,
onOpenTaskModal,
pendingSyncCount,
onNewServiceRecord,
showSectionNav = true,
}) {
const sectionTitle = NAV_ITEMS.find((item) => item.key === activeSection)?.label || 'Dashboard';
return (
<header className="sticky top-0 z-20 border-b border-border-hairline bg-canvas-elevated/95 backdrop-blur">
{/* ── Gornji red ───────────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div>
<p className="text-xs uppercase tracking-wide text-text-muted">Pregled sustava</p>
<h2 className="text-xl font-semibold text-text-main">{sectionTitle}</h2>
<h2 className="text-xl font-semibold text-text-main">ERP</h2>
</div>
<div className="flex items-center gap-3">
{pendingSyncCount > 0 && (
@@ -53,31 +39,6 @@ export default function DashboardTopbar({
{/* ── Selector bar: Kupac + Dizalica ───────────────────── */}
<ServiceContextSelector onNewServiceRecord={onNewServiceRecord} />
{/* ── Navigacijski tabovi ──────────────────────────────── */}
{showSectionNav && (
<nav className="overflow-x-auto border-t border-border-hairline px-4 sm:px-6">
<ul className="flex min-w-max items-center gap-1 py-2 text-sm">
{NAV_ITEMS.map((item) => (
<li key={item.key}>
<button
type="button"
onClick={() => {
onSectionChange(item.key);
}}
className={
activeSection === item.key
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
}
>
{item.label}
</button>
</li>
))}
</ul>
</nav>
)}
</header>
);
}

Some files were not shown because too many files have changed in this diff Show More