IT-менеджер: каталог моделей оборудования + перевод choices на справочники
- EquipmentModel (каталог: тип+бренд+модель заводятся раз) отделён от Equipment (экземпляр: серийник+локация); миграция сворачивает данные. Массовый ввод серийников, массовая выдача на локации/сотрудников - Enum-choices (тип/статус/приоритет заявок, вид работ, каналы, расходники, договоры) переведены на core.Directory с quick-add из формы - Фронт-страница /it-manager/: вкладки оборудование/заявки/работы/ расходники/провайдеры/обращения; searchable-select локаций - Тесты (10) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+87
-60
@@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
|
||||
from .models import (
|
||||
Equipment, EquipmentAssignment,
|
||||
Equipment, EquipmentAssignment, EquipmentModel,
|
||||
Ticket, TicketComment,
|
||||
InternetProvider, InternetContract,
|
||||
WorkLog, ServiceCall,
|
||||
@@ -78,23 +78,37 @@ class ConsumableInline(TabularInline):
|
||||
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', 'brand', 'model', 'serial_number',
|
||||
'type_display', 'model_display', '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']
|
||||
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_type', 'brand', 'model', 'serial_number', 'inventory_number'),
|
||||
'fields': ('equipment_model', 'serial_number', 'inventory_number'),
|
||||
}),
|
||||
(_('Статус и размещение'), {
|
||||
'fields': ('status', 'assigned_to', 'location'),
|
||||
@@ -116,7 +130,7 @@ class EquipmentAdmin(ModelAdmin):
|
||||
|
||||
def get_inlines(self, request, obj):
|
||||
inlines = [EquipmentAssignmentInline]
|
||||
if obj and obj.equipment_type in ('printer', 'scanner'):
|
||||
if obj and obj.equipment_model.equipment_type.code in ('printer', 'scanner'):
|
||||
inlines.append(ConsumableInline)
|
||||
return inlines
|
||||
|
||||
@@ -135,19 +149,24 @@ class EquipmentAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def type_display(self, obj):
|
||||
icon = self.EQUIPMENT_ICONS.get(obj.equipment_type, '📦')
|
||||
return format_html('{} {}', icon, obj.get_equipment_type_display())
|
||||
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_type'
|
||||
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, '#6b7280')
|
||||
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display()
|
||||
color, obj.status.name
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
status_display.admin_order_field = 'status__name'
|
||||
|
||||
def warranty_display(self, obj):
|
||||
status = obj.warranty_status
|
||||
@@ -174,9 +193,9 @@ class ConsumableAdmin(ModelAdmin):
|
||||
]
|
||||
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']
|
||||
'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']
|
||||
|
||||
@@ -218,31 +237,31 @@ class ConsumableAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def consumable_type_display(self, obj):
|
||||
return obj.get_consumable_type_display()
|
||||
return obj.consumable_type.name
|
||||
consumable_type_display.short_description = _('Тип')
|
||||
consumable_type_display.admin_order_field = 'consumable_type'
|
||||
consumable_type_display.admin_order_field = 'consumable_type__name'
|
||||
|
||||
def color_display(self, obj):
|
||||
bg = self.COLOR_CIRCLES.get(obj.color, '#9ca3af')
|
||||
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(
|
||||
'<span style="display:inline-flex;align-items:center;gap:6px;">'
|
||||
'<span style="display:inline-block;width:12px;height:12px;'
|
||||
'border-radius:50%;{};border:1px solid #e5e7eb;"></span>{}'
|
||||
'</span>',
|
||||
style, obj.get_color_display(),
|
||||
style, obj.color.name,
|
||||
)
|
||||
color_display.short_description = _('Цвет')
|
||||
color_display.admin_order_field = 'color'
|
||||
color_display.admin_order_field = 'color__name'
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display(),
|
||||
color, obj.status.name,
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
status_display.admin_order_field = 'status__name'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -266,7 +285,7 @@ class TicketAdmin(ModelAdmin):
|
||||
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']
|
||||
list_select_related = ['requester', 'assignee', 'equipment', 'location', 'ticket_type', 'priority', 'status']
|
||||
ordering = ['-created_at']
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
@@ -311,53 +330,61 @@ class TicketAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def ticket_type_display(self, obj):
|
||||
return obj.get_ticket_type_display()
|
||||
return obj.ticket_type.name
|
||||
ticket_type_display.short_description = _('Тип')
|
||||
ticket_type_display.admin_order_field = 'ticket_type'
|
||||
ticket_type_display.admin_order_field = 'ticket_type__name'
|
||||
|
||||
def priority_display(self, obj):
|
||||
color = self.PRIORITY_COLORS.get(obj.priority, '#6b7280')
|
||||
color = self.PRIORITY_COLORS.get(obj.priority.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_priority_display()
|
||||
color, obj.priority.name
|
||||
)
|
||||
priority_display.short_description = _('Приоритет')
|
||||
priority_display.admin_order_field = 'priority'
|
||||
priority_display.admin_order_field = 'priority__name'
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display()
|
||||
color, obj.status.name
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
status_display.admin_order_field = 'status__name'
|
||||
|
||||
def overdue_display(self, obj):
|
||||
if obj.is_overdue:
|
||||
return format_html('<span style="color:#dc2626;font-weight:bold;">⚠ Просрочена</span>')
|
||||
return mark_safe('<span style="color:#dc2626;font-weight:bold;">⚠ Просрочена</span>')
|
||||
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):
|
||||
updated = queryset.filter(status='new').update(
|
||||
status='in_progress', assignee=request.user
|
||||
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):
|
||||
from django.utils import timezone
|
||||
for ticket in queryset.exclude(status__in=['resolved', 'closed']):
|
||||
ticket.status = 'resolved'
|
||||
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):
|
||||
for ticket in queryset.exclude(status='closed'):
|
||||
ticket.status = 'closed'
|
||||
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()}')
|
||||
|
||||
@@ -409,7 +436,7 @@ class InternetProviderAdmin(ModelAdmin):
|
||||
count = obj.active_contracts_count
|
||||
if count:
|
||||
return format_html('<span style="color:#16a34a;font-weight:bold;">{}</span>', count)
|
||||
return format_html('<span style="color:#9ca3af;">0</span>')
|
||||
return mark_safe('<span style="color:#9ca3af;">0</span>')
|
||||
active_contracts_count.short_description = _('Активных договоров')
|
||||
|
||||
|
||||
@@ -424,7 +451,7 @@ class InternetContractAdmin(ModelAdmin):
|
||||
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']
|
||||
list_select_related = ['provider', 'location', 'service_type', 'connection_type', 'status']
|
||||
ordering = ['location', 'provider']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at', 'ip_list_display']
|
||||
@@ -463,28 +490,28 @@ class InternetContractAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display()
|
||||
color, obj.status.name
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
status_display.admin_order_field = 'status__name'
|
||||
|
||||
def service_type_display(self, obj):
|
||||
return obj.get_service_type_display()
|
||||
return obj.service_type.name
|
||||
service_type_display.short_description = _('Услуга')
|
||||
service_type_display.admin_order_field = 'service_type'
|
||||
service_type_display.admin_order_field = 'service_type__name'
|
||||
|
||||
def connection_type_display(self, obj):
|
||||
return obj.get_connection_type_display() if obj.connection_type else '—'
|
||||
return obj.connection_type.name if obj.connection_type else '—'
|
||||
connection_type_display.short_description = _('Технология')
|
||||
connection_type_display.admin_order_field = 'connection_type'
|
||||
connection_type_display.admin_order_field = 'connection_type__name'
|
||||
|
||||
def expiry_display(self, obj):
|
||||
if not obj.end_date:
|
||||
return '—'
|
||||
if obj.status != 'active':
|
||||
if obj.status.code != 'active':
|
||||
return str(obj.end_date)
|
||||
if obj.is_expiring_soon:
|
||||
days = (obj.end_date - timezone.now().date()).days
|
||||
@@ -571,7 +598,7 @@ class WorkLogAdmin(ModelAdmin):
|
||||
list_filter = ['work_type', 'technician', 'date']
|
||||
search_fields = ['description', 'result', 'technician__last_name',
|
||||
'technician__first_name']
|
||||
list_select_related = ['technician', 'location', 'ticket', 'equipment']
|
||||
list_select_related = ['technician', 'location', 'ticket', 'equipment', 'work_type']
|
||||
ordering = ['-date', '-created_at']
|
||||
date_hierarchy = 'date'
|
||||
|
||||
@@ -604,13 +631,13 @@ class WorkLogAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def work_type_display(self, obj):
|
||||
color = self.WORK_TYPE_COLORS.get(obj.work_type, '#6b7280')
|
||||
color = self.WORK_TYPE_COLORS.get(obj.work_type.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_work_type_display()
|
||||
color, obj.work_type.name
|
||||
)
|
||||
work_type_display.short_description = _('Вид работ')
|
||||
work_type_display.admin_order_field = 'work_type'
|
||||
work_type_display.admin_order_field = 'work_type__name'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change and not obj.technician_id:
|
||||
@@ -627,7 +654,7 @@ class ServiceCallAdmin(ModelAdmin):
|
||||
search_fields = ['description', 'resolution',
|
||||
'caller__last_name', 'caller__first_name',
|
||||
'handler__last_name', 'handler__first_name']
|
||||
list_select_related = ['caller', 'handler', 'location', 'ticket']
|
||||
list_select_related = ['caller', 'handler', 'location', 'ticket', 'channel']
|
||||
ordering = ['-date_time']
|
||||
date_hierarchy = 'date_time'
|
||||
|
||||
@@ -658,10 +685,10 @@ class ServiceCallAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def channel_display(self, obj):
|
||||
icon = self.CHANNEL_ICONS.get(obj.channel, '📋')
|
||||
return format_html('{} {}', icon, obj.get_channel_display())
|
||||
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'
|
||||
channel_display.admin_order_field = 'channel__name'
|
||||
|
||||
def duration_display(self, obj):
|
||||
return obj.duration_display
|
||||
|
||||
Reference in New Issue
Block a user