from datetime import datetime from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.db import models from django.db.models import Q, Sum from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from django.utils import timezone from apps.it_manager.models import ( Consumable, Equipment, EquipmentAssignment, EquipmentModel, InternetContract, InternetProvider, ServiceCall, Ticket, TicketComment, WorkLog, ) from apps.locations.models.base import Location from core.models.directories import Directory from core.utils.htmx import render_page from core.utils.pagination import paginate Employee = get_user_model() def _is_it_staff(user): from core.portal_access import user_can_access return user_can_access(user, 'it_manager') def _it_assignees(): """Кандидаты в исполнители: группа IT, а также staff/superuser (если в группу IT ещё никого не добавили — селект не пустой).""" return ( Employee.objects.filter(is_active=True) .filter(Q(groups__name='IT') | Q(is_staff=True) | Q(is_superuser=True)) .distinct() .order_by('last_name', 'first_name') ) def _directory_choices(type_code): """(code, name)-кортежі активних записів довідника — та сама форма, що й колишні CharField(choices=...), для сумісності з шаблонами.""" return list( Directory.objects.filter(directory_type__code=type_code, is_active=True) .order_by('name').values_list('code', 'name') ) def _resolve_directory(type_code, code): """Резолвить код у Directory.pk для запису у FK-поле з форми (POST шле код, не pk — так шаблони лишаються незмінними).""" if not code: return None return Directory.objects.filter(directory_type__code=type_code, code=code).values_list('id', flat=True).first() def _it_manager_context(request): equipment_qs = ( Equipment.objects.select_related( 'equipment_model__brand', 'equipment_model__equipment_type', 'assigned_to', 'location', 'status', ) .order_by('equipment_model__equipment_type__name', 'equipment_model__name', 'serial_number') ) equipment = list(equipment_qs) equipment_statuses = _directory_choices('equipment_status') equipment_counts = {code: 0 for code, _ in equipment_statuses} for item in equipment: equipment_counts[item.status.code] = equipment_counts.get(item.status.code, 0) + 1 equipment_page = paginate(request, equipment_qs, page_param='equipment_page', size_param='equipment_per_page') equipment_models = list( EquipmentModel.objects.select_related('equipment_type', 'brand') .annotate(units_total=models.Count('units')) .order_by('equipment_type__name', 'brand__name', 'name') ) tickets_qs = ( Ticket.objects.select_related('requester', 'assignee', 'equipment', 'ticket_type', 'priority', 'status') .prefetch_related('comments__author') .order_by('-created_at') ) tickets = list(tickets_qs) ticket_statuses = _directory_choices('ticket_status') ticket_counts = {code: 0 for code, _ in ticket_statuses} for ticket in tickets: ticket_counts[ticket.status.code] = ticket_counts.get(ticket.status.code, 0) + 1 ticket_overdue_count = sum(1 for t in tickets if t.is_overdue) tickets_page = paginate(request, tickets_qs, page_param='tickets_page', size_param='tickets_per_page') month_start = timezone.localdate().replace(day=1) worklogs_qs = ( WorkLog.objects.select_related('technician', 'location', 'equipment', 'ticket', 'work_type') .order_by('-date', '-created_at') ) worklogs_page = paginate(request, worklogs_qs, page_param='worklogs_page', size_param='worklogs_per_page') month_hours = WorkLog.objects.filter(date__gte=month_start).aggregate(total=Sum('hours_spent'))['total'] or 0 month_count = WorkLog.objects.filter(date__gte=month_start).count() consumables_qs = ( Consumable.objects.select_related('consumable_type', 'color', 'status', 'equipment', 'location') .order_by('equipment', 'consumable_type__name', 'color__name') ) consumables = list(consumables_qs) consumable_low_count = sum(1 for c in consumables if c.is_low) consumables_page = paginate(request, consumables_qs, page_param='consumables_page', size_param='consumables_per_page') printers = [e for e in equipment if e.equipment_type.code == 'printer'] providers = list(InternetProvider.objects.order_by('name')) contracts_qs = ( InternetContract.objects.select_related('provider', 'location', 'service_type', 'connection_type', 'status') .order_by('location__address', 'provider__name') ) contracts = list(contracts_qs) contracts_expiring_count = sum(1 for c in contracts if c.is_expiring_soon) contracts_page = paginate(request, contracts_qs, page_param='contracts_page', size_param='contracts_per_page') service_calls_qs = ( ServiceCall.objects.select_related('caller', 'handler', 'location', 'channel', 'ticket') .order_by('-date_time') ) service_calls_page = paginate( request, service_calls_qs, page_param='servicecalls_page', size_param='servicecalls_per_page', ) service_calls_count = service_calls_qs.count() return { 'equipment_list': equipment, 'equipment_page': equipment_page, 'equipment_counts': equipment_counts, 'equipment_types': _directory_choices('equipment_type'), 'equipment_statuses': equipment_statuses, 'equipment_brands': Directory.objects.filter(directory_type__code='equipment_brand', is_active=True).order_by('name'), 'equipment_models': equipment_models, 'it_locations': ( Location.objects.filter(is_active=True) .select_related('location_region') .prefetch_related('pharmacy') .order_by('location_type', 'address') ), 'it_employees': Employee.objects.filter(is_active=True).order_by('last_name', 'first_name'), 'tickets': tickets, 'tickets_page': tickets_page, 'ticket_counts': ticket_counts, 'ticket_overdue_count': ticket_overdue_count, 'ticket_statuses': ticket_statuses, 'ticket_priorities': _directory_choices('ticket_priority'), 'ticket_types': _directory_choices('ticket_type'), 'it_staff': _it_assignees(), 'worklogs_page': worklogs_page, 'worklog_types': _directory_choices('worklog_type'), 'month_hours': month_hours, 'month_count': month_count, 'consumables_page': consumables_page, 'consumables_count': len(consumables), 'consumable_low_count': consumable_low_count, 'consumable_types': _directory_choices('consumable_type'), 'consumable_colors': _directory_choices('consumable_color'), 'consumable_statuses': _directory_choices('consumable_status'), 'printers': printers, 'providers': providers, 'contracts_page': contracts_page, 'contracts_count': len(contracts), 'contracts_expiring_count': contracts_expiring_count, 'contract_service_types': _directory_choices('contract_service_type'), 'contract_connection_types': _directory_choices('contract_connection_type'), 'contract_statuses': _directory_choices('contract_status'), 'service_calls_page': service_calls_page, 'service_calls_count': service_calls_count, 'servicecall_channels': _directory_choices('servicecall_channel'), } @login_required def it_manager_view(request): if not _is_it_staff(request.user): return HttpResponseForbidden() return render_page(request, 'it-manager.html', _it_manager_context(request)) @login_required def equipment_model_create_view(request): """Додати модель у каталог (тип + бренд + назва). Далі за нею заводяться екземпляри лише з серійниками.""" if not _is_it_staff(request.user): return HttpResponseForbidden() context_extra = {} if request.method == 'POST': equipment_type_id = _resolve_directory('equipment_type', request.POST.get('equipment_type', '')) name = request.POST.get('name', '').strip() if equipment_type_id and name: warranty_months = request.POST.get('warranty_months', '').strip() EquipmentModel.objects.get_or_create( equipment_type_id=equipment_type_id, brand_id=request.POST.get('brand_id') or None, name=name, defaults={ 'warranty_months': int(warranty_months) if warranty_months.isdigit() else None, 'notes': request.POST.get('notes', '').strip(), }, ) else: context_extra['equipment_form_error'] = 'Вкажіть тип і назву моделі.' context = _it_manager_context(request) context['it_active_tab'] = 'equipment' context.update(context_extra) return render_page(request, 'it-manager.html', context) @login_required def equipment_bulk_create_view(request): """Масовий ввід екземплярів: обираємо модель з каталогу і вставляємо список серійників (по одному в рядку) — створюється N одиниць на склад. Так більше не треба щоразу переписувати тип/бренд/модель.""" if not _is_it_staff(request.user): return HttpResponseForbidden() context_extra = {} if request.method == 'POST': model_id = request.POST.get('equipment_model_id') or None raw_serials = request.POST.get('serial_numbers', '') # серійники — по одному в рядку (або через кому); чистимо й дедуплікуємо serials, seen = [], set() for chunk in raw_serials.replace(',', '\n').splitlines(): s = chunk.strip() if s and s not in seen: seen.add(s) serials.append(s) model = EquipmentModel.objects.filter(pk=model_id).first() if model_id else None if not model: context_extra['equipment_form_error'] = 'Оберіть модель з каталогу.' elif not serials: context_extra['equipment_form_error'] = 'Вставте хоча б один серійний номер.' else: purchase_date = request.POST.get('purchase_date') or None warranty_until = request.POST.get('warranty_until') or None # авто-розрахунок гарантії від дати покупки, якщо в моделі є термін if purchase_date and not warranty_until and model.warranty_months: try: d = datetime.strptime(purchase_date, '%Y-%m-%d').date() month = d.month - 1 + model.warranty_months year = d.year + month // 12 from calendar import monthrange day = min(d.day, monthrange(year, month % 12 + 1)[1]) warranty_until = d.replace(year=year, month=month % 12 + 1, day=day) except ValueError: warranty_until = None location_id = request.POST.get('location_id') or None # якщо одразу видаємо на локацію — статус assigned, інакше stock status_code = 'assigned' if location_id else 'stock' status_id = _resolve_directory('equipment_status', status_code) \ or _resolve_directory('equipment_status', 'stock') existing = set( Equipment.objects.filter(serial_number__in=serials) .values_list('serial_number', flat=True) ) created = 0 for s in serials: if s in existing: continue Equipment.objects.create( equipment_model=model, serial_number=s, status_id=status_id, location_id=location_id, purchase_date=purchase_date, warranty_until=warranty_until, ) created += 1 skipped = len(serials) - created msg = f'Додано {created} од. «{model}»' if skipped: msg += f'; пропущено {skipped} (серійник вже існує)' context_extra['equipment_form_message'] = msg context = _it_manager_context(request) context['it_active_tab'] = 'equipment' context.update(context_extra) return render_page(request, 'it-manager.html', context) @login_required def equipment_bulk_assign_view(request): """Масова видача: відмічені екземпляри → локація (та/або співробітник) за один крок, з записом у історію переміщень.""" if not _is_it_staff(request.user): return HttpResponseForbidden() context_extra = {} if request.method == 'POST': ids = request.POST.getlist('equipment_ids') location_id = request.POST.get('location_id') or None assigned_to_id = request.POST.get('assigned_to_id') or None if not ids: context_extra['equipment_form_error'] = 'Відмітьте хоча б один екземпляр.' elif not location_id and not assigned_to_id: context_extra['equipment_form_error'] = 'Оберіть локацію або співробітника для видачі.' else: assigned_status_id = _resolve_directory('equipment_status', 'assigned') units = Equipment.objects.filter(pk__in=ids) count = 0 for unit in units: # закриваємо попередню активну видачу, якщо була if unit.assigned_to_id: EquipmentAssignment.objects.filter( equipment=unit, assigned_to_id=unit.assigned_to_id, returned_at__isnull=True, ).update(returned_at=timezone.now()) if location_id: unit.location_id = location_id if assigned_to_id: unit.assigned_to_id = assigned_to_id if assigned_status_id: unit.status_id = assigned_status_id unit.save() EquipmentAssignment.objects.create( equipment=unit, assigned_to_id=assigned_to_id, location_id=location_id, assigned_by=request.user, ) count += 1 context_extra['equipment_form_message'] = f'Видано {count} од. обладнання' context = _it_manager_context(request) context['it_active_tab'] = 'equipment' context.update(context_extra) return render_page(request, 'it-manager.html', context) @login_required def equipment_update_view(request, pk): if not _is_it_staff(request.user): return HttpResponseForbidden() if request.method == 'POST': equipment = get_object_or_404(Equipment, pk=pk) new_status_id = _resolve_directory('equipment_status', request.POST.get('status')) if new_status_id: equipment.status_id = new_status_id new_assigned_to = request.POST.get('assigned_to_id') or None if str(equipment.assigned_to_id or '') != str(new_assigned_to or ''): if equipment.assigned_to_id: EquipmentAssignment.objects.filter( equipment=equipment, assigned_to_id=equipment.assigned_to_id, returned_at__isnull=True, ).update(returned_at=timezone.now()) equipment.assigned_to_id = new_assigned_to if new_assigned_to: EquipmentAssignment.objects.create( equipment=equipment, assigned_to_id=new_assigned_to, location=equipment.location, assigned_by=request.user, ) equipment.save() context = _it_manager_context(request) context['it_active_tab'] = 'equipment' return render_page(request, 'it-manager.html', context) @login_required def ticket_update_view(request, pk): if not _is_it_staff(request.user): return HttpResponseForbidden() if request.method == 'POST': ticket = get_object_or_404(Ticket, pk=pk) new_status_id = _resolve_directory('ticket_status', request.POST.get('status')) if new_status_id: ticket.status_id = new_status_id ticket.assignee_id = request.POST.get('assignee_id') or None ticket.save() context = _it_manager_context(request) context['it_active_tab'] = 'tickets' return render_page(request, 'it-manager.html', context) @login_required def ticket_comment_view(request, pk): if not _is_it_staff(request.user): return HttpResponseForbidden() if request.method == 'POST': ticket = get_object_or_404(Ticket, pk=pk) text = request.POST.get('text', '').strip() if text: TicketComment.objects.create( ticket=ticket, author=request.user, text=text, is_internal=bool(request.POST.get('is_internal')), ) context = _it_manager_context(request) context['it_active_tab'] = 'tickets' return render_page(request, 'it-manager.html', context) @login_required def worklog_create_view(request): if not _is_it_staff(request.user): return HttpResponseForbidden() if request.method == 'POST': description = request.POST.get('description', '').strip() work_type_id = _resolve_directory('worklog_type', request.POST.get('work_type', '')) if description and work_type_id: try: log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date() except ValueError: log_date = timezone.localdate() try: hours_spent = float(request.POST.get('hours_spent', '').strip() or 0) or None except ValueError: hours_spent = None WorkLog.objects.create( date=log_date, technician=request.user, work_type_id=work_type_id, location_id=request.POST.get('location_id') or None, equipment_id=request.POST.get('equipment_id') or None, ticket_id=request.POST.get('ticket_id') or None, description=description, result=request.POST.get('result', '').strip(), hours_spent=hours_spent, ) context = _it_manager_context(request) context['it_active_tab'] = 'worklog' return render_page(request, 'it-manager.html', context) @login_required def consumable_create_view(request): if not _is_it_staff(request.user): return HttpResponseForbidden() if request.method == 'POST': model_number = request.POST.get('model_number', '').strip() consumable_type_id = _resolve_directory('consumable_type', request.POST.get('consumable_type', '')) color_id = _resolve_directory('consumable_color', request.POST.get('color', '')) if model_number and consumable_type_id and color_id: Consumable.objects.create( consumable_type_id=consumable_type_id, brand=request.POST.get('brand', '').strip(), model_number=model_number, color_id=color_id, equipment_id=request.POST.get('equipment_id') or None, location_id=request.POST.get('location_id') or None, page_yield=request.POST.get('page_yield') or None, purchase_date=request.POST.get('purchase_date') or None, notes=request.POST.get('notes', '').strip(), ) context = _it_manager_context(request) context['it_active_tab'] = 'consumables' return render_page(request, 'it-manager.html', context) @login_required def consumable_update_view(request, pk): if not _is_it_staff(request.user): return HttpResponseForbidden() if request.method == 'POST': consumable = get_object_or_404(Consumable, pk=pk) new_status_code = request.POST.get('status') new_status_id = _resolve_directory('consumable_status', new_status_code) if new_status_id: consumable.status_id = new_status_id if new_status_code == 'installed' and not consumable.installed_date: consumable.installed_date = timezone.localdate() consumable.save() context = _it_manager_context(request) context['it_active_tab'] = 'consumables' return render_page(request, 'it-manager.html', context) @login_required def provider_create_view(request): if not _is_it_staff(request.user): return HttpResponseForbidden() if request.method == 'POST': name = request.POST.get('name', '').strip() if name: InternetProvider.objects.get_or_create( name=name, defaults={ 'phone': request.POST.get('phone', '').strip(), 'email': request.POST.get('email', '').strip(), 'website': request.POST.get('website', '').strip(), 'support_phone': request.POST.get('support_phone', '').strip(), 'support_email': request.POST.get('support_email', '').strip(), 'notes': request.POST.get('notes', '').strip(), }, ) context = _it_manager_context(request) context['it_active_tab'] = 'providers' return render_page(request, 'it-manager.html', context) @login_required def contract_create_view(request): if not _is_it_staff(request.user): return HttpResponseForbidden() if request.method == 'POST': provider_id = request.POST.get('provider_id') or None location_id = request.POST.get('location_id') or None contract_number = request.POST.get('contract_number', '').strip() start_date = request.POST.get('start_date') or None service_type_id = _resolve_directory('contract_service_type', request.POST.get('service_type', '')) if provider_id and location_id and contract_number and start_date and service_type_id: InternetContract.objects.create( provider_id=provider_id, location_id=location_id, contract_number=contract_number, service_type_id=service_type_id, connection_type_id=_resolve_directory('contract_connection_type', request.POST.get('connection_type', '')), start_date=start_date, end_date=request.POST.get('end_date') or None, monthly_cost=request.POST.get('monthly_cost') or None, bandwidth_down=request.POST.get('bandwidth_down') or None, bandwidth_up=request.POST.get('bandwidth_up') or None, account_number=request.POST.get('account_number', '').strip(), static_ips=request.POST.get('static_ips', '').strip() if request.POST.get('dedicated_ip') else '', notes=request.POST.get('notes', '').strip(), ) context = _it_manager_context(request) context['it_active_tab'] = 'providers' return render_page(request, 'it-manager.html', context) @login_required def contract_update_view(request, pk): if not _is_it_staff(request.user): return HttpResponseForbidden() if request.method == 'POST': contract = get_object_or_404(InternetContract, pk=pk) new_status_id = _resolve_directory('contract_status', request.POST.get('status')) if new_status_id: contract.status_id = new_status_id contract.save() context = _it_manager_context(request) context['it_active_tab'] = 'providers' return render_page(request, 'it-manager.html', context) @login_required def servicecall_create_view(request): if not _is_it_staff(request.user): return HttpResponseForbidden() if request.method == 'POST': caller_id = request.POST.get('caller_id') or None description = request.POST.get('description', '').strip() channel_id = _resolve_directory('servicecall_channel', request.POST.get('channel', '')) if caller_id and description and channel_id: ServiceCall.objects.create( caller_id=caller_id, handler=request.user, channel_id=channel_id, location_id=request.POST.get('location_id') or None, description=description, resolution=request.POST.get('resolution', '').strip(), minutes_spent=request.POST.get('minutes_spent') or None, ticket_id=request.POST.get('ticket_id') or None, ) context = _it_manager_context(request) context['it_active_tab'] = 'servicecalls' return render_page(request, 'it-manager.html', context)