from django import forms
from django.contrib import admin
from django.core.exceptions import ValidationError
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, EquipmentModel,
Ticket, TicketComment,
InternetProvider, InternetContract, InternetContractPayment,
WorkLog, ServiceCall,
Consumable,
)
class LocationChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
return str(obj)
class InternetContractForm(forms.ModelForm):
location = LocationChoiceField(
queryset=None,
required=False,
label=_('Локация'),
)
def clean(self):
cleaned = super().clean()
is_framework = cleaned.get('is_framework')
location = cleaned.get('location')
if is_framework and location:
raise ValidationError({'location': _('У рамочного договора локация не указывается.')})
if not is_framework and not location:
raise ValidationError({'location': _('Укажите локацию для договора.')})
if is_framework:
cleaned['location'] = None
return cleaned
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(EquipmentModel)
class EquipmentModelAdmin(ModelAdmin):
list_display = ['name', 'equipment_type', 'brand', 'units_count', 'warranty_months', 'updated_at']
list_filter = ['equipment_type', 'brand']
search_fields = ['name', 'brand__name', 'notes']
list_select_related = ['equipment_type', 'brand']
ordering = ['equipment_type__name', 'brand__name', 'name']
def units_count(self, obj):
return obj.units.count()
units_count.short_description = _('Экземпляров')
@admin.register(Equipment)
class EquipmentAdmin(ModelAdmin):
list_display = [
'type_display', 'model_display', 'serial_number',
'status_display', 'assigned_to', 'location',
'warranty_display', 'updated_at',
]
list_filter = ['equipment_model__equipment_type', 'status', 'location__location_type', 'equipment_model__brand']
search_fields = ['equipment_model__name', 'equipment_model__brand__name', 'serial_number', 'inventory_number', 'notes']
list_select_related = ['assigned_to', 'location', 'equipment_model__brand', 'equipment_model__equipment_type', 'status']
autocomplete_fields = ['equipment_model']
ordering = ['equipment_model__equipment_type__name', 'equipment_model__name']
readonly_fields = ['created_at', 'updated_at', 'warranty_display']
fieldsets = (
(_('Оборудование'), {
'fields': ('equipment_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_model.equipment_type.code 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_model.equipment_type.code, '📦')
return format_html('{} {}', icon, obj.equipment_model.equipment_type.name)
type_display.short_description = _('Тип')
type_display.admin_order_field = 'equipment_model__equipment_type__name'
def model_display(self, obj):
return str(obj.equipment_model)
model_display.short_description = _('Модель')
model_display.admin_order_field = 'equipment_model__name'
def status_display(self, obj):
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
return format_html(
'{}',
color, obj.status.name
)
status_display.short_description = _('Статус')
status_display.admin_order_field = 'status__name'
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__equipment_model__brand__name', 'equipment__equipment_model__name']
list_select_related = ['equipment', 'location', 'consumable_type', 'color', 'status']
ordering = ['consumable_type__name', 'color__name', '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.consumable_type.name
consumable_type_display.short_description = _('Тип')
consumable_type_display.admin_order_field = 'consumable_type__name'
def color_display(self, obj):
bg = self.COLOR_CIRCLES.get(obj.color.code, '#9ca3af')
style = f'background:{bg}' if 'gradient' not in bg else f'background-image:{bg}'
return format_html(
''
'{}'
'',
style, obj.color.name,
)
color_display.short_description = _('Цвет')
color_display.admin_order_field = 'color__name'
def status_display(self, obj):
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
return format_html(
'{}',
color, obj.status.name,
)
status_display.short_description = _('Статус')
status_display.admin_order_field = 'status__name'
# ---------------------------------------------------------------------------
# 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', 'ticket_type', 'priority', 'status']
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.ticket_type.name
ticket_type_display.short_description = _('Тип')
ticket_type_display.admin_order_field = 'ticket_type__name'
def priority_display(self, obj):
color = self.PRIORITY_COLORS.get(obj.priority.code, '#6b7280')
return format_html(
'{}',
color, obj.priority.name
)
priority_display.short_description = _('Приоритет')
priority_display.admin_order_field = 'priority__name'
def status_display(self, obj):
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
return format_html(
'{}',
color, obj.status.name
)
status_display.short_description = _('Статус')
status_display.admin_order_field = 'status__name'
def overdue_display(self, obj):
if obj.is_overdue:
return mark_safe('⚠ Просрочена')
return ''
overdue_display.short_description = _('SLA')
@staticmethod
def _ticket_status_directory(code):
from core.models.directories import Directory
return Directory.objects.get(directory_type__code='ticket_status', code=code)
@admin.action(description=_('Взять в работу'))
def mark_in_progress(self, request, queryset):
new_status = self._ticket_status_directory('new')
in_progress_status = self._ticket_status_directory('in_progress')
updated = queryset.filter(status=new_status).update(
status=in_progress_status, assignee=request.user
)
self.message_user(request, f'Взято в работу: {updated}')
@admin.action(description=_('Отметить решёнными'))
def mark_resolved(self, request, queryset):
resolved_status = self._ticket_status_directory('resolved')
for ticket in queryset.exclude(status__code__in=['resolved', 'closed']):
ticket.status = resolved_status
ticket.save()
self.message_user(request, f'Отмечено решёнными: {queryset.count()}')
@admin.action(description=_('Закрыть'))
def mark_closed(self, request, queryset):
closed_status = self._ticket_status_directory('closed')
for ticket in queryset.exclude(status__code='closed'):
ticket.status = closed_status
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
form = InternetContractForm
extra = 0
fields = ('location', 'contract_number', 'is_framework', 'parent_contract', 'service_type', 'status',
'start_date', 'bandwidth_down', 'bandwidth_up', 'monthly_cost', 'end_date')
show_change_link = True
class InternetContractPaymentInline(TabularInline):
model = InternetContractPayment
extra = 0
fields = ('period', 'amount', 'is_paid', 'paid_at', 'invoice_number', 'recorded_by')
readonly_fields = ('recorded_by',)
@admin.register(InternetProvider)
class InternetProviderAdmin(ModelAdmin):
class Media:
js = ('assets/js/phone-format.js',)
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 mark_safe('0')
active_contracts_count.short_description = _('Активных договоров')
@admin.register(InternetContract)
class InternetContractAdmin(ModelAdmin):
form = InternetContractForm
inlines = [InternetContractPaymentInline]
list_display = [
'provider', 'location', 'contract_number', 'is_framework', 'parent_contract',
'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', 'service_type', 'connection_type', 'status']
ordering = ['location', 'provider']
readonly_fields = ['created_at', 'updated_at', 'ip_list_display']
fieldsets = (
(_('Договор'), {
'fields': ('provider', 'contract_number', 'account_number',
'is_framework', 'parent_contract',
'service_type', 'connection_type', 'status'),
}),
(_('Локация'), {
'fields': ('location',),
'description': _('Для рамочного договора локация не указывается.'),
}),
(_('Параметры'), {
'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.code, '#6b7280')
return format_html(
'{}',
color, obj.status.name
)
status_display.short_description = _('Статус')
status_display.admin_order_field = 'status__name'
def service_type_display(self, obj):
return obj.service_type.name
service_type_display.short_description = _('Услуга')
service_type_display.admin_order_field = 'service_type__name'
def connection_type_display(self, obj):
return obj.connection_type.name if obj.connection_type else '—'
connection_type_display.short_description = _('Технология')
connection_type_display.admin_order_field = 'connection_type__name'
def expiry_display(self, obj):
if not obj.end_date:
return '—'
if obj.status.code != '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', 'catalog_display', 'location',
'hours_spent', 'ticket', 'created_at']
list_filter = ['catalog_work__parent', 'work_type', 'technician', 'date']
search_fields = ['description', 'result', 'catalog_work__name',
'technician__last_name', 'technician__first_name']
list_select_related = [
'technician', 'location', 'ticket', 'equipment',
'work_type', 'catalog_work', 'catalog_work__parent',
]
ordering = ['-date', '-created_at']
date_hierarchy = 'date'
raw_id_fields = ['catalog_work']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
(_('Работа'), {
'fields': ('date', 'technician', 'catalog_work', 'details', 'location'),
}),
(_('Устарело / комментарий'), {
'fields': ('work_type', 'description', 'result', 'hours_spent'),
}),
(_('Связи'), {
'fields': ('equipment', 'ticket'),
}),
(_('Служебное'), {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',),
}),
)
def catalog_display(self, obj):
if obj.catalog_work_id:
cat = obj.catalog_work.parent.name if obj.catalog_work.parent_id else ''
label = obj.catalog_work.name
if cat:
return f'{cat}: {label}'
return label
if obj.work_type_id:
return obj.work_type.name
return (obj.description or '—')[:60]
catalog_display.short_description = _('Типовая работа')
catalog_display.admin_order_field = 'catalog_work__name'
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', 'channel']
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.code, '📋')
return format_html('{} {}', icon, obj.channel.name)
channel_display.short_description = _('Канал')
channel_display.admin_order_field = 'channel__name'
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)