from django import forms from django.contrib import admin from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from unfold.admin import ModelAdmin, TabularInline from .models import ( Equipment, EquipmentAssignment, Ticket, TicketComment, InternetProvider, InternetContract, WorkLog, ServiceCall, Consumable, ) class LocationChoiceField(forms.ModelChoiceField): def label_from_instance(self, obj): type_label = obj.get_location_type_display() addr = obj.get_full_address() or obj.address or '' if obj.location_type == 'pharmacy': try: num = obj.pharmacy.pharmacy_number return f'Аптека №{num} — {addr}' except Exception: pass return f'{type_label} — {addr}' if addr else type_label class InternetContractForm(forms.ModelForm): location = LocationChoiceField( queryset=None, label=_('Локация'), ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) from apps.locations.models import Location self.fields['location'].queryset = ( Location.objects .filter(is_active=True) .select_related('location_region') .prefetch_related('pharmacy') .order_by('location_type', 'address') ) class Meta: model = InternetContract fields = '__all__' # --------------------------------------------------------------------------- # Equipment # --------------------------------------------------------------------------- class EquipmentAssignmentInline(TabularInline): model = EquipmentAssignment extra = 0 fields = ('assigned_to', 'location', 'assigned_by', 'assigned_at', 'returned_at', 'notes') readonly_fields = ('assigned_at',) ordering = ('-assigned_at',) def has_delete_permission(self, request, obj=None): return False class ConsumableInline(TabularInline): model = Consumable extra = 0 fields = ('consumable_type', 'brand', 'model_number', 'color', 'status', 'page_yield', 'installed_date', 'replaced_date') ordering = ('color',) verbose_name = 'Расходник' verbose_name_plural = 'Расходники (картриджи, тонеры)' def get_queryset(self, request): return super().get_queryset(request) @admin.register(Equipment) class EquipmentAdmin(ModelAdmin): list_display = [ 'type_display', 'brand', 'model', 'serial_number', 'status_display', 'assigned_to', 'location', 'warranty_display', 'updated_at', ] list_filter = ['equipment_type', 'status', 'location__location_type', 'brand'] search_fields = ['brand__name', 'model', 'serial_number', 'inventory_number', 'notes'] list_select_related = ['assigned_to', 'location', 'brand'] ordering = ['equipment_type', 'brand__name', 'model'] readonly_fields = ['created_at', 'updated_at', 'warranty_display'] fieldsets = ( (_('Оборудование'), { 'fields': ('equipment_type', 'brand', 'model', 'serial_number', 'inventory_number'), }), (_('Статус и размещение'), { 'fields': ('status', 'assigned_to', 'location'), }), (_('Гарантия и закупка'), { 'fields': ('purchase_date', 'warranty_until', 'warranty_display'), }), (_('Примечания'), { 'fields': ('notes',), 'classes': ('collapse',), }), (_('Служебное'), { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',), }), ) inlines = [EquipmentAssignmentInline, ConsumableInline] def get_inlines(self, request, obj): inlines = [EquipmentAssignmentInline] if obj and obj.equipment_type in ('printer', 'scanner'): inlines.append(ConsumableInline) return inlines EQUIPMENT_ICONS = { 'laptop': '💻', 'desktop': '🖥️', 'monitor': '🖥️', 'printer': '🖨️', 'phone': '📱', 'tablet': '📟', 'router': '📡', 'ups': '🔋', 'scanner': '📠', 'other': '📦', } STATUS_COLORS = { 'stock': '#6b7280', 'assigned': '#16a34a', 'repair': '#ca8a04', 'written_off': '#dc2626', } def type_display(self, obj): icon = self.EQUIPMENT_ICONS.get(obj.equipment_type, '📦') return format_html('{} {}', icon, obj.get_equipment_type_display()) type_display.short_description = _('Тип') type_display.admin_order_field = 'equipment_type' def status_display(self, obj): color = self.STATUS_COLORS.get(obj.status, '#6b7280') return format_html( '{}', color, obj.get_status_display() ) status_display.short_description = _('Статус') status_display.admin_order_field = 'status' def warranty_display(self, obj): status = obj.warranty_status if status == 'Действительна': return format_html('{}', status) if 'Истекает' in status: return format_html('{}', status) if status == 'Истекла': return format_html('{}', status) return status warranty_display.short_description = _('Гарантия') # --------------------------------------------------------------------------- # Consumables # --------------------------------------------------------------------------- @admin.register(Consumable) class ConsumableAdmin(ModelAdmin): list_display = [ 'consumable_type_display', 'brand', 'model_number', 'color_display', 'status_display', 'equipment', 'location', 'page_yield', 'installed_date', ] list_filter = ['consumable_type', 'status', 'color'] search_fields = ['brand', 'model_number', 'notes', 'equipment__brand__name', 'equipment__model'] list_select_related = ['equipment', 'location'] ordering = ['consumable_type', 'color', 'brand'] readonly_fields = ['created_at', 'updated_at'] fieldsets = ( (_('Расходник'), { 'fields': ('consumable_type', 'brand', 'model_number', 'color'), }), (_('Статус и размещение'), { 'fields': ('status', 'equipment', 'location'), }), (_('Эксплуатация'), { 'fields': ('page_yield', 'purchase_date', 'installed_date', 'replaced_date'), }), (_('Примечания'), { 'fields': ('notes',), 'classes': ('collapse',), }), (_('Служебное'), { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',), }), ) COLOR_CIRCLES = { 'black': '#1f2937', 'cyan': '#0891b2', 'magenta': '#db2777', 'yellow': '#ca8a04', 'color': 'linear-gradient(135deg,#0891b2,#db2777,#ca8a04)', 'na': '#9ca3af', } STATUS_COLORS = { 'in_stock': '#16a34a', 'installed': '#2563eb', 'empty': '#dc2626', 'ordered': '#ca8a04', 'disposed': '#9ca3af', } def consumable_type_display(self, obj): return obj.get_consumable_type_display() consumable_type_display.short_description = _('Тип') consumable_type_display.admin_order_field = 'consumable_type' def color_display(self, obj): bg = self.COLOR_CIRCLES.get(obj.color, '#9ca3af') style = f'background:{bg}' if 'gradient' not in bg else f'background-image:{bg}' return format_html( '' '{}' '', style, obj.get_color_display(), ) color_display.short_description = _('Цвет') color_display.admin_order_field = 'color' def status_display(self, obj): color = self.STATUS_COLORS.get(obj.status, '#6b7280') return format_html( '{}', color, obj.get_status_display(), ) status_display.short_description = _('Статус') status_display.admin_order_field = 'status' # --------------------------------------------------------------------------- # Tickets # --------------------------------------------------------------------------- class TicketCommentInline(TabularInline): model = TicketComment extra = 1 fields = ('author', 'text', 'is_internal', 'created_at') readonly_fields = ('created_at',) ordering = ('created_at',) @admin.register(Ticket) class TicketAdmin(ModelAdmin): list_display = [ 'number', 'title', 'ticket_type_display', 'priority_display', 'status_display', 'requester', 'assignee', 'created_at', 'overdue_display', ] list_filter = ['status', 'priority', 'ticket_type', 'assignee'] search_fields = ['number', 'title', 'description', 'requester__last_name', 'requester__first_name'] list_select_related = ['requester', 'assignee', 'equipment', 'location'] ordering = ['-created_at'] date_hierarchy = 'created_at' readonly_fields = ['number', 'created_at', 'updated_at', 'resolved_at', 'closed_at'] fieldsets = ( (_('Заявка'), { 'fields': ('number', 'title', 'ticket_type', 'priority', 'status'), }), (_('Участники'), { 'fields': ('requester', 'assignee'), }), (_('Связи'), { 'fields': ('equipment', 'location'), }), (_('Описание и решение'), { 'fields': ('description', 'resolution'), }), (_('Даты'), { 'fields': ('created_at', 'updated_at', 'resolved_at', 'closed_at'), 'classes': ('collapse',), }), ) inlines = [TicketCommentInline] actions = ['mark_in_progress', 'mark_resolved', 'mark_closed'] PRIORITY_COLORS = { 'low': '#6b7280', 'medium': '#2563eb', 'high': '#ca8a04', 'critical': '#dc2626', } STATUS_COLORS = { 'new': '#2563eb', 'in_progress': '#ca8a04', 'waiting': '#9333ea', 'resolved': '#16a34a', 'closed': '#6b7280', } def ticket_type_display(self, obj): return obj.get_ticket_type_display() ticket_type_display.short_description = _('Тип') ticket_type_display.admin_order_field = 'ticket_type' def priority_display(self, obj): color = self.PRIORITY_COLORS.get(obj.priority, '#6b7280') return format_html( '{}', color, obj.get_priority_display() ) priority_display.short_description = _('Приоритет') priority_display.admin_order_field = 'priority' def status_display(self, obj): color = self.STATUS_COLORS.get(obj.status, '#6b7280') return format_html( '{}', color, obj.get_status_display() ) status_display.short_description = _('Статус') status_display.admin_order_field = 'status' def overdue_display(self, obj): if obj.is_overdue: return format_html('⚠ Просрочена') return '' overdue_display.short_description = _('SLA') @admin.action(description=_('Взять в работу')) def mark_in_progress(self, request, queryset): updated = queryset.filter(status='new').update( status='in_progress', assignee=request.user ) self.message_user(request, f'Взято в работу: {updated}') @admin.action(description=_('Отметить решёнными')) def mark_resolved(self, request, queryset): from django.utils import timezone for ticket in queryset.exclude(status__in=['resolved', 'closed']): ticket.status = 'resolved' ticket.save() self.message_user(request, f'Отмечено решёнными: {queryset.count()}') @admin.action(description=_('Закрыть')) def mark_closed(self, request, queryset): for ticket in queryset.exclude(status='closed'): ticket.status = 'closed' ticket.save() self.message_user(request, f'Закрыто: {queryset.count()}') def save_model(self, request, obj, form, change): if not change: obj.requester = obj.requester or request.user super().save_model(request, obj, form, change) # --------------------------------------------------------------------------- # Internet Providers & Contracts # --------------------------------------------------------------------------- class InternetContractInline(TabularInline): model = InternetContract extra = 0 fields = ('location', 'contract_number', 'service_type', 'status', 'bandwidth_down', 'bandwidth_up', 'monthly_cost', 'end_date') show_change_link = True @admin.register(InternetProvider) class InternetProviderAdmin(ModelAdmin): list_display = ['name', 'phone', 'support_phone', 'email', 'active_contracts_count', 'is_active'] list_filter = ['is_active'] search_fields = ['name', 'phone', 'email', 'support_phone'] ordering = ['name'] fieldsets = ( (_('Провайдер'), { 'fields': ('name', 'is_active'), }), (_('Контакты'), { 'fields': ('phone', 'email', 'website'), }), (_('Техподдержка'), { 'fields': ('support_phone', 'support_email'), }), (_('Примечания'), { 'fields': ('notes',), 'classes': ('collapse',), }), ) inlines = [InternetContractInline] def active_contracts_count(self, obj): count = obj.active_contracts_count if count: return format_html('{}', count) return format_html('0') active_contracts_count.short_description = _('Активных договоров') @admin.register(InternetContract) class InternetContractAdmin(ModelAdmin): form = InternetContractForm list_display = [ 'provider', 'location', 'contract_number', 'service_type_display', 'connection_type_display', 'status_display', 'bandwidth_display', 'monthly_cost', 'end_date', 'expiry_display', ] list_filter = ['status', 'service_type', 'connection_type', 'provider', 'location__location_type'] search_fields = ['contract_number', 'account_number', 'static_ips', 'provider__name', 'location__address'] list_select_related = ['provider', 'location'] ordering = ['location', 'provider'] readonly_fields = ['created_at', 'updated_at', 'ip_list_display'] fieldsets = ( (_('Договор'), { 'fields': ('provider', 'contract_number', 'account_number', 'service_type', 'connection_type', 'status'), }), (_('Локация'), { 'fields': ('location',), }), (_('Параметры'), { 'fields': ('bandwidth_down', 'bandwidth_up', 'monthly_cost'), }), (_('Сроки'), { 'fields': ('start_date', 'end_date'), }), (_('IP-адреса'), { 'fields': ('static_ips', 'ip_list_display'), }), (_('Примечания'), { 'fields': ('notes',), 'classes': ('collapse',), }), (_('Служебное'), { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',), }), ) STATUS_COLORS = { 'active': '#16a34a', 'suspended': '#ca8a04', 'terminated': '#dc2626', } def status_display(self, obj): color = self.STATUS_COLORS.get(obj.status, '#6b7280') return format_html( '{}', color, obj.get_status_display() ) status_display.short_description = _('Статус') status_display.admin_order_field = 'status' def service_type_display(self, obj): return obj.get_service_type_display() service_type_display.short_description = _('Услуга') service_type_display.admin_order_field = 'service_type' def connection_type_display(self, obj): return obj.get_connection_type_display() if obj.connection_type else '—' connection_type_display.short_description = _('Технология') connection_type_display.admin_order_field = 'connection_type' def expiry_display(self, obj): if not obj.end_date: return '—' if obj.status != 'active': return str(obj.end_date) if obj.is_expiring_soon: days = (obj.end_date - timezone.now().date()).days return format_html('⚠ {} дн.', days) return str(obj.end_date) expiry_display.short_description = _('Окончание') expiry_display.admin_order_field = 'end_date' def ip_list_display(self, obj): ips = obj.static_ip_list if not ips: return '—' return mark_safe('
'.join( format_html( '{}', ip ) for ip in ips )) ip_list_display.short_description = _('Статические IP') # --------------------------------------------------------------------------- # Work Logs & Service Calls # --------------------------------------------------------------------------- class LocationChoiceFieldSimple(forms.ModelChoiceField): def label_from_instance(self, obj): type_label = obj.get_location_type_display() addr = obj.get_full_address() or obj.address or '' if obj.location_type == 'pharmacy': try: num = obj.pharmacy.pharmacy_number return f'Аптека №{num} — {addr}' except Exception: pass return f'{type_label} — {addr}' if addr else type_label def _location_queryset(): from apps.locations.models import Location return ( Location.objects .filter(is_active=True) .select_related('location_region') .prefetch_related('pharmacy') .order_by('location_type', 'address') ) class WorkLogForm(forms.ModelForm): location = LocationChoiceFieldSimple( queryset=None, required=False, label=_('Локация'), ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['location'].queryset = _location_queryset() class Meta: model = WorkLog fields = '__all__' class ServiceCallForm(forms.ModelForm): location = LocationChoiceFieldSimple( queryset=None, required=False, label=_('Локация'), ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['location'].queryset = _location_queryset() class Meta: model = ServiceCall fields = '__all__' @admin.register(WorkLog) class WorkLogAdmin(ModelAdmin): form = WorkLogForm list_display = ['date', 'technician', 'work_type_display', 'location', 'hours_spent', 'ticket', 'created_at'] list_filter = ['work_type', 'technician', 'date'] search_fields = ['description', 'result', 'technician__last_name', 'technician__first_name'] list_select_related = ['technician', 'location', 'ticket', 'equipment'] ordering = ['-date', '-created_at'] date_hierarchy = 'date' readonly_fields = ['created_at', 'updated_at'] fieldsets = ( (_('Работа'), { 'fields': ('date', 'technician', 'work_type', 'location'), }), (_('Описание'), { 'fields': ('description', 'result', 'hours_spent'), }), (_('Связи'), { 'fields': ('equipment', 'ticket'), }), (_('Служебное'), { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',), }), ) WORK_TYPE_COLORS = { 'maintenance': '#2563eb', 'installation': '#16a34a', 'repair': '#ca8a04', 'network': '#9333ea', 'software': '#0891b2', 'backup': '#6b7280', 'other': '#6b7280', } def work_type_display(self, obj): color = self.WORK_TYPE_COLORS.get(obj.work_type, '#6b7280') return format_html( '{}', color, obj.get_work_type_display() ) work_type_display.short_description = _('Вид работ') work_type_display.admin_order_field = 'work_type' def save_model(self, request, obj, form, change): if not change and not obj.technician_id: obj.technician = request.user super().save_model(request, obj, form, change) @admin.register(ServiceCall) class ServiceCallAdmin(ModelAdmin): form = ServiceCallForm list_display = ['date_time', 'caller', 'handler', 'channel_display', 'location', 'duration_display', 'ticket'] list_filter = ['channel', 'handler', 'date_time'] search_fields = ['description', 'resolution', 'caller__last_name', 'caller__first_name', 'handler__last_name', 'handler__first_name'] list_select_related = ['caller', 'handler', 'location', 'ticket'] ordering = ['-date_time'] date_hierarchy = 'date_time' readonly_fields = ['created_at', 'duration_display'] fieldsets = ( (_('Обращение'), { 'fields': ('date_time', 'channel', 'caller', 'handler', 'location'), }), (_('Содержание'), { 'fields': ('description', 'resolution', 'minutes_spent', 'duration_display'), }), (_('Связи'), { 'fields': ('ticket',), }), (_('Служебное'), { 'fields': ('created_at',), 'classes': ('collapse',), }), ) CHANNEL_ICONS = { 'phone': '📞', 'chat': '💬', 'email': '📧', 'personal': '🚶', 'other': '📋', } def channel_display(self, obj): icon = self.CHANNEL_ICONS.get(obj.channel, '📋') return format_html('{} {}', icon, obj.get_channel_display()) channel_display.short_description = _('Канал') channel_display.admin_order_field = 'channel' def duration_display(self, obj): return obj.duration_display duration_display.short_description = _('Длительность') def save_model(self, request, obj, form, change): if not change and not obj.handler_id: obj.handler = request.user super().save_model(request, obj, form, change)