Compare commits
56 Commits
environmen
...
e8272a00bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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',),
|
||||
),
|
||||
]
|
||||
@@ -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,13 @@ 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."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
@@ -53,7 +63,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,159 @@
|
||||
# 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',
|
||||
'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},
|
||||
}
|
||||
|
||||
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 +162,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 +184,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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user