feat: dodaj bilješke widget i admin kalendar/notifikacije
Uveden je novi Notes drawer s kalendar integracijom, zajednički DRY left-slide mehanizam te međusobno isključivo prikazivanje s Kalendar widgetom. Backend i frontend su prošireni za servisne bilješke (uključujući slanje na više servisera), dodan je users/servicers endpoint, te su uvedene warning notifikacije kada serviser ima više od 2 zadatka isti dan. U admin su dodane stavke za Bilješke i zaseban Kalendar zadataka (proxy TaskCalendar) radi lakšeg operativnog pregleda. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -2,9 +2,11 @@
|
||||
|
||||
from io import BytesIO
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from django.conf import settings
|
||||
@@ -14,6 +16,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
from django.db.models import Q
|
||||
from django.db import transaction
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.utils import ImageReader
|
||||
@@ -21,7 +24,7 @@ from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.platypus import Table, TableStyle, Paragraph
|
||||
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework import viewsets, permissions, status, mixins
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError
|
||||
@@ -32,6 +35,7 @@ from .models import (
|
||||
Crane,
|
||||
Vehicle,
|
||||
VehicleNotification,
|
||||
ServiceContextNote,
|
||||
WorkOrder,
|
||||
WorkOrderPhoto,
|
||||
GeneratedWorkOrderPdf,
|
||||
@@ -50,6 +54,8 @@ from .serializers import (
|
||||
WorkOrderPhotoSerializer,
|
||||
VehicleServiceRecordSerializer,
|
||||
VehicleNotificationSerializer,
|
||||
ServiceContextNoteSerializer,
|
||||
ServiceContextNoteCreateSerializer,
|
||||
VehicleServicePhotoSerializer,
|
||||
VehicleServiceAttachmentSerializer,
|
||||
)
|
||||
@@ -67,6 +73,7 @@ from .tasks import (
|
||||
)
|
||||
|
||||
register_unicode_fonts()
|
||||
User = get_user_model()
|
||||
|
||||
def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
|
||||
queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True)
|
||||
@@ -99,6 +106,23 @@ def _work_orders_queryset_for_user(user):
|
||||
return queryset.filter(creator=user)
|
||||
|
||||
|
||||
def _is_supervisor_user(user):
|
||||
return bool(getattr(user, 'is_staff', False) or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False)))
|
||||
|
||||
|
||||
def _active_team_members_queryset():
|
||||
return User.objects.filter(is_active=True, is_team_member=True).order_by('first_name', 'last_name', 'email')
|
||||
|
||||
|
||||
def _service_context_notes_queryset_for_user(user):
|
||||
return (
|
||||
ServiceContextNote.objects
|
||||
.select_related('created_by', 'recipient', 'work_order', 'task')
|
||||
.filter(is_active=True, recipient=user, is_closed=False)
|
||||
.order_by('note_date', '-created_at')
|
||||
)
|
||||
|
||||
|
||||
def _work_order_related_tasks_queryset(work_order):
|
||||
from modules.task_management.models import Task
|
||||
|
||||
@@ -1994,6 +2018,110 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
|
||||
instance.is_active = False
|
||||
instance.save(update_fields=['is_active'])
|
||||
|
||||
|
||||
class ServiceContextNoteViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return _service_context_notes_queryset_for_user(self.request.user)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return ServiceContextNoteCreateSerializer
|
||||
return ServiceContextNoteSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data, context={'request': request})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
payload = serializer.validated_data
|
||||
user = request.user
|
||||
audience = payload.get('audience', 'self')
|
||||
target_users = payload.get('target_users') or []
|
||||
note_text = payload.get('note')
|
||||
note_date = payload.get('note_date')
|
||||
work_order = payload.get('work_order')
|
||||
task = payload.get('task')
|
||||
is_supervisor = _is_supervisor_user(user)
|
||||
can_manage_recipients = bool(is_supervisor or getattr(user, 'is_serviser', False))
|
||||
|
||||
if work_order and not _work_orders_queryset_for_user(user).filter(pk=work_order.pk).exists():
|
||||
raise DRFValidationError({'work_order': 'Nemate dozvolu za odabrani putni nalog.'})
|
||||
if task and not is_supervisor and str(getattr(task, 'assigned_to_id', '') or '') != str(user.id):
|
||||
raise DRFValidationError({'task': 'Možete povezati samo svoj aktivni radni zadatak.'})
|
||||
|
||||
recipients = []
|
||||
if audience == 'self':
|
||||
recipients = [user]
|
||||
elif audience == 'member':
|
||||
recipients = list(target_users)
|
||||
else:
|
||||
if not can_manage_recipients:
|
||||
raise DRFValidationError({'audience': 'Nemate dozvolu za slanje bilješke cijelom timu.'})
|
||||
recipients = list(_active_team_members_queryset())
|
||||
if not recipients:
|
||||
raise DRFValidationError({'audience': 'Nema aktivnih članova tima za slanje bilješke.'})
|
||||
|
||||
created_notes = []
|
||||
seen = OrderedDict()
|
||||
for recipient in recipients:
|
||||
seen[str(recipient.id)] = recipient
|
||||
with transaction.atomic():
|
||||
for recipient in seen.values():
|
||||
note = ServiceContextNote.objects.create(
|
||||
created_by=user,
|
||||
recipient=recipient,
|
||||
audience_source=audience,
|
||||
work_order=work_order,
|
||||
task=task,
|
||||
note=note_text,
|
||||
note_date=note_date,
|
||||
)
|
||||
created_notes.append(note)
|
||||
NotificationService.create_notification(
|
||||
recipient=recipient,
|
||||
title='Nova bilješka servisnog konteksta',
|
||||
message=note_text[:255],
|
||||
level='info',
|
||||
metadata={
|
||||
'entity_type': 'service_note',
|
||||
'service_note_id': str(note.id),
|
||||
'work_order_id': str(work_order.id) if work_order else None,
|
||||
'task_id': str(task.id) if task else None,
|
||||
'section': 'dashboard',
|
||||
'note_date': note_date.isoformat() if note_date else None,
|
||||
},
|
||||
send_email=False,
|
||||
)
|
||||
|
||||
response_serializer = ServiceContextNoteSerializer(
|
||||
_service_context_notes_queryset_for_user(user)[:20],
|
||||
many=True,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
'created_count': len(created_notes),
|
||||
'notes': response_serializer.data,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='close')
|
||||
def close(self, request, pk=None):
|
||||
note = self.get_object()
|
||||
if note.is_closed:
|
||||
serializer = ServiceContextNoteSerializer(note)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
note.is_closed = True
|
||||
note.closed_at = timezone.now()
|
||||
note.save(update_fields=['is_closed', 'closed_at', 'updated_at'])
|
||||
serializer = ServiceContextNoteSerializer(note)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class VehicleNotificationViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
List all notifications for the authenticated user and provide an action
|
||||
|
||||
Reference in New Issue
Block a user