diff --git a/apps/it_manager/admin.py b/apps/it_manager/admin.py index 2890790..35365f9 100644 --- a/apps/it_manager/admin.py +++ b/apps/it_manager/admin.py @@ -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( '{}', - 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( '' '{}' '', - 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( '{}', - 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( '{}', - 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( '{}', - 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('⚠ Просрочена') + return mark_safe('⚠ Просрочена') return '' overdue_display.short_description = _('SLA') + @staticmethod + def _ticket_status_directory(code): + from core.models.directories import Directory + return Directory.objects.get(directory_type__code='ticket_status', code=code) + @admin.action(description=_('Взять в работу')) def mark_in_progress(self, request, queryset): - 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('{}', count) - return format_html('0') + return mark_safe('0') 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( '{}', - 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( '{}', - 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 diff --git a/apps/it_manager/migrations/0007_create_it_group.py b/apps/it_manager/migrations/0007_create_it_group.py new file mode 100644 index 0000000..aed9e06 --- /dev/null +++ b/apps/it_manager/migrations/0007_create_it_group.py @@ -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), + ] diff --git a/apps/it_manager/migrations/0008_equipment_choices_to_directory.py b/apps/it_manager/migrations/0008_equipment_choices_to_directory.py new file mode 100644 index 0000000..b8a0fc4 --- /dev/null +++ b/apps/it_manager/migrations/0008_equipment_choices_to_directory.py @@ -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), + ] diff --git a/apps/it_manager/migrations/0009_equipment_choices_finalize.py b/apps/it_manager/migrations/0009_equipment_choices_finalize.py new file mode 100644 index 0000000..6849d57 --- /dev/null +++ b/apps/it_manager/migrations/0009_equipment_choices_finalize.py @@ -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': 'Оборудование'}, + ), + ] diff --git a/apps/it_manager/migrations/0010_ticket_choices_to_directory.py b/apps/it_manager/migrations/0010_ticket_choices_to_directory.py new file mode 100644 index 0000000..1d46f6a --- /dev/null +++ b/apps/it_manager/migrations/0010_ticket_choices_to_directory.py @@ -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), + ] diff --git a/apps/it_manager/migrations/0011_ticket_choices_finalize.py b/apps/it_manager/migrations/0011_ticket_choices_finalize.py new file mode 100644 index 0000000..5f9b4b1 --- /dev/null +++ b/apps/it_manager/migrations/0011_ticket_choices_finalize.py @@ -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'), + ), + ), + ] diff --git a/apps/it_manager/migrations/0012_worklog_servicecall_choices_to_directory.py b/apps/it_manager/migrations/0012_worklog_servicecall_choices_to_directory.py new file mode 100644 index 0000000..8f2615a --- /dev/null +++ b/apps/it_manager/migrations/0012_worklog_servicecall_choices_to_directory.py @@ -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), + ] diff --git a/apps/it_manager/migrations/0013_worklog_servicecall_choices_finalize.py b/apps/it_manager/migrations/0013_worklog_servicecall_choices_finalize.py new file mode 100644 index 0000000..2af5cc3 --- /dev/null +++ b/apps/it_manager/migrations/0013_worklog_servicecall_choices_finalize.py @@ -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='Канал', + ), + ), + ] diff --git a/apps/it_manager/migrations/0014_consumable_choices_to_directory.py b/apps/it_manager/migrations/0014_consumable_choices_to_directory.py new file mode 100644 index 0000000..8215fef --- /dev/null +++ b/apps/it_manager/migrations/0014_consumable_choices_to_directory.py @@ -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), + ] diff --git a/apps/it_manager/migrations/0015_consumable_choices_finalize.py b/apps/it_manager/migrations/0015_consumable_choices_finalize.py new file mode 100644 index 0000000..cb49685 --- /dev/null +++ b/apps/it_manager/migrations/0015_consumable_choices_finalize.py @@ -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': 'Расходники'}, + ), + ] diff --git a/apps/it_manager/migrations/0016_contract_choices_to_directory.py b/apps/it_manager/migrations/0016_contract_choices_to_directory.py new file mode 100644 index 0000000..97db63c --- /dev/null +++ b/apps/it_manager/migrations/0016_contract_choices_to_directory.py @@ -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), + ] diff --git a/apps/it_manager/migrations/0017_contract_choices_finalize.py b/apps/it_manager/migrations/0017_contract_choices_finalize.py new file mode 100644 index 0000000..4cdd3ad --- /dev/null +++ b/apps/it_manager/migrations/0017_contract_choices_finalize.py @@ -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': 'Договоры с провайдерами'}, + ), + ] diff --git a/apps/it_manager/migrations/0018_equipment_model_catalog.py b/apps/it_manager/migrations/0018_equipment_model_catalog.py new file mode 100644 index 0000000..2bb5a21 --- /dev/null +++ b/apps/it_manager/migrations/0018_equipment_model_catalog.py @@ -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='Устройство'), + ), + ] diff --git a/apps/it_manager/models/__init__.py b/apps/it_manager/models/__init__.py index 41f3302..02ddeab 100644 --- a/apps/it_manager/models/__init__.py +++ b/apps/it_manager/models/__init__.py @@ -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 diff --git a/apps/it_manager/models/consumables.py b/apps/it_manager/models/consumables.py index 2d9fcfe..20f9bd6 100644 --- a/apps/it_manager/models/consumables.py +++ b/apps/it_manager/models/consumables.py @@ -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') diff --git a/apps/it_manager/models/equipment.py b/apps/it_manager/models/equipment.py index 5586764..63bedab 100644 --- a/apps/it_manager/models/equipment.py +++ b/apps/it_manager/models/equipment.py @@ -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): diff --git a/apps/it_manager/models/providers.py b/apps/it_manager/models/providers.py index 0f6ac2f..7e6fe38 100644 --- a/apps/it_manager/models/providers.py +++ b/apps/it_manager/models/providers.py @@ -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 diff --git a/apps/it_manager/models/tickets.py b/apps/it_manager/models/tickets.py index bbbd653..2ba1708 100644 --- a/apps/it_manager/models/tickets.py +++ b/apps/it_manager/models/tickets.py @@ -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 diff --git a/apps/it_manager/models/worklogs.py b/apps/it_manager/models/worklogs.py index ba7c0c4..0df28e8 100644 --- a/apps/it_manager/models/worklogs.py +++ b/apps/it_manager/models/worklogs.py @@ -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 diff --git a/apps/it_manager/tests.py b/apps/it_manager/tests.py new file mode 100644 index 0000000..e0f6619 --- /dev/null +++ b/apps/it_manager/tests.py @@ -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) diff --git a/apps/it_manager/urls.py b/apps/it_manager/urls.py new file mode 100644 index 0000000..ebbd133 --- /dev/null +++ b/apps/it_manager/urls.py @@ -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//update/', views.equipment_update_view, name='it_equipment_update'), + path('it-manager/tickets//update/', views.ticket_update_view, name='it_ticket_update'), + path('it-manager/tickets//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//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//update/', views.contract_update_view, name='it_contract_update'), + path('it-manager/servicecalls/create/', views.servicecall_create_view, name='it_servicecall_create'), +] diff --git a/apps/it_manager/views.py b/apps/it_manager/views.py new file mode 100644 index 0000000..4623fc7 --- /dev/null +++ b/apps/it_manager/views.py @@ -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) diff --git a/templates/pages/it-manager.html b/templates/pages/it-manager.html new file mode 100644 index 0000000..8fc8953 --- /dev/null +++ b/templates/pages/it-manager.html @@ -0,0 +1,1131 @@ +{% load common_tags %} +
+ +
+

IT-відділ

+

Інвентаризація обладнання, черга заявок та журнал виконаних робіт

+
+ + +
+ + + + + + +
+ + +
+ +
+
+
Всього одиниць
+
{{ equipment_list|length }}
+
+
+
На складі
+
{{ equipment_counts.stock }}
+
+
+
Видано
+
{{ equipment_counts.assigned }}
+
+
+
В ремонті / списано
+
{{ equipment_counts.repair|add:equipment_counts.written_off }}
+
+
+ + {% if equipment_form_message %} +
{{ equipment_form_message }}
+ {% endif %} + {% if equipment_form_error %} +
{{ equipment_form_error }}
+ {% endif %} + +
+ + +
+ + + +
+
+ {% csrf_token %} +
+ Додати екземпляри + +
+
+
+ +
+ +
+
Немає потрібної? Додати модель у каталог
+
+
+ + +
Скільки серійників — стільки одиниць буде створено. Дублікати й вже наявні пропускаються.
+
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+
+ + +
+
+
+
+ + + +
+
+ Каталог моделей + +
+
+ {% csrf_token %} +
Нова модель
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
У каталозі ({{ equipment_models|length }})
+
+ {% for m in equipment_models %} +
+
+
{{ m }}
+
{{ m.equipment_type.name }}
+
+ {{ m.units_total }} од. +
+ {% empty %} +
Каталог порожній — додайте першу модель.
+ {% endfor %} +
+
+
+ + + + + +
+
+ + + + + + + + + + + + + + {% for equipment in equipment_page %} + + + + + + + + + + + + + + + {% empty %} + + {% endfor %} + +
Тип / Бренд / МодельС/н · Інв. №ЛокаціяВиданоСтатус
+ + + ▸ + + {{ equipment.equipment_type.name }} +
{{ equipment.brand.name|default:"" }} {{ equipment.model }}
+
+ {{ equipment.serial_number }}{% if equipment.inventory_number %} · {{ equipment.inventory_number }}{% endif %} + {{ equipment.location|default:"—" }}{{ equipment.assigned_to.get_full_name|default:"—" }} + {% if equipment.status.code == 'stock' %}На складі + {% elif equipment.status.code == 'assigned' %}Видано + {% elif equipment.status.code == 'repair' %}В ремонті + {% else %}Списано{% endif %} +
Обладнання ще не додано
+
+ {% include "partials/pagination.html" with page=equipment_page base_url="/it-manager/" page_param="equipment_page" size_param="equipment_per_page" item_label="одиниць обладнання" %} +
+
+ + + + + + + + + + + + + + + + + + + + + + + {% if it_active_tab and it_active_tab != 'equipment' %} + + {% endif %} + + +