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:
2026-07-22 17:31:52 +03:00
parent f342d67f97
commit 38747a93fc
23 changed files with 3051 additions and 229 deletions
+87 -60
View File
@@ -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
@@ -0,0 +1,33 @@
"""Data migration: створюємо auth.Group «IT» + PermissionGroup, за зразком
apps/employees/migrations/0004_create_role_groups.py."""
from django.db import migrations
GROUP_NAME = 'IT'
def create_group(apps, schema_editor):
Group = apps.get_model('auth', 'Group')
PermissionGroup = apps.get_model('employees', 'PermissionGroup')
group, _ = Group.objects.get_or_create(name=GROUP_NAME)
PermissionGroup.objects.get_or_create(
group=group,
defaults={'description': 'Співробітники IT-відділу: доступ до /it-manager/', 'is_active': True},
)
def delete_group(apps, schema_editor):
Group = apps.get_model('auth', 'Group')
Group.objects.filter(name=GROUP_NAME).delete()
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0006_equipment_brand_to_directory'),
('employees', '0004_create_role_groups'),
]
operations = [
migrations.RunPython(create_group, delete_group),
]
@@ -0,0 +1,93 @@
"""Equipment.equipment_type / Equipment.status: CharField(choices=) → FK core.Directory,
за зразком 0006_equipment_brand_to_directory.py. Directory.code дорівнює старому
буквальному значенню — уся логіка, що порівнює по коду, лишається робочою."""
import django.db.models.deletion
from django.db import migrations, models
EQUIPMENT_TYPES = [
('laptop', 'Ноутбук'),
('desktop', 'ПК'),
('monitor', 'Монитор'),
('printer', 'Принтер'),
('phone', 'Телефон'),
('tablet', 'Планшет'),
('router', 'Роутер/Свитч'),
('ups', 'ИБП'),
('scanner', 'Сканер'),
('other', 'Другое'),
]
STATUS_CHOICES = [
('stock', 'На складе'),
('assigned', 'Выдано'),
('repair', 'В ремонте'),
('written_off', 'Списано'),
]
def seed_directory(apps, type_code, type_name, choices):
DirectoryType = apps.get_model('core', 'DirectoryType')
Directory = apps.get_model('core', 'Directory')
directory_type, _ = DirectoryType.objects.get_or_create(
code=type_code, defaults={'name': type_name, 'hierarchy_type': 'strict'},
)
result = {}
for code, name in choices:
directory, _ = Directory.objects.get_or_create(
directory_type=directory_type, code=code, defaults={'name': name, 'is_active': True},
)
result[code] = directory
return result
def forwards(apps, schema_editor):
Equipment = apps.get_model('it_manager', 'Equipment')
types_by_code = seed_directory(apps, 'equipment_type', 'Тип обладнання', EQUIPMENT_TYPES)
statuses_by_code = seed_directory(apps, 'equipment_status', 'Статус обладнання', STATUS_CHOICES)
for equipment in Equipment.objects.all():
equipment.equipment_type_new = types_by_code.get(equipment.equipment_type)
equipment.status_new = statuses_by_code.get(equipment.status)
equipment.save(update_fields=['equipment_type_new', 'status_new'])
def backwards(apps, schema_editor):
Equipment = apps.get_model('it_manager', 'Equipment')
for equipment in Equipment.objects.select_related('equipment_type_new', 'status_new'):
equipment.equipment_type = equipment.equipment_type_new.code if equipment.equipment_type_new else 'other'
equipment.status = equipment.status_new.code if equipment.status_new else 'stock'
equipment.save(update_fields=['equipment_type', 'status'])
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0007_create_it_group'),
('core', '0002_connector_config'),
]
operations = [
migrations.RemoveIndex(model_name='equipment', name='it_manager__status_f1197a_idx'),
migrations.RemoveIndex(model_name='equipment', name='it_manager__equipme_0beeb3_idx'),
migrations.AddField(
model_name='equipment',
name='equipment_type_new',
field=models.ForeignKey(
null=True, on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'equipment_type', 'is_active': True},
related_name='equipment_by_type', to='core.directory', verbose_name='Тип',
),
),
migrations.AddField(
model_name='equipment',
name='status_new',
field=models.ForeignKey(
null=True, on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'equipment_status', 'is_active': True},
related_name='equipment_by_status', to='core.directory', verbose_name='Статус',
),
),
migrations.RunPython(forwards, backwards),
]
@@ -0,0 +1,46 @@
"""Друга фаза 0008: прибрати старі CharField, перейменувати нові FK на місце.
Окрема міграція (окрема транзакція) — інакше Postgres падає з
"cannot ALTER TABLE core_directory because there are pending trigger events"
через django-treenode тригери на core_directory, викликані масовими
get_or_create() у RunPython 0008 в межах тієї самої транзакції."""
import django.db.models.deletion
from django.db import migrations, models
from core.models.directories import directory_default
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0008_equipment_choices_to_directory'),
]
operations = [
migrations.RemoveField(model_name='equipment', name='equipment_type'),
migrations.RemoveField(model_name='equipment', name='status'),
migrations.RenameField(model_name='equipment', old_name='equipment_type_new', new_name='equipment_type'),
migrations.RenameField(model_name='equipment', old_name='status_new', new_name='status'),
migrations.AlterField(
model_name='equipment',
name='equipment_type',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'equipment_type', 'is_active': True},
related_name='equipment_by_type', to='core.directory', verbose_name='Тип',
),
),
migrations.AlterField(
model_name='equipment',
name='status',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'equipment_status', 'is_active': True},
related_name='equipment_by_status', to='core.directory', verbose_name='Статус',
default=directory_default('equipment_status', 'stock'),
),
),
migrations.AlterModelOptions(
name='equipment',
options={'ordering': ['equipment_type__name', 'brand__name', 'model'], 'verbose_name': 'Оборудование', 'verbose_name_plural': 'Оборудование'},
),
]
@@ -0,0 +1,109 @@
"""Ticket.ticket_type / priority / status: CharField(choices=) → FK core.Directory.
Фаза 1 (дані): seed довідників + бекфіл. Фаза 2 (схема) — окрема міграція
0011, інакше падає на "pending trigger events" на core_directory (django-treenode)."""
from django.db import migrations, models
TICKET_TYPES = [
('repair', 'Ремонт'),
('replacement', 'Замена оборудования'),
('new_equipment', 'Новое оборудование'),
('access', 'Доступ / Аккаунт'),
('consultation', 'Консультация'),
('other', 'Другое'),
]
PRIORITY_CHOICES = [
('low', 'Низкий'),
('medium', 'Средний'),
('high', 'Высокий'),
('critical', 'Критический'),
]
STATUS_CHOICES = [
('new', 'Новая'),
('in_progress', 'В работе'),
('waiting', 'Ожидание'),
('resolved', 'Решена'),
('closed', 'Закрыта'),
]
def seed_directory(apps, type_code, type_name, choices):
DirectoryType = apps.get_model('core', 'DirectoryType')
Directory = apps.get_model('core', 'Directory')
directory_type, _ = DirectoryType.objects.get_or_create(
code=type_code, defaults={'name': type_name, 'hierarchy_type': 'strict'},
)
result = {}
for code, name in choices:
directory, _ = Directory.objects.get_or_create(
directory_type=directory_type, code=code, defaults={'name': name, 'is_active': True},
)
result[code] = directory
return result
def forwards(apps, schema_editor):
Ticket = apps.get_model('it_manager', 'Ticket')
types_by_code = seed_directory(apps, 'ticket_type', 'Тип заявки', TICKET_TYPES)
priorities_by_code = seed_directory(apps, 'ticket_priority', 'Пріоритет заявки', PRIORITY_CHOICES)
statuses_by_code = seed_directory(apps, 'ticket_status', 'Статус заявки', STATUS_CHOICES)
for ticket in Ticket.objects.all():
ticket.ticket_type_new = types_by_code.get(ticket.ticket_type)
ticket.priority_new = priorities_by_code.get(ticket.priority)
ticket.status_new = statuses_by_code.get(ticket.status)
ticket.save(update_fields=['ticket_type_new', 'priority_new', 'status_new'])
def backwards(apps, schema_editor):
Ticket = apps.get_model('it_manager', 'Ticket')
for ticket in Ticket.objects.select_related('ticket_type_new', 'priority_new', 'status_new'):
ticket.ticket_type = ticket.ticket_type_new.code if ticket.ticket_type_new else 'other'
ticket.priority = ticket.priority_new.code if ticket.priority_new else 'medium'
ticket.status = ticket.status_new.code if ticket.status_new else 'new'
ticket.save(update_fields=['ticket_type', 'priority', 'status'])
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0009_equipment_choices_finalize'),
('core', '0002_connector_config'),
]
operations = [
migrations.RemoveIndex(model_name='ticket', name='it_manager__status_6e5b9f_idx'),
migrations.RemoveIndex(model_name='ticket', name='it_manager__request_a11dcf_idx'),
migrations.RemoveIndex(model_name='ticket', name='it_manager__assigne_555f6b_idx'),
migrations.AddField(
model_name='ticket',
name='ticket_type_new',
field=models.ForeignKey(
null=True, on_delete=models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'ticket_type', 'is_active': True},
related_name='tickets_by_type', to='core.directory', verbose_name='Тип',
),
),
migrations.AddField(
model_name='ticket',
name='priority_new',
field=models.ForeignKey(
null=True, on_delete=models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'ticket_priority', 'is_active': True},
related_name='tickets_by_priority', to='core.directory', verbose_name='Приоритет',
),
),
migrations.AddField(
model_name='ticket',
name='status_new',
field=models.ForeignKey(
null=True, on_delete=models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'ticket_status', 'is_active': True},
related_name='tickets_by_status', to='core.directory', verbose_name='Статус',
),
),
migrations.RunPython(forwards, backwards),
]
@@ -0,0 +1,49 @@
import django.db.models.deletion
from django.db import migrations, models
from core.models.directories import directory_default
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0010_ticket_choices_to_directory'),
]
operations = [
migrations.RemoveField(model_name='ticket', name='ticket_type'),
migrations.RemoveField(model_name='ticket', name='priority'),
migrations.RemoveField(model_name='ticket', name='status'),
migrations.RenameField(model_name='ticket', old_name='ticket_type_new', new_name='ticket_type'),
migrations.RenameField(model_name='ticket', old_name='priority_new', new_name='priority'),
migrations.RenameField(model_name='ticket', old_name='status_new', new_name='status'),
migrations.AlterField(
model_name='ticket',
name='ticket_type',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'ticket_type', 'is_active': True},
related_name='tickets_by_type', to='core.directory', verbose_name='Тип',
),
),
migrations.AlterField(
model_name='ticket',
name='priority',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'ticket_priority', 'is_active': True},
related_name='tickets_by_priority', to='core.directory', verbose_name='Приоритет',
default=directory_default('ticket_priority', 'medium'),
),
),
migrations.AlterField(
model_name='ticket',
name='status',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'ticket_status', 'is_active': True},
related_name='tickets_by_status', to='core.directory', verbose_name='Статус',
default=directory_default('ticket_status', 'new'),
),
),
]
@@ -0,0 +1,94 @@
from django.db import migrations, models
WORK_TYPES = [
('maintenance', 'Техническое обслуживание'),
('installation', 'Установка / настройка'),
('repair', 'Ремонт'),
('network', 'Сетевые работы'),
('software', 'Программное обеспечение'),
('backup', 'Резервное копирование'),
('other', 'Другое'),
]
CHANNEL_CHOICES = [
('phone', 'Телефон'),
('chat', 'Чат / мессенджер'),
('email', 'Email'),
('personal', 'Личный визит'),
('other', 'Другое'),
]
def seed_directory(apps, type_code, type_name, choices):
DirectoryType = apps.get_model('core', 'DirectoryType')
Directory = apps.get_model('core', 'Directory')
directory_type, _ = DirectoryType.objects.get_or_create(
code=type_code, defaults={'name': type_name, 'hierarchy_type': 'strict'},
)
result = {}
for code, name in choices:
directory, _ = Directory.objects.get_or_create(
directory_type=directory_type, code=code, defaults={'name': name, 'is_active': True},
)
result[code] = directory
return result
def forwards(apps, schema_editor):
WorkLog = apps.get_model('it_manager', 'WorkLog')
ServiceCall = apps.get_model('it_manager', 'ServiceCall')
types_by_code = seed_directory(apps, 'worklog_type', 'Вид робіт', WORK_TYPES)
channels_by_code = seed_directory(apps, 'servicecall_channel', 'Канал звернення', CHANNEL_CHOICES)
for log in WorkLog.objects.all():
log.work_type_new = types_by_code.get(log.work_type)
log.save(update_fields=['work_type_new'])
for call in ServiceCall.objects.all():
call.channel_new = channels_by_code.get(call.channel)
call.save(update_fields=['channel_new'])
def backwards(apps, schema_editor):
WorkLog = apps.get_model('it_manager', 'WorkLog')
ServiceCall = apps.get_model('it_manager', 'ServiceCall')
for log in WorkLog.objects.select_related('work_type_new'):
log.work_type = log.work_type_new.code if log.work_type_new else 'other'
log.save(update_fields=['work_type'])
for call in ServiceCall.objects.select_related('channel_new'):
call.channel = call.channel_new.code if call.channel_new else 'other'
call.save(update_fields=['channel'])
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0011_ticket_choices_finalize'),
('core', '0002_connector_config'),
]
operations = [
migrations.RemoveIndex(model_name='worklog', name='it_manager__work_ty_8d1ee5_idx'),
migrations.RemoveIndex(model_name='servicecall', name='it_manager__channel_098784_idx'),
migrations.AddField(
model_name='worklog',
name='work_type_new',
field=models.ForeignKey(
null=True, on_delete=models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True},
related_name='worklogs_by_type', to='core.directory', verbose_name='Вид работ',
),
),
migrations.AddField(
model_name='servicecall',
name='channel_new',
field=models.ForeignKey(
null=True, on_delete=models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'servicecall_channel', 'is_active': True},
related_name='servicecalls_by_channel', to='core.directory', verbose_name='Канал',
),
),
migrations.RunPython(forwards, backwards),
]
@@ -0,0 +1,34 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0012_worklog_servicecall_choices_to_directory'),
]
operations = [
migrations.RemoveField(model_name='worklog', name='work_type'),
migrations.RemoveField(model_name='servicecall', name='channel'),
migrations.RenameField(model_name='worklog', old_name='work_type_new', new_name='work_type'),
migrations.RenameField(model_name='servicecall', old_name='channel_new', new_name='channel'),
migrations.AlterField(
model_name='worklog',
name='work_type',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True},
related_name='worklogs_by_type', to='core.directory', verbose_name='Вид работ',
),
),
migrations.AlterField(
model_name='servicecall',
name='channel',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'servicecall_channel', 'is_active': True},
related_name='servicecalls_by_channel', to='core.directory', verbose_name='Канал',
),
),
]
@@ -0,0 +1,115 @@
from django.db import migrations, models
CONSUMABLE_TYPES = [
('cartridge', 'Картридж'),
('toner', 'Тонер'),
('drum', 'Фотобарабан'),
('ink', 'Чернила'),
('other', 'Другое'),
]
COLOR_CHOICES = [
('black', 'Чёрный'),
('cyan', 'Голубой (C)'),
('magenta', 'Пурпурный (M)'),
('yellow', 'Жёлтый (Y)'),
('color', 'Цветной (триколор)'),
('na', 'Не применимо'),
]
STATUS_CHOICES = [
('in_stock', 'На складе'),
('installed', 'Установлен'),
('empty', 'Закончился / на замену'),
('ordered', 'Заказан'),
('disposed', 'Утилизирован'),
]
def seed_directory(apps, type_code, type_name, choices):
DirectoryType = apps.get_model('core', 'DirectoryType')
Directory = apps.get_model('core', 'Directory')
directory_type, _ = DirectoryType.objects.get_or_create(
code=type_code, defaults={'name': type_name, 'hierarchy_type': 'strict'},
)
result = {}
for code, name in choices:
directory, _ = Directory.objects.get_or_create(
directory_type=directory_type, code=code, defaults={'name': name, 'is_active': True},
)
result[code] = directory
return result
def forwards(apps, schema_editor):
Consumable = apps.get_model('it_manager', 'Consumable')
types_by_code = seed_directory(apps, 'consumable_type', 'Тип витратного матеріалу', CONSUMABLE_TYPES)
colors_by_code = seed_directory(apps, 'consumable_color', 'Колір витратного матеріалу', COLOR_CHOICES)
statuses_by_code = seed_directory(apps, 'consumable_status', 'Статус витратного матеріалу', STATUS_CHOICES)
for item in Consumable.objects.all():
item.consumable_type_new = types_by_code.get(item.consumable_type)
item.color_new = colors_by_code.get(item.color)
item.status_new = statuses_by_code.get(item.status)
item.save(update_fields=['consumable_type_new', 'color_new', 'status_new'])
def backwards(apps, schema_editor):
Consumable = apps.get_model('it_manager', 'Consumable')
for item in Consumable.objects.select_related('consumable_type_new', 'color_new', 'status_new'):
item.consumable_type = item.consumable_type_new.code if item.consumable_type_new else 'cartridge'
item.color = item.color_new.code if item.color_new else 'black'
item.status = item.status_new.code if item.status_new else 'in_stock'
item.save(update_fields=['consumable_type', 'color', 'status'])
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0013_worklog_servicecall_choices_finalize'),
('core', '0002_connector_config'),
]
operations = [
migrations.RemoveIndex(model_name='consumable', name='it_manager__status_53998f_idx'),
migrations.RemoveIndex(model_name='consumable', name='it_manager__equipme_8f7405_idx'),
migrations.AddField(
model_name='consumable',
name='consumable_type_new',
field=models.ForeignKey(
null=True, on_delete=models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'consumable_type', 'is_active': True},
related_name='consumables_by_type', to='core.directory', verbose_name='Тип',
),
),
migrations.AddField(
model_name='consumable',
name='color_new',
field=models.ForeignKey(
null=True, on_delete=models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'consumable_color', 'is_active': True},
related_name='consumables_by_color', to='core.directory', verbose_name='Цвет',
),
),
migrations.AddField(
model_name='consumable',
name='status_new',
field=models.ForeignKey(
null=True, on_delete=models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'consumable_status', 'is_active': True},
related_name='consumables_by_status', to='core.directory', verbose_name='Статус',
),
),
migrations.AlterField(
model_name='consumable',
name='equipment',
field=models.ForeignKey(
blank=True, null=True, on_delete=models.deletion.SET_NULL,
limit_choices_to={'equipment_type__code': 'printer'},
related_name='consumables', to='it_manager.equipment', verbose_name='Устройство',
),
),
migrations.RunPython(forwards, backwards),
]
@@ -0,0 +1,54 @@
import django.db.models.deletion
from django.db import migrations, models
from core.models.directories import directory_default
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0014_consumable_choices_to_directory'),
]
operations = [
migrations.RemoveField(model_name='consumable', name='consumable_type'),
migrations.RemoveField(model_name='consumable', name='color'),
migrations.RemoveField(model_name='consumable', name='status'),
migrations.RenameField(model_name='consumable', old_name='consumable_type_new', new_name='consumable_type'),
migrations.RenameField(model_name='consumable', old_name='color_new', new_name='color'),
migrations.RenameField(model_name='consumable', old_name='status_new', new_name='status'),
migrations.AlterField(
model_name='consumable',
name='consumable_type',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'consumable_type', 'is_active': True},
related_name='consumables_by_type', to='core.directory', verbose_name='Тип',
default=directory_default('consumable_type', 'cartridge'),
),
),
migrations.AlterField(
model_name='consumable',
name='color',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'consumable_color', 'is_active': True},
related_name='consumables_by_color', to='core.directory', verbose_name='Цвет',
default=directory_default('consumable_color', 'black'),
),
),
migrations.AlterField(
model_name='consumable',
name='status',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'consumable_status', 'is_active': True},
related_name='consumables_by_status', to='core.directory', verbose_name='Статус',
default=directory_default('consumable_status', 'in_stock'),
),
),
migrations.AlterModelOptions(
name='consumable',
options={'ordering': ['equipment', 'consumable_type__name', 'color__name'], 'verbose_name': 'Расходник', 'verbose_name_plural': 'Расходники'},
),
]
@@ -0,0 +1,106 @@
from django.db import migrations, models
SERVICE_TYPES = [
('internet', 'Интернет'),
('telephony', 'Телефония'),
('vpn', 'VPN'),
('dedicated', 'Выделенная линия'),
('other', 'Другое'),
]
CONNECTION_TYPES = [
('fiber', 'Оптоволокно (FTTH/FTTB)'),
('ethernet', 'Ethernet (FTTB)'),
('docsis', 'Кабель (DOCSIS)'),
('adsl', 'xDSL (ADSL/VDSL)'),
('4g', '4G/LTE'),
('5g', '5G'),
('radio', 'Радиоканал'),
('other', 'Другое'),
]
STATUS_CHOICES = [
('active', 'Активен'),
('suspended', 'Приостановлен'),
('terminated', 'Расторгнут'),
]
def seed_directory(apps, type_code, type_name, choices):
DirectoryType = apps.get_model('core', 'DirectoryType')
Directory = apps.get_model('core', 'Directory')
directory_type, _ = DirectoryType.objects.get_or_create(
code=type_code, defaults={'name': type_name, 'hierarchy_type': 'strict'},
)
result = {}
for code, name in choices:
directory, _ = Directory.objects.get_or_create(
directory_type=directory_type, code=code, defaults={'name': name, 'is_active': True},
)
result[code] = directory
return result
def forwards(apps, schema_editor):
InternetContract = apps.get_model('it_manager', 'InternetContract')
services_by_code = seed_directory(apps, 'contract_service_type', 'Тип послуги (договір)', SERVICE_TYPES)
connections_by_code = seed_directory(apps, 'contract_connection_type', 'Технологія підключення', CONNECTION_TYPES)
statuses_by_code = seed_directory(apps, 'contract_status', 'Статус договору', STATUS_CHOICES)
for contract in InternetContract.objects.all():
contract.service_type_new = services_by_code.get(contract.service_type)
contract.connection_type_new = connections_by_code.get(contract.connection_type) if contract.connection_type else None
contract.status_new = statuses_by_code.get(contract.status)
contract.save(update_fields=['service_type_new', 'connection_type_new', 'status_new'])
def backwards(apps, schema_editor):
InternetContract = apps.get_model('it_manager', 'InternetContract')
for contract in InternetContract.objects.select_related('service_type_new', 'connection_type_new', 'status_new'):
contract.service_type = contract.service_type_new.code if contract.service_type_new else 'internet'
contract.connection_type = contract.connection_type_new.code if contract.connection_type_new else ''
contract.status = contract.status_new.code if contract.status_new else 'active'
contract.save(update_fields=['service_type', 'connection_type', 'status'])
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0015_consumable_choices_finalize'),
('core', '0002_connector_config'),
]
operations = [
migrations.RemoveIndex(model_name='internetcontract', name='it_manager__status_e06eaa_idx'),
migrations.RemoveIndex(model_name='internetcontract', name='it_manager__locatio_6abc78_idx'),
migrations.AddField(
model_name='internetcontract',
name='service_type_new',
field=models.ForeignKey(
null=True, on_delete=models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'contract_service_type', 'is_active': True},
related_name='contracts_by_service_type', to='core.directory', verbose_name='Тип услуги',
),
),
migrations.AddField(
model_name='internetcontract',
name='connection_type_new',
field=models.ForeignKey(
null=True, blank=True, on_delete=models.deletion.SET_NULL,
limit_choices_to={'directory_type__code': 'contract_connection_type', 'is_active': True},
related_name='contracts_by_connection_type', to='core.directory', verbose_name='Технология подключения',
),
),
migrations.AddField(
model_name='internetcontract',
name='status_new',
field=models.ForeignKey(
null=True, on_delete=models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'contract_status', 'is_active': True},
related_name='contracts_by_status', to='core.directory', verbose_name='Статус',
),
),
migrations.RunPython(forwards, backwards),
]
@@ -0,0 +1,53 @@
import django.db.models.deletion
from django.db import migrations, models
from core.models.directories import directory_default
class Migration(migrations.Migration):
dependencies = [
('it_manager', '0016_contract_choices_to_directory'),
]
operations = [
migrations.RemoveField(model_name='internetcontract', name='service_type'),
migrations.RemoveField(model_name='internetcontract', name='connection_type'),
migrations.RemoveField(model_name='internetcontract', name='status'),
migrations.RenameField(model_name='internetcontract', old_name='service_type_new', new_name='service_type'),
migrations.RenameField(model_name='internetcontract', old_name='connection_type_new', new_name='connection_type'),
migrations.RenameField(model_name='internetcontract', old_name='status_new', new_name='status'),
migrations.AlterField(
model_name='internetcontract',
name='service_type',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'contract_service_type', 'is_active': True},
related_name='contracts_by_service_type', to='core.directory', verbose_name='Тип услуги',
default=directory_default('contract_service_type', 'internet'),
),
),
migrations.AlterField(
model_name='internetcontract',
name='connection_type',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
limit_choices_to={'directory_type__code': 'contract_connection_type', 'is_active': True},
related_name='contracts_by_connection_type', to='core.directory', verbose_name='Технология подключения',
),
),
migrations.AlterField(
model_name='internetcontract',
name='status',
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
limit_choices_to={'directory_type__code': 'contract_status', 'is_active': True},
related_name='contracts_by_status', to='core.directory', verbose_name='Статус',
default=directory_default('contract_status', 'active'),
),
),
migrations.AlterModelOptions(
name='internetcontract',
options={'ordering': ['location', 'provider'], 'verbose_name': 'Договор с провайдером', 'verbose_name_plural': 'Договоры с провайдерами'},
),
]
@@ -0,0 +1,100 @@
import django.db.models.deletion
from django.db import migrations, models
def build_catalog(apps, schema_editor):
"""Створити EquipmentModel з унікальних комбінацій type+brand+model
існуючого обладнання і прив'язати кожен екземпляр до своєї моделі."""
Equipment = apps.get_model('it_manager', 'Equipment')
EquipmentModel = apps.get_model('it_manager', 'EquipmentModel')
cache = {}
for eq in Equipment.objects.all():
key = (eq.equipment_type_id, eq.brand_id, eq.model)
model = cache.get(key)
if model is None:
model, _ = EquipmentModel.objects.get_or_create(
equipment_type_id=eq.equipment_type_id,
brand_id=eq.brand_id,
name=eq.model,
)
cache[key] = model
eq.equipment_model = model
eq.save(update_fields=['equipment_model'])
def unbuild_catalog(apps, schema_editor):
"""Зворотній перенос: повернути type/brand/model на екземпляр з моделі."""
Equipment = apps.get_model('it_manager', 'Equipment')
for eq in Equipment.objects.select_related('equipment_model').all():
m = eq.equipment_model
eq.equipment_type_id = m.equipment_type_id
eq.brand_id = m.brand_id
eq.model = m.name
eq.save(update_fields=['equipment_type', 'brand', 'model'])
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('it_manager', '0017_contract_choices_finalize'),
]
operations = [
# 1. Каталог моделей
migrations.CreateModel(
name='EquipmentModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Модель')),
('warranty_months', models.PositiveIntegerField(blank=True, help_text='Стандартный срок гарантии для этой модели', null=True, verbose_name='Гарантия (мес.)')),
('notes', models.TextField(blank=True, verbose_name='Примечания')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('brand', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'equipment_brand', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='models_by_brand', to='core.directory', verbose_name='Бренд')),
('equipment_type', models.ForeignKey(limit_choices_to={'directory_type__code': 'equipment_type', 'is_active': True}, on_delete=django.db.models.deletion.PROTECT, related_name='models_by_type', to='core.directory', verbose_name='Тип')),
],
options={
'verbose_name': 'Модель оборудования',
'verbose_name_plural': 'Модели оборудования (каталог)',
'ordering': ['equipment_type__name', 'brand__name', 'name'],
},
),
migrations.AddConstraint(
model_name='equipmentmodel',
constraint=models.UniqueConstraint(fields=('equipment_type', 'brand', 'name'), name='unique_equipment_model'),
),
# 2. FK на модель (тимчасово nullable для перенесення)
migrations.AddField(
model_name='equipment',
name='equipment_model',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='units', to='it_manager.equipmentmodel', verbose_name='Модель'),
),
# 3. Перенесення даних
migrations.RunPython(build_catalog, unbuild_catalog),
# 4. Тепер модель обов'язкова
migrations.AlterField(
model_name='equipment',
name='equipment_model',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='units', to='it_manager.equipmentmodel', verbose_name='Модель'),
),
# 5. Прибрати старі поля з екземпляра
migrations.RemoveField(model_name='equipment', name='brand'),
migrations.RemoveField(model_name='equipment', name='equipment_type'),
migrations.RemoveField(model_name='equipment', name='model'),
# 6. Оновити службове
migrations.AlterModelOptions(
name='equipment',
options={'ordering': ['equipment_model__equipment_type__name', 'equipment_model__name'], 'verbose_name': 'Оборудование', 'verbose_name_plural': 'Оборудование'},
),
migrations.AddIndex(
model_name='equipment',
index=models.Index(fields=['equipment_model'], name='it_manager__equipme_5b76a5_idx'),
),
migrations.AlterField(
model_name='consumable',
name='equipment',
field=models.ForeignKey(blank=True, limit_choices_to={'equipment_model__equipment_type__code': 'printer'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='consumables', to='it_manager.equipment', verbose_name='Устройство'),
),
]
+1 -1
View File
@@ -1,4 +1,4 @@
from .equipment import Equipment, EquipmentAssignment
from .equipment import Equipment, EquipmentAssignment, EquipmentModel
from .tickets import Ticket, TicketComment
from .providers import InternetProvider, InternetContract
from .worklogs import WorkLog, ServiceCall
+26 -41
View File
@@ -1,47 +1,36 @@
from django.db import models
from django.utils import timezone
from core.models.directories import directory_default
class Consumable(models.Model):
CONSUMABLE_TYPES = [
('cartridge', 'Картридж'),
('toner', 'Тонер'),
('drum', 'Фотобарабан'),
('ink', 'Чернила'),
('other', 'Другое'),
]
COLOR_CHOICES = [
('black', 'Чёрный'),
('cyan', 'Голубой (C)'),
('magenta', 'Пурпурный (M)'),
('yellow', 'Жёлтый (Y)'),
('color', 'Цветной (триколор)'),
('na', 'Не применимо'),
]
STATUS_CHOICES = [
('in_stock', 'На складе'),
('installed', 'Установлен'),
('empty', 'Закончился / на замену'),
('ordered', 'Заказан'),
('disposed', 'Утилизирован'),
]
consumable_type = models.CharField('Тип', max_length=20,
choices=CONSUMABLE_TYPES, default='cartridge', db_index=True)
consumable_type = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'consumable_type', 'is_active': True},
related_name='consumables_by_type', verbose_name='Тип',
default=directory_default('consumable_type', 'cartridge'),
)
brand = models.CharField('Производитель', max_length=100, blank=True)
model_number = models.CharField('Модель / артикул', max_length=100)
color = models.CharField('Цвет', max_length=10,
choices=COLOR_CHOICES, default='black')
status = models.CharField('Статус', max_length=20,
choices=STATUS_CHOICES, default='in_stock', db_index=True)
color = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'consumable_color', 'is_active': True},
related_name='consumables_by_color', verbose_name='Цвет',
default=directory_default('consumable_color', 'black'),
)
status = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'consumable_status', 'is_active': True},
related_name='consumables_by_status', verbose_name='Статус',
default=directory_default('consumable_status', 'in_stock'),
)
equipment = models.ForeignKey(
'it_manager.Equipment', on_delete=models.SET_NULL,
null=True, blank=True,
related_name='consumables', verbose_name='Устройство',
limit_choices_to={'equipment_type': 'printer'},
limit_choices_to={'equipment_model__equipment_type__code': 'printer'},
)
location = models.ForeignKey(
'locations.Location', on_delete=models.SET_NULL,
@@ -61,22 +50,18 @@ class Consumable(models.Model):
class Meta:
verbose_name = 'Расходник'
verbose_name_plural = 'Расходники'
ordering = ['equipment', 'consumable_type', 'color']
indexes = [
models.Index(fields=['status']),
models.Index(fields=['equipment', 'status']),
]
ordering = ['equipment', 'consumable_type__name', 'color__name']
def __str__(self):
parts = [self.get_consumable_type_display()]
parts = [self.consumable_type.name]
if self.brand:
parts.append(self.brand)
parts.append(self.model_number)
if self.color != 'na':
parts.append(f'({self.get_color_display()})')
if self.color.code != 'na':
parts.append(f'({self.color.name})')
return ' '.join(parts)
@property
def is_low(self):
"""Нужно заказывать: закончился или заказан."""
return self.status in ('empty', 'ordered')
return self.status.code in ('empty', 'ordered')
+72 -28
View File
@@ -2,42 +2,73 @@ from django.db import models
from django.conf import settings
from django.utils import timezone
from core.models.directories import directory_default
class Equipment(models.Model):
EQUIPMENT_TYPES = [
('laptop', 'Ноутбук'),
('desktop', 'ПК'),
('monitor', 'Монитор'),
('printer', 'Принтер'),
('phone', 'Телефон'),
('tablet', 'Планшет'),
('router', 'Роутер/Свитч'),
('ups', 'ИБП'),
('scanner', 'Сканер'),
('other', 'Другое'),
]
STATUS_CHOICES = [
('stock', 'На складе'),
('assigned', 'Выдано'),
('repair', 'В ремонте'),
('written_off', 'Списано'),
]
class EquipmentModel(models.Model):
"""Каталог моделей: тип + бренд + название заводятся один раз
(например «Mikrotik RB952Ui-5ac2nD»). Конкретные экземпляры (Equipment)
ссылаются на модель и несут только серийник/инвентарник/локацию —
больше не нужно каждый раз переписывать тип и модель."""
equipment_type = models.CharField('Тип', max_length=20, choices=EQUIPMENT_TYPES)
equipment_type = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'equipment_type', 'is_active': True},
related_name='models_by_type', verbose_name='Тип',
)
brand = models.ForeignKey(
'core.Directory', on_delete=models.SET_NULL,
limit_choices_to={'directory_type__code': 'equipment_brand', 'is_active': True},
related_name='equipment_brands', verbose_name='Бренд',
related_name='models_by_brand', verbose_name='Бренд',
null=True, blank=True,
)
model = models.CharField('Модель', max_length=100)
name = models.CharField('Модель', max_length=100)
# Гарантия по умолчанию (месяцев) — при массовом вводе экземпляров
# warranty_until можно посчитать от даты покупки, не вводя вручную
warranty_months = models.PositiveIntegerField(
'Гарантия (мес.)', null=True, blank=True,
help_text='Стандартный срок гарантии для этой модели',
)
notes = models.TextField('Примечания', blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'Модель оборудования'
verbose_name_plural = 'Модели оборудования (каталог)'
ordering = ['equipment_type__name', 'brand__name', 'name']
constraints = [
models.UniqueConstraint(
fields=['equipment_type', 'brand', 'name'],
name='unique_equipment_model',
),
]
def __str__(self):
brand = f'{self.brand.name} ' if self.brand_id else ''
return f'{brand}{self.name}'.strip()
@property
def full_label(self):
return f'{self.equipment_type.name} · {self}'
class Equipment(models.Model):
equipment_model = models.ForeignKey(
EquipmentModel, on_delete=models.PROTECT,
related_name='units', verbose_name='Модель',
)
serial_number = models.CharField('Серийный номер', max_length=100, unique=True)
inventory_number = models.CharField('Инвентарный номер', max_length=50,
blank=True, unique=True, null=True)
status = models.CharField('Статус', max_length=20,
choices=STATUS_CHOICES, default='stock', db_index=True)
status = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'equipment_status', 'is_active': True},
related_name='equipment_by_status', verbose_name='Статус',
default=directory_default('equipment_status', 'stock'),
)
assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True,
@@ -59,15 +90,28 @@ class Equipment(models.Model):
class Meta:
verbose_name = 'Оборудование'
verbose_name_plural = 'Оборудование'
ordering = ['equipment_type', 'brand__name', 'model']
ordering = ['equipment_model__equipment_type__name', 'equipment_model__name']
indexes = [
models.Index(fields=['status']),
models.Index(fields=['equipment_type']),
models.Index(fields=['assigned_to']),
models.Index(fields=['equipment_model']),
]
def __str__(self):
return f'{self.get_equipment_type_display()} {self.brand} {self.model} [{self.serial_number}]'
return f'{self.equipment_model} [{self.serial_number}]'
# --- Удобные проксі до моделі каталогу (щоб шаблони/код не лізли
# кожен раз через equipment_model) ---
@property
def equipment_type(self):
return self.equipment_model.equipment_type
@property
def brand(self):
return self.equipment_model.brand
@property
def model(self):
return self.equipment_model.name
@property
def is_under_warranty(self):
+22 -37
View File
@@ -1,6 +1,8 @@
from django.db import models
from django.utils import timezone
from core.models.directories import directory_default
class InternetProvider(models.Model):
name = models.CharField('Название', max_length=200, unique=True)
@@ -23,35 +25,10 @@ class InternetProvider(models.Model):
@property
def active_contracts_count(self):
return self.contracts.filter(status='active').count()
return self.contracts.filter(status__code='active').count()
class InternetContract(models.Model):
SERVICE_TYPES = [
('internet', 'Интернет'),
('telephony', 'Телефония'),
('vpn', 'VPN'),
('dedicated', 'Выделенная линия'),
('other', 'Другое'),
]
CONNECTION_TYPES = [
('fiber', 'Оптоволокно (FTTH/FTTB)'),
('ethernet', 'Ethernet (FTTB)'),
('docsis', 'Кабель (DOCSIS)'),
('adsl', 'xDSL (ADSL/VDSL)'),
('4g', '4G/LTE'),
('5g', '5G'),
('radio', 'Радиоканал'),
('other', 'Другое'),
]
STATUS_CHOICES = [
('active', 'Активен'),
('suspended', 'Приостановлен'),
('terminated', 'Расторгнут'),
]
provider = models.ForeignKey(
InternetProvider, on_delete=models.PROTECT,
related_name='contracts', verbose_name='Провайдер',
@@ -62,12 +39,24 @@ class InternetContract(models.Model):
)
contract_number = models.CharField('Номер договора', max_length=100)
service_type = models.CharField('Тип услуги', max_length=20,
choices=SERVICE_TYPES, default='internet')
connection_type = models.CharField('Технология подключения', max_length=20,
choices=CONNECTION_TYPES, blank=True)
status = models.CharField('Статус', max_length=20,
choices=STATUS_CHOICES, default='active', db_index=True)
service_type = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'contract_service_type', 'is_active': True},
related_name='contracts_by_service_type', verbose_name='Тип услуги',
default=directory_default('contract_service_type', 'internet'),
)
connection_type = models.ForeignKey(
'core.Directory', on_delete=models.SET_NULL,
limit_choices_to={'directory_type__code': 'contract_connection_type', 'is_active': True},
related_name='contracts_by_connection_type', verbose_name='Технология подключения',
null=True, blank=True,
)
status = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'contract_status', 'is_active': True},
related_name='contracts_by_status', verbose_name='Статус',
default=directory_default('contract_status', 'active'),
)
start_date = models.DateField('Дата начала')
end_date = models.DateField('Дата окончания', null=True, blank=True)
@@ -90,17 +79,13 @@ class InternetContract(models.Model):
verbose_name_plural = 'Договоры с провайдерами'
ordering = ['location', 'provider']
unique_together = [('provider', 'contract_number')]
indexes = [
models.Index(fields=['status']),
models.Index(fields=['location', 'status']),
]
def __str__(self):
return f'{self.provider} — {self.location} (№{self.contract_number})'
@property
def is_expiring_soon(self):
if not self.end_date or self.status != 'active':
if not self.end_date or self.status.code != 'active':
return False
return (self.end_date - timezone.now().date()).days <= 30
+23 -38
View File
@@ -2,39 +2,29 @@ from django.db import models
from django.conf import settings
from django.utils import timezone
from core.models.directories import directory_default
class Ticket(models.Model):
TICKET_TYPES = [
('repair', 'Ремонт'),
('replacement', 'Замена оборудования'),
('new_equipment', 'Новое оборудование'),
('access', 'Доступ / Аккаунт'),
('consultation', 'Консультация'),
('other', 'Другое'),
]
PRIORITY_CHOICES = [
('low', 'Низкий'),
('medium', 'Средний'),
('high', 'Высокий'),
('critical', 'Критический'),
]
STATUS_CHOICES = [
('new', 'Новая'),
('in_progress', 'В работе'),
('waiting', 'Ожидание'),
('resolved', 'Решена'),
('closed', 'Закрыта'),
]
number = models.CharField('Номер', max_length=20, unique=True, editable=False)
title = models.CharField('Заголовок', max_length=200)
ticket_type = models.CharField('Тип', max_length=20, choices=TICKET_TYPES, db_index=True)
priority = models.CharField('Приоритет', max_length=10,
choices=PRIORITY_CHOICES, default='medium', db_index=True)
status = models.CharField('Статус', max_length=20,
choices=STATUS_CHOICES, default='new', db_index=True)
ticket_type = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'ticket_type', 'is_active': True},
related_name='tickets_by_type', verbose_name='Тип',
)
priority = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'ticket_priority', 'is_active': True},
related_name='tickets_by_priority', verbose_name='Приоритет',
default=directory_default('ticket_priority', 'medium'),
)
status = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'ticket_status', 'is_active': True},
related_name='tickets_by_status', verbose_name='Статус',
default=directory_default('ticket_status', 'new'),
)
requester = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
@@ -68,11 +58,6 @@ class Ticket(models.Model):
verbose_name = 'Заявка'
verbose_name_plural = 'Заявки'
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', 'priority']),
models.Index(fields=['requester', 'status']),
models.Index(fields=['assignee', 'status']),
]
def __str__(self):
return f'[{self.number}] {self.title}'
@@ -80,9 +65,9 @@ class Ticket(models.Model):
def save(self, *args, **kwargs):
if not self.number:
self.number = self._generate_number()
if self.status == 'resolved' and not self.resolved_at:
if self.status.code == 'resolved' and not self.resolved_at:
self.resolved_at = timezone.now()
if self.status == 'closed' and not self.closed_at:
if self.status.code == 'closed' and not self.closed_at:
self.closed_at = timezone.now()
super().save(*args, **kwargs)
@@ -101,10 +86,10 @@ class Ticket(models.Model):
@property
def is_overdue(self):
if self.status in ('resolved', 'closed'):
if self.status.code in ('resolved', 'closed'):
return False
sla_hours = {'critical': 4, 'high': 8, 'medium': 24, 'low': 72}
limit = sla_hours.get(self.priority, 24)
limit = sla_hours.get(self.priority.code, 24)
return (timezone.now() - self.created_at).total_seconds() / 3600 > limit
+14 -24
View File
@@ -2,18 +2,10 @@ from django.db import models
from django.conf import settings
from django.utils import timezone
from core.models.directories import directory_default
class WorkLog(models.Model):
WORK_TYPES = [
('maintenance', 'Техническое обслуживание'),
('installation', 'Установка / настройка'),
('repair', 'Ремонт'),
('network', 'Сетевые работы'),
('software', 'Программное обеспечение'),
('backup', 'Резервное копирование'),
('other', 'Другое'),
]
date = models.DateField('Дата', default=timezone.localdate, db_index=True)
technician = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
@@ -24,7 +16,11 @@ class WorkLog(models.Model):
null=True, blank=True,
related_name='work_logs', verbose_name='Локация',
)
work_type = models.CharField('Вид работ', max_length=20, choices=WORK_TYPES, db_index=True)
work_type = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True},
related_name='worklogs_by_type', verbose_name='Вид работ',
)
description = models.TextField('Описание работ')
result = models.TextField('Результат', blank=True)
hours_spent = models.DecimalField('Затрачено часов', max_digits=5, decimal_places=1,
@@ -50,22 +46,13 @@ class WorkLog(models.Model):
ordering = ['-date', '-created_at']
indexes = [
models.Index(fields=['date', 'technician']),
models.Index(fields=['work_type']),
]
def __str__(self):
return f'{self.get_work_type_display()} — {self.technician} — {self.date:%d.%m.%Y}'
return f'{self.work_type.name} — {self.technician} — {self.date:%d.%m.%Y}'
class ServiceCall(models.Model):
CHANNEL_CHOICES = [
('phone', 'Телефон'),
('chat', 'Чат / мессенджер'),
('email', 'Email'),
('personal', 'Личный визит'),
('other', 'Другое'),
]
date_time = models.DateTimeField('Дата и время', default=timezone.now, db_index=True)
caller = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
@@ -76,7 +63,11 @@ class ServiceCall(models.Model):
null=True, blank=True,
related_name='service_calls_handled', verbose_name='Принял обращение',
)
channel = models.CharField('Канал', max_length=20, choices=CHANNEL_CHOICES, db_index=True)
channel = models.ForeignKey(
'core.Directory', on_delete=models.PROTECT,
limit_choices_to={'directory_type__code': 'servicecall_channel', 'is_active': True},
related_name='servicecalls_by_channel', verbose_name='Канал',
)
location = models.ForeignKey(
'locations.Location', on_delete=models.SET_NULL,
null=True, blank=True,
@@ -100,11 +91,10 @@ class ServiceCall(models.Model):
ordering = ['-date_time']
indexes = [
models.Index(fields=['date_time', 'handler']),
models.Index(fields=['channel']),
]
def __str__(self):
return (f'{self.get_channel_display()} — {self.caller} — '
return (f'{self.channel.name} — {self.caller} — '
f'{self.date_time:%d.%m.%Y %H:%M}')
@property
+185
View File
@@ -0,0 +1,185 @@
"""Тести IT-порталу: доступ через групу IT, інвентаризація обладнання
(зі списанням історії видачі), черга заявок, журнал робіт."""
from datetime import date
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.test import Client, TestCase
from apps.it_manager.models import Equipment, EquipmentAssignment, Ticket, WorkLog
from core.models.directories import Directory
Employee = get_user_model()
_employee_seq = 0
def _dir_id(type_code, code):
"""Знаходить Directory.pk за (тип, код) — довідники засіяні data-міграціями конверсії choices."""
return Directory.objects.get(directory_type__code=type_code, code=code).id
def make_employee(**overrides):
global _employee_seq
_employee_seq += 1
defaults = {
'username': f'itman.user{_employee_seq}',
'email': f'itman.user{_employee_seq}@company.local',
'first_name': 'Тест',
'last_name': 'Співробітник',
'hire_date': date(2026, 1, 1),
'user_location_type': 'office',
'mattermost_sync_enabled': False,
'ad_sync_enabled': False,
}
defaults.update(overrides)
return Employee.objects.create(**defaults)
def make_equipment(**overrides):
defaults = {
'equipment_type_id': _dir_id('equipment_type', 'laptop'),
'model': 'ThinkPad T14',
'serial_number': f'SN-{Equipment.objects.count() + 1}',
}
defaults.update(overrides)
return Equipment.objects.create(**defaults)
class ItManagerAccessTests(TestCase):
"""Сторінка й усі мутації доступні лише групі IT (або суперюзеру)."""
def setUp(self):
self.outsider = make_employee()
self.client = Client()
self.client.force_login(self.outsider)
def test_page_forbidden_for_non_it(self):
r = self.client.get('/it-manager/')
self.assertEqual(r.status_code, 403)
def test_equipment_create_forbidden_for_non_it(self):
r = self.client.post('/it-manager/equipment/create/', {
'equipment_type': 'laptop', 'model': 'X', 'serial_number': 'X1',
})
self.assertEqual(r.status_code, 403)
def test_worklog_create_forbidden_for_non_it(self):
r = self.client.post('/it-manager/worklog/create/', {
'work_type': 'repair', 'description': 'test',
})
self.assertEqual(r.status_code, 403)
class ItManagerEquipmentTests(TestCase):
def setUp(self):
self.it_user = make_employee(is_staff=False)
self.it_user.groups.add(Group.objects.get(name='IT'))
self.client = Client()
self.client.force_login(self.it_user)
def test_page_accessible_for_it_group(self):
r = self.client.get('/it-manager/')
self.assertEqual(r.status_code, 200)
def test_create_equipment(self):
r = self.client.post('/it-manager/equipment/create/', {
'equipment_type': 'laptop', 'model': 'ThinkPad T14', 'serial_number': 'SN-100',
}, HTTP_HX_REQUEST='true')
self.assertEqual(r.status_code, 200)
self.assertTrue(Equipment.objects.filter(serial_number='SN-100').exists())
def test_assign_equipment_creates_and_closes_history(self):
equipment = make_equipment()
emp1 = make_employee()
emp2 = make_employee()
self.client.post(f'/it-manager/equipment/{equipment.id}/update/', {
'status': 'assigned', 'assigned_to_id': emp1.id,
})
equipment.refresh_from_db()
self.assertEqual(equipment.assigned_to_id, emp1.id)
self.assertEqual(equipment.status.code, 'assigned')
self.assertEqual(EquipmentAssignment.objects.filter(equipment=equipment, returned_at__isnull=True).count(), 1)
self.client.post(f'/it-manager/equipment/{equipment.id}/update/', {
'status': 'assigned', 'assigned_to_id': emp2.id,
})
equipment.refresh_from_db()
self.assertEqual(equipment.assigned_to_id, emp2.id)
first_assignment = EquipmentAssignment.objects.get(equipment=equipment, assigned_to=emp1)
self.assertIsNotNone(first_assignment.returned_at)
self.assertEqual(
EquipmentAssignment.objects.filter(equipment=equipment, assigned_to=emp2, returned_at__isnull=True).count(), 1,
)
def test_unassign_equipment_closes_history_without_new_entry(self):
equipment = make_equipment()
emp1 = make_employee()
self.client.post(f'/it-manager/equipment/{equipment.id}/update/', {
'status': 'assigned', 'assigned_to_id': emp1.id,
})
self.client.post(f'/it-manager/equipment/{equipment.id}/update/', {
'status': 'stock', 'assigned_to_id': '',
})
equipment.refresh_from_db()
self.assertIsNone(equipment.assigned_to_id)
self.assertEqual(equipment.status.code, 'stock')
self.assertEqual(EquipmentAssignment.objects.filter(equipment=equipment, returned_at__isnull=True).count(), 0)
class ItManagerTicketTests(TestCase):
def setUp(self):
self.it_user = make_employee(is_staff=False)
self.it_user.groups.add(Group.objects.get(name='IT'))
self.requester = make_employee()
self.client = Client()
self.client.force_login(self.it_user)
def test_resolve_ticket_stamps_resolved_at(self):
ticket = Ticket.objects.create(
title='Не працює принтер', description='...',
ticket_type_id=_dir_id('ticket_type', 'repair'),
requester=self.requester,
)
self.client.post(f'/it-manager/tickets/{ticket.id}/update/', {
'status': 'resolved', 'assignee_id': self.it_user.id,
})
ticket.refresh_from_db()
self.assertEqual(ticket.status.code, 'resolved')
self.assertEqual(ticket.assignee_id, self.it_user.id)
self.assertIsNotNone(ticket.resolved_at)
def test_add_comment(self):
ticket = Ticket.objects.create(
title='Питання', description='...',
ticket_type_id=_dir_id('ticket_type', 'consultation'),
requester=self.requester,
)
r = self.client.post(f'/it-manager/tickets/{ticket.id}/comment/', {
'text': 'Дивимось', 'is_internal': 'on',
})
self.assertEqual(r.status_code, 200)
self.assertEqual(ticket.comments.count(), 1)
self.assertTrue(ticket.comments.first().is_internal)
class ItManagerWorkLogTests(TestCase):
def setUp(self):
self.it_user = make_employee(is_staff=False)
self.it_user.groups.add(Group.objects.get(name='IT'))
self.client = Client()
self.client.force_login(self.it_user)
def test_create_worklog(self):
r = self.client.post('/it-manager/worklog/create/', {
'work_type': 'maintenance', 'description': 'Профілактика серверної',
'hours_spent': '2.5',
})
self.assertEqual(r.status_code, 200)
log = WorkLog.objects.get(description='Профілактика серверної')
self.assertEqual(log.technician_id, self.it_user.id)
self.assertEqual(float(log.hours_spent), 2.5)
+20
View File
@@ -0,0 +1,20 @@
from django.urls import path
from . import views
urlpatterns = [
path('it-manager/', views.it_manager_view, name='it_manager'),
path('it-manager/equipment/models/create/', views.equipment_model_create_view, name='it_equipment_model_create'),
path('it-manager/equipment/bulk-create/', views.equipment_bulk_create_view, name='it_equipment_bulk_create'),
path('it-manager/equipment/bulk-assign/', views.equipment_bulk_assign_view, name='it_equipment_bulk_assign'),
path('it-manager/equipment/<int:pk>/update/', views.equipment_update_view, name='it_equipment_update'),
path('it-manager/tickets/<int:pk>/update/', views.ticket_update_view, name='it_ticket_update'),
path('it-manager/tickets/<int:pk>/comment/', views.ticket_comment_view, name='it_ticket_comment'),
path('it-manager/worklog/create/', views.worklog_create_view, name='it_worklog_create'),
path('it-manager/consumables/create/', views.consumable_create_view, name='it_consumable_create'),
path('it-manager/consumables/<int:pk>/update/', views.consumable_update_view, name='it_consumable_update'),
path('it-manager/providers/create/', views.provider_create_view, name='it_provider_create'),
path('it-manager/contracts/create/', views.contract_create_view, name='it_contract_create'),
path('it-manager/contracts/<int:pk>/update/', views.contract_update_view, name='it_contract_update'),
path('it-manager/servicecalls/create/', views.servicecall_create_view, name='it_servicecall_create'),
]
+584
View File
@@ -0,0 +1,584 @@
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 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):
return user.is_superuser or user.groups.filter(name='IT').exists()
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': Employee.objects.filter(groups__name='IT', is_active=True).order_by('last_name'),
'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)