Compare commits
58 Commits
environmen
...
9a7c7e3163
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a7c7e3163 | ||
|
|
7da1c84139 | ||
|
|
e8272a00bb | ||
|
|
d49ec15cc7 | ||
|
|
1d7d631fbd | ||
|
|
45142bba4b | ||
|
|
e7641e7be0 | ||
|
|
52f54e3a75 | ||
|
|
a8f441c150 | ||
|
|
2a9cac0c81 | ||
|
|
79145c58e4 | ||
|
|
fd8c6d8040 | ||
|
|
06321fc3d1 | ||
|
|
6470a2996c | ||
|
|
16264cb8a9 | ||
|
|
51a65f9b80 | ||
|
|
882e255523 | ||
|
|
3a33751012 | ||
|
|
fe739b14c0 | ||
|
|
8b82481bb4 | ||
|
|
525152b817 | ||
|
|
ae6ae08fb1 | ||
|
|
1055820741 | ||
|
|
908025abc6 | ||
|
|
666ddb56c9 | ||
|
|
ebb642e572 | ||
|
|
5945423346 | ||
|
|
42fcd836ed | ||
|
|
b92596f6a6 | ||
|
|
1a18498b30 | ||
|
|
61996bd1a0 | ||
|
|
ae35a6041d | ||
|
|
d35eaeccf4 | ||
|
|
cbcbf0b3af | ||
|
|
ad7b293f54 | ||
|
|
8485431180 | ||
|
|
bbdd02c9e9 | ||
|
|
fe7edf5643 | ||
|
|
0c9e1b7d50 | ||
|
|
bf8e25efe7 | ||
|
|
ef8718a4cf | ||
|
|
75257525c4 | ||
|
|
7acaef941f | ||
|
|
e14665d8ec | ||
|
|
ef51036bc7 | ||
|
|
13de9e113c | ||
|
|
e8330473cd | ||
|
|
5e7ec72a4e | ||
|
|
346682b172 | ||
|
|
8e4b3f8aea | ||
|
|
1ab2bfb3c3 | ||
|
|
3ad536a23e | ||
| 70151f1225 | |||
|
|
ac5f25a99c | ||
| 24cd9ae11c | |||
| 3268e53642 | |||
|
|
4e3bf6dab0 | ||
|
|
3b54499b17 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
24
README.md
24
README.md
@@ -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/
|
||||
```
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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'),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,,
|
||||
|
202
backend/core/users/management/commands/import_users_csv.py
Normal file
202
backend/core/users/management/commands/import_users_csv.py
Normal 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
|
||||
16
backend/core/users/migrations/0007_customuser_signature.py
Normal file
16
backend/core/users/migrations/0007_customuser_signature.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
39
backend/core/users/tests/test_email_signature.py
Normal file
39
backend/core/users/tests/test_email_signature.py
Normal 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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
103
backend/entrypoint.sh
Normal file → Executable 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
|
||||
|
||||
|
||||
0
backend/modules/__init__.py
Normal file
0
backend/modules/__init__.py
Normal 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
|
||||
|
@@ -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
|
||||
|
0
backend/modules/crm/management/__init__.py
Normal file
0
backend/modules/crm/management/__init__.py
Normal file
0
backend/modules/crm/management/commands/__init__.py
Normal file
0
backend/modules/crm/management/commands/__init__.py
Normal file
130
backend/modules/crm/management/commands/import_clients_csv.py
Normal file
130
backend/modules/crm/management/commands/import_clients_csv.py
Normal 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))
|
||||
@@ -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
|
||||
BIN
backend/modules/fleet/docx_templates/Servisni N-R.docx
Normal file
BIN
backend/modules/fleet/docx_templates/Servisni N-R.docx
Normal file
Binary file not shown.
17
backend/modules/fleet/email_utils.py
Normal file
17
backend/modules/fleet/email_utils.py
Normal 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}"
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
0
backend/modules/fleet/management/__init__.py
Normal file
0
backend/modules/fleet/management/__init__.py
Normal file
283
backend/modules/fleet/management/commands/import_cranes_csv.py
Normal file
283
backend/modules/fleet/management/commands/import_cranes_csv.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
43
backend/modules/fleet/migrations/0022_workorderphoto.py
Normal file
43
backend/modules/fleet/migrations/0022_workorderphoto.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
11
backend/modules/fleet/migrations/0030_merge_20260719_2057.py
Normal file
11
backend/modules/fleet/migrations/0030_merge_20260719_2057.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fleet', '0029_alter_workorder_purpose'),
|
||||
('fleet', '0029_workorderadditionalcoststable'),
|
||||
]
|
||||
|
||||
operations = []
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
41
backend/modules/fleet/migrations/0032_servicecontextnote.py
Normal file
41
backend/modules/fleet/migrations/0032_servicecontextnote.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
43
backend/modules/fleet/migrations/0033_emaildispatchlog.py
Normal file
43
backend/modules/fleet/migrations/0033_emaildispatchlog.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
128
backend/modules/fleet/pdf_layout.py
Normal file
128
backend/modules/fleet/pdf_layout.py
Normal 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",
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
169
backend/modules/fleet/tests/test_service_context_notes.py
Normal file
169
backend/modules/fleet/tests/test_service_context_notes.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
273
backend/modules/fleet/tests/test_work_order_images_endpoint.py
Normal file
273
backend/modules/fleet/tests/test_work_order_images_endpoint.py
Normal 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)
|
||||
128
backend/modules/fleet/tests/test_work_order_send_email.py
Normal file
128
backend/modules/fleet/tests/test_work_order_send_email.py
Normal 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()
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
7
backend/modules/task_management/exceptions.py
Normal file
7
backend/modules/task_management/exceptions.py
Normal 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.'
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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.',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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',),
|
||||
),
|
||||
]
|
||||
@@ -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.',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
0
backend/modules/task_management/tests/__init__.py
Normal file
0
backend/modules/task_management/tests/__init__.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
121
docker-compose.prod.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
237
docs/production-db-local-setup.md
Normal file
237
docs/production-db-local-setup.md
Normal 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
|
||||
```
|
||||
@@ -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()],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user