IT-менеджер: каталог моделей оборудования + перевод choices на справочники
- EquipmentModel (каталог: тип+бренд+модель заводятся раз) отделён от Equipment (экземпляр: серийник+локация); миграция сворачивает данные. Массовый ввод серийников, массовая выдача на локации/сотрудников - Enum-choices (тип/статус/приоритет заявок, вид работ, каналы, расходники, договоры) переведены на core.Directory с quick-add из формы - Фронт-страница /it-manager/: вкладки оборудование/заявки/работы/ расходники/провайдеры/обращения; searchable-select локаций - Тесты (10) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+87
-60
@@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
|
||||
from .models import (
|
||||
Equipment, EquipmentAssignment,
|
||||
Equipment, EquipmentAssignment, EquipmentModel,
|
||||
Ticket, TicketComment,
|
||||
InternetProvider, InternetContract,
|
||||
WorkLog, ServiceCall,
|
||||
@@ -78,23 +78,37 @@ class ConsumableInline(TabularInline):
|
||||
return super().get_queryset(request)
|
||||
|
||||
|
||||
@admin.register(EquipmentModel)
|
||||
class EquipmentModelAdmin(ModelAdmin):
|
||||
list_display = ['name', 'equipment_type', 'brand', 'units_count', 'warranty_months', 'updated_at']
|
||||
list_filter = ['equipment_type', 'brand']
|
||||
search_fields = ['name', 'brand__name', 'notes']
|
||||
list_select_related = ['equipment_type', 'brand']
|
||||
ordering = ['equipment_type__name', 'brand__name', 'name']
|
||||
|
||||
def units_count(self, obj):
|
||||
return obj.units.count()
|
||||
units_count.short_description = _('Экземпляров')
|
||||
|
||||
|
||||
@admin.register(Equipment)
|
||||
class EquipmentAdmin(ModelAdmin):
|
||||
list_display = [
|
||||
'type_display', 'brand', 'model', 'serial_number',
|
||||
'type_display', 'model_display', 'serial_number',
|
||||
'status_display', 'assigned_to', 'location',
|
||||
'warranty_display', 'updated_at',
|
||||
]
|
||||
list_filter = ['equipment_type', 'status', 'location__location_type', 'brand']
|
||||
search_fields = ['brand__name', 'model', 'serial_number', 'inventory_number', 'notes']
|
||||
list_select_related = ['assigned_to', 'location', 'brand']
|
||||
ordering = ['equipment_type', 'brand__name', 'model']
|
||||
list_filter = ['equipment_model__equipment_type', 'status', 'location__location_type', 'equipment_model__brand']
|
||||
search_fields = ['equipment_model__name', 'equipment_model__brand__name', 'serial_number', 'inventory_number', 'notes']
|
||||
list_select_related = ['assigned_to', 'location', 'equipment_model__brand', 'equipment_model__equipment_type', 'status']
|
||||
autocomplete_fields = ['equipment_model']
|
||||
ordering = ['equipment_model__equipment_type__name', 'equipment_model__name']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at', 'warranty_display']
|
||||
|
||||
fieldsets = (
|
||||
(_('Оборудование'), {
|
||||
'fields': ('equipment_type', 'brand', 'model', 'serial_number', 'inventory_number'),
|
||||
'fields': ('equipment_model', 'serial_number', 'inventory_number'),
|
||||
}),
|
||||
(_('Статус и размещение'), {
|
||||
'fields': ('status', 'assigned_to', 'location'),
|
||||
@@ -116,7 +130,7 @@ class EquipmentAdmin(ModelAdmin):
|
||||
|
||||
def get_inlines(self, request, obj):
|
||||
inlines = [EquipmentAssignmentInline]
|
||||
if obj and obj.equipment_type in ('printer', 'scanner'):
|
||||
if obj and obj.equipment_model.equipment_type.code in ('printer', 'scanner'):
|
||||
inlines.append(ConsumableInline)
|
||||
return inlines
|
||||
|
||||
@@ -135,19 +149,24 @@ class EquipmentAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def type_display(self, obj):
|
||||
icon = self.EQUIPMENT_ICONS.get(obj.equipment_type, '📦')
|
||||
return format_html('{} {}', icon, obj.get_equipment_type_display())
|
||||
icon = self.EQUIPMENT_ICONS.get(obj.equipment_model.equipment_type.code, '📦')
|
||||
return format_html('{} {}', icon, obj.equipment_model.equipment_type.name)
|
||||
type_display.short_description = _('Тип')
|
||||
type_display.admin_order_field = 'equipment_type'
|
||||
type_display.admin_order_field = 'equipment_model__equipment_type__name'
|
||||
|
||||
def model_display(self, obj):
|
||||
return str(obj.equipment_model)
|
||||
model_display.short_description = _('Модель')
|
||||
model_display.admin_order_field = 'equipment_model__name'
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display()
|
||||
color, obj.status.name
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
status_display.admin_order_field = 'status__name'
|
||||
|
||||
def warranty_display(self, obj):
|
||||
status = obj.warranty_status
|
||||
@@ -174,9 +193,9 @@ class ConsumableAdmin(ModelAdmin):
|
||||
]
|
||||
list_filter = ['consumable_type', 'status', 'color']
|
||||
search_fields = ['brand', 'model_number', 'notes',
|
||||
'equipment__brand__name', 'equipment__model']
|
||||
list_select_related = ['equipment', 'location']
|
||||
ordering = ['consumable_type', 'color', 'brand']
|
||||
'equipment__equipment_model__brand__name', 'equipment__equipment_model__name']
|
||||
list_select_related = ['equipment', 'location', 'consumable_type', 'color', 'status']
|
||||
ordering = ['consumable_type__name', 'color__name', 'brand']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
@@ -218,31 +237,31 @@ class ConsumableAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def consumable_type_display(self, obj):
|
||||
return obj.get_consumable_type_display()
|
||||
return obj.consumable_type.name
|
||||
consumable_type_display.short_description = _('Тип')
|
||||
consumable_type_display.admin_order_field = 'consumable_type'
|
||||
consumable_type_display.admin_order_field = 'consumable_type__name'
|
||||
|
||||
def color_display(self, obj):
|
||||
bg = self.COLOR_CIRCLES.get(obj.color, '#9ca3af')
|
||||
bg = self.COLOR_CIRCLES.get(obj.color.code, '#9ca3af')
|
||||
style = f'background:{bg}' if 'gradient' not in bg else f'background-image:{bg}'
|
||||
return format_html(
|
||||
'<span style="display:inline-flex;align-items:center;gap:6px;">'
|
||||
'<span style="display:inline-block;width:12px;height:12px;'
|
||||
'border-radius:50%;{};border:1px solid #e5e7eb;"></span>{}'
|
||||
'</span>',
|
||||
style, obj.get_color_display(),
|
||||
style, obj.color.name,
|
||||
)
|
||||
color_display.short_description = _('Цвет')
|
||||
color_display.admin_order_field = 'color'
|
||||
color_display.admin_order_field = 'color__name'
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display(),
|
||||
color, obj.status.name,
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
status_display.admin_order_field = 'status__name'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -266,7 +285,7 @@ class TicketAdmin(ModelAdmin):
|
||||
list_filter = ['status', 'priority', 'ticket_type', 'assignee']
|
||||
search_fields = ['number', 'title', 'description', 'requester__last_name',
|
||||
'requester__first_name']
|
||||
list_select_related = ['requester', 'assignee', 'equipment', 'location']
|
||||
list_select_related = ['requester', 'assignee', 'equipment', 'location', 'ticket_type', 'priority', 'status']
|
||||
ordering = ['-created_at']
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
@@ -311,53 +330,61 @@ class TicketAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def ticket_type_display(self, obj):
|
||||
return obj.get_ticket_type_display()
|
||||
return obj.ticket_type.name
|
||||
ticket_type_display.short_description = _('Тип')
|
||||
ticket_type_display.admin_order_field = 'ticket_type'
|
||||
ticket_type_display.admin_order_field = 'ticket_type__name'
|
||||
|
||||
def priority_display(self, obj):
|
||||
color = self.PRIORITY_COLORS.get(obj.priority, '#6b7280')
|
||||
color = self.PRIORITY_COLORS.get(obj.priority.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_priority_display()
|
||||
color, obj.priority.name
|
||||
)
|
||||
priority_display.short_description = _('Приоритет')
|
||||
priority_display.admin_order_field = 'priority'
|
||||
priority_display.admin_order_field = 'priority__name'
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display()
|
||||
color, obj.status.name
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
status_display.admin_order_field = 'status__name'
|
||||
|
||||
def overdue_display(self, obj):
|
||||
if obj.is_overdue:
|
||||
return format_html('<span style="color:#dc2626;font-weight:bold;">⚠ Просрочена</span>')
|
||||
return mark_safe('<span style="color:#dc2626;font-weight:bold;">⚠ Просрочена</span>')
|
||||
return ''
|
||||
overdue_display.short_description = _('SLA')
|
||||
|
||||
@staticmethod
|
||||
def _ticket_status_directory(code):
|
||||
from core.models.directories import Directory
|
||||
return Directory.objects.get(directory_type__code='ticket_status', code=code)
|
||||
|
||||
@admin.action(description=_('Взять в работу'))
|
||||
def mark_in_progress(self, request, queryset):
|
||||
updated = queryset.filter(status='new').update(
|
||||
status='in_progress', assignee=request.user
|
||||
new_status = self._ticket_status_directory('new')
|
||||
in_progress_status = self._ticket_status_directory('in_progress')
|
||||
updated = queryset.filter(status=new_status).update(
|
||||
status=in_progress_status, assignee=request.user
|
||||
)
|
||||
self.message_user(request, f'Взято в работу: {updated}')
|
||||
|
||||
@admin.action(description=_('Отметить решёнными'))
|
||||
def mark_resolved(self, request, queryset):
|
||||
from django.utils import timezone
|
||||
for ticket in queryset.exclude(status__in=['resolved', 'closed']):
|
||||
ticket.status = 'resolved'
|
||||
resolved_status = self._ticket_status_directory('resolved')
|
||||
for ticket in queryset.exclude(status__code__in=['resolved', 'closed']):
|
||||
ticket.status = resolved_status
|
||||
ticket.save()
|
||||
self.message_user(request, f'Отмечено решёнными: {queryset.count()}')
|
||||
|
||||
@admin.action(description=_('Закрыть'))
|
||||
def mark_closed(self, request, queryset):
|
||||
for ticket in queryset.exclude(status='closed'):
|
||||
ticket.status = 'closed'
|
||||
closed_status = self._ticket_status_directory('closed')
|
||||
for ticket in queryset.exclude(status__code='closed'):
|
||||
ticket.status = closed_status
|
||||
ticket.save()
|
||||
self.message_user(request, f'Закрыто: {queryset.count()}')
|
||||
|
||||
@@ -409,7 +436,7 @@ class InternetProviderAdmin(ModelAdmin):
|
||||
count = obj.active_contracts_count
|
||||
if count:
|
||||
return format_html('<span style="color:#16a34a;font-weight:bold;">{}</span>', count)
|
||||
return format_html('<span style="color:#9ca3af;">0</span>')
|
||||
return mark_safe('<span style="color:#9ca3af;">0</span>')
|
||||
active_contracts_count.short_description = _('Активных договоров')
|
||||
|
||||
|
||||
@@ -424,7 +451,7 @@ class InternetContractAdmin(ModelAdmin):
|
||||
list_filter = ['status', 'service_type', 'connection_type', 'provider', 'location__location_type']
|
||||
search_fields = ['contract_number', 'account_number', 'static_ips',
|
||||
'provider__name', 'location__address']
|
||||
list_select_related = ['provider', 'location']
|
||||
list_select_related = ['provider', 'location', 'service_type', 'connection_type', 'status']
|
||||
ordering = ['location', 'provider']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at', 'ip_list_display']
|
||||
@@ -463,28 +490,28 @@ class InternetContractAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def status_display(self, obj):
|
||||
color = self.STATUS_COLORS.get(obj.status, '#6b7280')
|
||||
color = self.STATUS_COLORS.get(obj.status.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_status_display()
|
||||
color, obj.status.name
|
||||
)
|
||||
status_display.short_description = _('Статус')
|
||||
status_display.admin_order_field = 'status'
|
||||
status_display.admin_order_field = 'status__name'
|
||||
|
||||
def service_type_display(self, obj):
|
||||
return obj.get_service_type_display()
|
||||
return obj.service_type.name
|
||||
service_type_display.short_description = _('Услуга')
|
||||
service_type_display.admin_order_field = 'service_type'
|
||||
service_type_display.admin_order_field = 'service_type__name'
|
||||
|
||||
def connection_type_display(self, obj):
|
||||
return obj.get_connection_type_display() if obj.connection_type else '—'
|
||||
return obj.connection_type.name if obj.connection_type else '—'
|
||||
connection_type_display.short_description = _('Технология')
|
||||
connection_type_display.admin_order_field = 'connection_type'
|
||||
connection_type_display.admin_order_field = 'connection_type__name'
|
||||
|
||||
def expiry_display(self, obj):
|
||||
if not obj.end_date:
|
||||
return '—'
|
||||
if obj.status != 'active':
|
||||
if obj.status.code != 'active':
|
||||
return str(obj.end_date)
|
||||
if obj.is_expiring_soon:
|
||||
days = (obj.end_date - timezone.now().date()).days
|
||||
@@ -571,7 +598,7 @@ class WorkLogAdmin(ModelAdmin):
|
||||
list_filter = ['work_type', 'technician', 'date']
|
||||
search_fields = ['description', 'result', 'technician__last_name',
|
||||
'technician__first_name']
|
||||
list_select_related = ['technician', 'location', 'ticket', 'equipment']
|
||||
list_select_related = ['technician', 'location', 'ticket', 'equipment', 'work_type']
|
||||
ordering = ['-date', '-created_at']
|
||||
date_hierarchy = 'date'
|
||||
|
||||
@@ -604,13 +631,13 @@ class WorkLogAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def work_type_display(self, obj):
|
||||
color = self.WORK_TYPE_COLORS.get(obj.work_type, '#6b7280')
|
||||
color = self.WORK_TYPE_COLORS.get(obj.work_type.code, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="color:{};font-weight:bold;">{}</span>',
|
||||
color, obj.get_work_type_display()
|
||||
color, obj.work_type.name
|
||||
)
|
||||
work_type_display.short_description = _('Вид работ')
|
||||
work_type_display.admin_order_field = 'work_type'
|
||||
work_type_display.admin_order_field = 'work_type__name'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change and not obj.technician_id:
|
||||
@@ -627,7 +654,7 @@ class ServiceCallAdmin(ModelAdmin):
|
||||
search_fields = ['description', 'resolution',
|
||||
'caller__last_name', 'caller__first_name',
|
||||
'handler__last_name', 'handler__first_name']
|
||||
list_select_related = ['caller', 'handler', 'location', 'ticket']
|
||||
list_select_related = ['caller', 'handler', 'location', 'ticket', 'channel']
|
||||
ordering = ['-date_time']
|
||||
date_hierarchy = 'date_time'
|
||||
|
||||
@@ -658,10 +685,10 @@ class ServiceCallAdmin(ModelAdmin):
|
||||
}
|
||||
|
||||
def channel_display(self, obj):
|
||||
icon = self.CHANNEL_ICONS.get(obj.channel, '📋')
|
||||
return format_html('{} {}', icon, obj.get_channel_display())
|
||||
icon = self.CHANNEL_ICONS.get(obj.channel.code, '📋')
|
||||
return format_html('{} {}', icon, obj.channel.name)
|
||||
channel_display.short_description = _('Канал')
|
||||
channel_display.admin_order_field = 'channel'
|
||||
channel_display.admin_order_field = 'channel__name'
|
||||
|
||||
def duration_display(self, obj):
|
||||
return obj.duration_display
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Data migration: створюємо auth.Group «IT» + PermissionGroup, за зразком
|
||||
apps/employees/migrations/0004_create_role_groups.py."""
|
||||
from django.db import migrations
|
||||
|
||||
GROUP_NAME = 'IT'
|
||||
|
||||
|
||||
def create_group(apps, schema_editor):
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
PermissionGroup = apps.get_model('employees', 'PermissionGroup')
|
||||
|
||||
group, _ = Group.objects.get_or_create(name=GROUP_NAME)
|
||||
PermissionGroup.objects.get_or_create(
|
||||
group=group,
|
||||
defaults={'description': 'Співробітники IT-відділу: доступ до /it-manager/', 'is_active': True},
|
||||
)
|
||||
|
||||
|
||||
def delete_group(apps, schema_editor):
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
Group.objects.filter(name=GROUP_NAME).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0006_equipment_brand_to_directory'),
|
||||
('employees', '0004_create_role_groups'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_group, delete_group),
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Equipment.equipment_type / Equipment.status: CharField(choices=) → FK core.Directory,
|
||||
за зразком 0006_equipment_brand_to_directory.py. Directory.code дорівнює старому
|
||||
буквальному значенню — уся логіка, що порівнює по коду, лишається робочою."""
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
EQUIPMENT_TYPES = [
|
||||
('laptop', 'Ноутбук'),
|
||||
('desktop', 'ПК'),
|
||||
('monitor', 'Монитор'),
|
||||
('printer', 'Принтер'),
|
||||
('phone', 'Телефон'),
|
||||
('tablet', 'Планшет'),
|
||||
('router', 'Роутер/Свитч'),
|
||||
('ups', 'ИБП'),
|
||||
('scanner', 'Сканер'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('stock', 'На складе'),
|
||||
('assigned', 'Выдано'),
|
||||
('repair', 'В ремонте'),
|
||||
('written_off', 'Списано'),
|
||||
]
|
||||
|
||||
|
||||
def seed_directory(apps, type_code, type_name, choices):
|
||||
DirectoryType = apps.get_model('core', 'DirectoryType')
|
||||
Directory = apps.get_model('core', 'Directory')
|
||||
|
||||
directory_type, _ = DirectoryType.objects.get_or_create(
|
||||
code=type_code, defaults={'name': type_name, 'hierarchy_type': 'strict'},
|
||||
)
|
||||
result = {}
|
||||
for code, name in choices:
|
||||
directory, _ = Directory.objects.get_or_create(
|
||||
directory_type=directory_type, code=code, defaults={'name': name, 'is_active': True},
|
||||
)
|
||||
result[code] = directory
|
||||
return result
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Equipment = apps.get_model('it_manager', 'Equipment')
|
||||
|
||||
types_by_code = seed_directory(apps, 'equipment_type', 'Тип обладнання', EQUIPMENT_TYPES)
|
||||
statuses_by_code = seed_directory(apps, 'equipment_status', 'Статус обладнання', STATUS_CHOICES)
|
||||
|
||||
for equipment in Equipment.objects.all():
|
||||
equipment.equipment_type_new = types_by_code.get(equipment.equipment_type)
|
||||
equipment.status_new = statuses_by_code.get(equipment.status)
|
||||
equipment.save(update_fields=['equipment_type_new', 'status_new'])
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
Equipment = apps.get_model('it_manager', 'Equipment')
|
||||
for equipment in Equipment.objects.select_related('equipment_type_new', 'status_new'):
|
||||
equipment.equipment_type = equipment.equipment_type_new.code if equipment.equipment_type_new else 'other'
|
||||
equipment.status = equipment.status_new.code if equipment.status_new else 'stock'
|
||||
equipment.save(update_fields=['equipment_type', 'status'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0007_create_it_group'),
|
||||
('core', '0002_connector_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(model_name='equipment', name='it_manager__status_f1197a_idx'),
|
||||
migrations.RemoveIndex(model_name='equipment', name='it_manager__equipme_0beeb3_idx'),
|
||||
migrations.AddField(
|
||||
model_name='equipment',
|
||||
name='equipment_type_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'equipment_type', 'is_active': True},
|
||||
related_name='equipment_by_type', to='core.directory', verbose_name='Тип',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='equipment',
|
||||
name='status_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'equipment_status', 'is_active': True},
|
||||
related_name='equipment_by_status', to='core.directory', verbose_name='Статус',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Друга фаза 0008: прибрати старі CharField, перейменувати нові FK на місце.
|
||||
Окрема міграція (окрема транзакція) — інакше Postgres падає з
|
||||
"cannot ALTER TABLE core_directory because there are pending trigger events"
|
||||
через django-treenode тригери на core_directory, викликані масовими
|
||||
get_or_create() у RunPython 0008 в межах тієї самої транзакції."""
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
from core.models.directories import directory_default
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0008_equipment_choices_to_directory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name='equipment', name='equipment_type'),
|
||||
migrations.RemoveField(model_name='equipment', name='status'),
|
||||
migrations.RenameField(model_name='equipment', old_name='equipment_type_new', new_name='equipment_type'),
|
||||
migrations.RenameField(model_name='equipment', old_name='status_new', new_name='status'),
|
||||
migrations.AlterField(
|
||||
model_name='equipment',
|
||||
name='equipment_type',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'equipment_type', 'is_active': True},
|
||||
related_name='equipment_by_type', to='core.directory', verbose_name='Тип',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='equipment',
|
||||
name='status',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'equipment_status', 'is_active': True},
|
||||
related_name='equipment_by_status', to='core.directory', verbose_name='Статус',
|
||||
default=directory_default('equipment_status', 'stock'),
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='equipment',
|
||||
options={'ordering': ['equipment_type__name', 'brand__name', 'model'], 'verbose_name': 'Оборудование', 'verbose_name_plural': 'Оборудование'},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Ticket.ticket_type / priority / status: CharField(choices=) → FK core.Directory.
|
||||
Фаза 1 (дані): seed довідників + бекфіл. Фаза 2 (схема) — окрема міграція
|
||||
0011, інакше падає на "pending trigger events" на core_directory (django-treenode)."""
|
||||
from django.db import migrations, models
|
||||
|
||||
TICKET_TYPES = [
|
||||
('repair', 'Ремонт'),
|
||||
('replacement', 'Замена оборудования'),
|
||||
('new_equipment', 'Новое оборудование'),
|
||||
('access', 'Доступ / Аккаунт'),
|
||||
('consultation', 'Консультация'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('low', 'Низкий'),
|
||||
('medium', 'Средний'),
|
||||
('high', 'Высокий'),
|
||||
('critical', 'Критический'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('new', 'Новая'),
|
||||
('in_progress', 'В работе'),
|
||||
('waiting', 'Ожидание'),
|
||||
('resolved', 'Решена'),
|
||||
('closed', 'Закрыта'),
|
||||
]
|
||||
|
||||
|
||||
def seed_directory(apps, type_code, type_name, choices):
|
||||
DirectoryType = apps.get_model('core', 'DirectoryType')
|
||||
Directory = apps.get_model('core', 'Directory')
|
||||
|
||||
directory_type, _ = DirectoryType.objects.get_or_create(
|
||||
code=type_code, defaults={'name': type_name, 'hierarchy_type': 'strict'},
|
||||
)
|
||||
result = {}
|
||||
for code, name in choices:
|
||||
directory, _ = Directory.objects.get_or_create(
|
||||
directory_type=directory_type, code=code, defaults={'name': name, 'is_active': True},
|
||||
)
|
||||
result[code] = directory
|
||||
return result
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Ticket = apps.get_model('it_manager', 'Ticket')
|
||||
|
||||
types_by_code = seed_directory(apps, 'ticket_type', 'Тип заявки', TICKET_TYPES)
|
||||
priorities_by_code = seed_directory(apps, 'ticket_priority', 'Пріоритет заявки', PRIORITY_CHOICES)
|
||||
statuses_by_code = seed_directory(apps, 'ticket_status', 'Статус заявки', STATUS_CHOICES)
|
||||
|
||||
for ticket in Ticket.objects.all():
|
||||
ticket.ticket_type_new = types_by_code.get(ticket.ticket_type)
|
||||
ticket.priority_new = priorities_by_code.get(ticket.priority)
|
||||
ticket.status_new = statuses_by_code.get(ticket.status)
|
||||
ticket.save(update_fields=['ticket_type_new', 'priority_new', 'status_new'])
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
Ticket = apps.get_model('it_manager', 'Ticket')
|
||||
for ticket in Ticket.objects.select_related('ticket_type_new', 'priority_new', 'status_new'):
|
||||
ticket.ticket_type = ticket.ticket_type_new.code if ticket.ticket_type_new else 'other'
|
||||
ticket.priority = ticket.priority_new.code if ticket.priority_new else 'medium'
|
||||
ticket.status = ticket.status_new.code if ticket.status_new else 'new'
|
||||
ticket.save(update_fields=['ticket_type', 'priority', 'status'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0009_equipment_choices_finalize'),
|
||||
('core', '0002_connector_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(model_name='ticket', name='it_manager__status_6e5b9f_idx'),
|
||||
migrations.RemoveIndex(model_name='ticket', name='it_manager__request_a11dcf_idx'),
|
||||
migrations.RemoveIndex(model_name='ticket', name='it_manager__assigne_555f6b_idx'),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='ticket_type_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'ticket_type', 'is_active': True},
|
||||
related_name='tickets_by_type', to='core.directory', verbose_name='Тип',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='priority_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'ticket_priority', 'is_active': True},
|
||||
related_name='tickets_by_priority', to='core.directory', verbose_name='Приоритет',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='status_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'ticket_status', 'is_active': True},
|
||||
related_name='tickets_by_status', to='core.directory', verbose_name='Статус',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
from core.models.directories import directory_default
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0010_ticket_choices_to_directory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name='ticket', name='ticket_type'),
|
||||
migrations.RemoveField(model_name='ticket', name='priority'),
|
||||
migrations.RemoveField(model_name='ticket', name='status'),
|
||||
migrations.RenameField(model_name='ticket', old_name='ticket_type_new', new_name='ticket_type'),
|
||||
migrations.RenameField(model_name='ticket', old_name='priority_new', new_name='priority'),
|
||||
migrations.RenameField(model_name='ticket', old_name='status_new', new_name='status'),
|
||||
migrations.AlterField(
|
||||
model_name='ticket',
|
||||
name='ticket_type',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'ticket_type', 'is_active': True},
|
||||
related_name='tickets_by_type', to='core.directory', verbose_name='Тип',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ticket',
|
||||
name='priority',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'ticket_priority', 'is_active': True},
|
||||
related_name='tickets_by_priority', to='core.directory', verbose_name='Приоритет',
|
||||
default=directory_default('ticket_priority', 'medium'),
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ticket',
|
||||
name='status',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'ticket_status', 'is_active': True},
|
||||
related_name='tickets_by_status', to='core.directory', verbose_name='Статус',
|
||||
default=directory_default('ticket_status', 'new'),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
WORK_TYPES = [
|
||||
('maintenance', 'Техническое обслуживание'),
|
||||
('installation', 'Установка / настройка'),
|
||||
('repair', 'Ремонт'),
|
||||
('network', 'Сетевые работы'),
|
||||
('software', 'Программное обеспечение'),
|
||||
('backup', 'Резервное копирование'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
CHANNEL_CHOICES = [
|
||||
('phone', 'Телефон'),
|
||||
('chat', 'Чат / мессенджер'),
|
||||
('email', 'Email'),
|
||||
('personal', 'Личный визит'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
|
||||
def seed_directory(apps, type_code, type_name, choices):
|
||||
DirectoryType = apps.get_model('core', 'DirectoryType')
|
||||
Directory = apps.get_model('core', 'Directory')
|
||||
|
||||
directory_type, _ = DirectoryType.objects.get_or_create(
|
||||
code=type_code, defaults={'name': type_name, 'hierarchy_type': 'strict'},
|
||||
)
|
||||
result = {}
|
||||
for code, name in choices:
|
||||
directory, _ = Directory.objects.get_or_create(
|
||||
directory_type=directory_type, code=code, defaults={'name': name, 'is_active': True},
|
||||
)
|
||||
result[code] = directory
|
||||
return result
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
WorkLog = apps.get_model('it_manager', 'WorkLog')
|
||||
ServiceCall = apps.get_model('it_manager', 'ServiceCall')
|
||||
|
||||
types_by_code = seed_directory(apps, 'worklog_type', 'Вид робіт', WORK_TYPES)
|
||||
channels_by_code = seed_directory(apps, 'servicecall_channel', 'Канал звернення', CHANNEL_CHOICES)
|
||||
|
||||
for log in WorkLog.objects.all():
|
||||
log.work_type_new = types_by_code.get(log.work_type)
|
||||
log.save(update_fields=['work_type_new'])
|
||||
|
||||
for call in ServiceCall.objects.all():
|
||||
call.channel_new = channels_by_code.get(call.channel)
|
||||
call.save(update_fields=['channel_new'])
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
WorkLog = apps.get_model('it_manager', 'WorkLog')
|
||||
ServiceCall = apps.get_model('it_manager', 'ServiceCall')
|
||||
for log in WorkLog.objects.select_related('work_type_new'):
|
||||
log.work_type = log.work_type_new.code if log.work_type_new else 'other'
|
||||
log.save(update_fields=['work_type'])
|
||||
for call in ServiceCall.objects.select_related('channel_new'):
|
||||
call.channel = call.channel_new.code if call.channel_new else 'other'
|
||||
call.save(update_fields=['channel'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0011_ticket_choices_finalize'),
|
||||
('core', '0002_connector_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(model_name='worklog', name='it_manager__work_ty_8d1ee5_idx'),
|
||||
migrations.RemoveIndex(model_name='servicecall', name='it_manager__channel_098784_idx'),
|
||||
migrations.AddField(
|
||||
model_name='worklog',
|
||||
name='work_type_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True},
|
||||
related_name='worklogs_by_type', to='core.directory', verbose_name='Вид работ',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='servicecall',
|
||||
name='channel_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'servicecall_channel', 'is_active': True},
|
||||
related_name='servicecalls_by_channel', to='core.directory', verbose_name='Канал',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0012_worklog_servicecall_choices_to_directory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name='worklog', name='work_type'),
|
||||
migrations.RemoveField(model_name='servicecall', name='channel'),
|
||||
migrations.RenameField(model_name='worklog', old_name='work_type_new', new_name='work_type'),
|
||||
migrations.RenameField(model_name='servicecall', old_name='channel_new', new_name='channel'),
|
||||
migrations.AlterField(
|
||||
model_name='worklog',
|
||||
name='work_type',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True},
|
||||
related_name='worklogs_by_type', to='core.directory', verbose_name='Вид работ',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='servicecall',
|
||||
name='channel',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'servicecall_channel', 'is_active': True},
|
||||
related_name='servicecalls_by_channel', to='core.directory', verbose_name='Канал',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
CONSUMABLE_TYPES = [
|
||||
('cartridge', 'Картридж'),
|
||||
('toner', 'Тонер'),
|
||||
('drum', 'Фотобарабан'),
|
||||
('ink', 'Чернила'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
COLOR_CHOICES = [
|
||||
('black', 'Чёрный'),
|
||||
('cyan', 'Голубой (C)'),
|
||||
('magenta', 'Пурпурный (M)'),
|
||||
('yellow', 'Жёлтый (Y)'),
|
||||
('color', 'Цветной (триколор)'),
|
||||
('na', 'Не применимо'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('in_stock', 'На складе'),
|
||||
('installed', 'Установлен'),
|
||||
('empty', 'Закончился / на замену'),
|
||||
('ordered', 'Заказан'),
|
||||
('disposed', 'Утилизирован'),
|
||||
]
|
||||
|
||||
|
||||
def seed_directory(apps, type_code, type_name, choices):
|
||||
DirectoryType = apps.get_model('core', 'DirectoryType')
|
||||
Directory = apps.get_model('core', 'Directory')
|
||||
|
||||
directory_type, _ = DirectoryType.objects.get_or_create(
|
||||
code=type_code, defaults={'name': type_name, 'hierarchy_type': 'strict'},
|
||||
)
|
||||
result = {}
|
||||
for code, name in choices:
|
||||
directory, _ = Directory.objects.get_or_create(
|
||||
directory_type=directory_type, code=code, defaults={'name': name, 'is_active': True},
|
||||
)
|
||||
result[code] = directory
|
||||
return result
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Consumable = apps.get_model('it_manager', 'Consumable')
|
||||
|
||||
types_by_code = seed_directory(apps, 'consumable_type', 'Тип витратного матеріалу', CONSUMABLE_TYPES)
|
||||
colors_by_code = seed_directory(apps, 'consumable_color', 'Колір витратного матеріалу', COLOR_CHOICES)
|
||||
statuses_by_code = seed_directory(apps, 'consumable_status', 'Статус витратного матеріалу', STATUS_CHOICES)
|
||||
|
||||
for item in Consumable.objects.all():
|
||||
item.consumable_type_new = types_by_code.get(item.consumable_type)
|
||||
item.color_new = colors_by_code.get(item.color)
|
||||
item.status_new = statuses_by_code.get(item.status)
|
||||
item.save(update_fields=['consumable_type_new', 'color_new', 'status_new'])
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
Consumable = apps.get_model('it_manager', 'Consumable')
|
||||
for item in Consumable.objects.select_related('consumable_type_new', 'color_new', 'status_new'):
|
||||
item.consumable_type = item.consumable_type_new.code if item.consumable_type_new else 'cartridge'
|
||||
item.color = item.color_new.code if item.color_new else 'black'
|
||||
item.status = item.status_new.code if item.status_new else 'in_stock'
|
||||
item.save(update_fields=['consumable_type', 'color', 'status'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0013_worklog_servicecall_choices_finalize'),
|
||||
('core', '0002_connector_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(model_name='consumable', name='it_manager__status_53998f_idx'),
|
||||
migrations.RemoveIndex(model_name='consumable', name='it_manager__equipme_8f7405_idx'),
|
||||
migrations.AddField(
|
||||
model_name='consumable',
|
||||
name='consumable_type_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'consumable_type', 'is_active': True},
|
||||
related_name='consumables_by_type', to='core.directory', verbose_name='Тип',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consumable',
|
||||
name='color_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'consumable_color', 'is_active': True},
|
||||
related_name='consumables_by_color', to='core.directory', verbose_name='Цвет',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consumable',
|
||||
name='status_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'consumable_status', 'is_active': True},
|
||||
related_name='consumables_by_status', to='core.directory', verbose_name='Статус',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consumable',
|
||||
name='equipment',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=models.deletion.SET_NULL,
|
||||
limit_choices_to={'equipment_type__code': 'printer'},
|
||||
related_name='consumables', to='it_manager.equipment', verbose_name='Устройство',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
from core.models.directories import directory_default
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0014_consumable_choices_to_directory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name='consumable', name='consumable_type'),
|
||||
migrations.RemoveField(model_name='consumable', name='color'),
|
||||
migrations.RemoveField(model_name='consumable', name='status'),
|
||||
migrations.RenameField(model_name='consumable', old_name='consumable_type_new', new_name='consumable_type'),
|
||||
migrations.RenameField(model_name='consumable', old_name='color_new', new_name='color'),
|
||||
migrations.RenameField(model_name='consumable', old_name='status_new', new_name='status'),
|
||||
migrations.AlterField(
|
||||
model_name='consumable',
|
||||
name='consumable_type',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'consumable_type', 'is_active': True},
|
||||
related_name='consumables_by_type', to='core.directory', verbose_name='Тип',
|
||||
default=directory_default('consumable_type', 'cartridge'),
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consumable',
|
||||
name='color',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'consumable_color', 'is_active': True},
|
||||
related_name='consumables_by_color', to='core.directory', verbose_name='Цвет',
|
||||
default=directory_default('consumable_color', 'black'),
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consumable',
|
||||
name='status',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'consumable_status', 'is_active': True},
|
||||
related_name='consumables_by_status', to='core.directory', verbose_name='Статус',
|
||||
default=directory_default('consumable_status', 'in_stock'),
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='consumable',
|
||||
options={'ordering': ['equipment', 'consumable_type__name', 'color__name'], 'verbose_name': 'Расходник', 'verbose_name_plural': 'Расходники'},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
SERVICE_TYPES = [
|
||||
('internet', 'Интернет'),
|
||||
('telephony', 'Телефония'),
|
||||
('vpn', 'VPN'),
|
||||
('dedicated', 'Выделенная линия'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
CONNECTION_TYPES = [
|
||||
('fiber', 'Оптоволокно (FTTH/FTTB)'),
|
||||
('ethernet', 'Ethernet (FTTB)'),
|
||||
('docsis', 'Кабель (DOCSIS)'),
|
||||
('adsl', 'xDSL (ADSL/VDSL)'),
|
||||
('4g', '4G/LTE'),
|
||||
('5g', '5G'),
|
||||
('radio', 'Радиоканал'),
|
||||
('other', 'Другое'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('active', 'Активен'),
|
||||
('suspended', 'Приостановлен'),
|
||||
('terminated', 'Расторгнут'),
|
||||
]
|
||||
|
||||
|
||||
def seed_directory(apps, type_code, type_name, choices):
|
||||
DirectoryType = apps.get_model('core', 'DirectoryType')
|
||||
Directory = apps.get_model('core', 'Directory')
|
||||
|
||||
directory_type, _ = DirectoryType.objects.get_or_create(
|
||||
code=type_code, defaults={'name': type_name, 'hierarchy_type': 'strict'},
|
||||
)
|
||||
result = {}
|
||||
for code, name in choices:
|
||||
directory, _ = Directory.objects.get_or_create(
|
||||
directory_type=directory_type, code=code, defaults={'name': name, 'is_active': True},
|
||||
)
|
||||
result[code] = directory
|
||||
return result
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
InternetContract = apps.get_model('it_manager', 'InternetContract')
|
||||
|
||||
services_by_code = seed_directory(apps, 'contract_service_type', 'Тип послуги (договір)', SERVICE_TYPES)
|
||||
connections_by_code = seed_directory(apps, 'contract_connection_type', 'Технологія підключення', CONNECTION_TYPES)
|
||||
statuses_by_code = seed_directory(apps, 'contract_status', 'Статус договору', STATUS_CHOICES)
|
||||
|
||||
for contract in InternetContract.objects.all():
|
||||
contract.service_type_new = services_by_code.get(contract.service_type)
|
||||
contract.connection_type_new = connections_by_code.get(contract.connection_type) if contract.connection_type else None
|
||||
contract.status_new = statuses_by_code.get(contract.status)
|
||||
contract.save(update_fields=['service_type_new', 'connection_type_new', 'status_new'])
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
InternetContract = apps.get_model('it_manager', 'InternetContract')
|
||||
for contract in InternetContract.objects.select_related('service_type_new', 'connection_type_new', 'status_new'):
|
||||
contract.service_type = contract.service_type_new.code if contract.service_type_new else 'internet'
|
||||
contract.connection_type = contract.connection_type_new.code if contract.connection_type_new else ''
|
||||
contract.status = contract.status_new.code if contract.status_new else 'active'
|
||||
contract.save(update_fields=['service_type', 'connection_type', 'status'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0015_consumable_choices_finalize'),
|
||||
('core', '0002_connector_config'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(model_name='internetcontract', name='it_manager__status_e06eaa_idx'),
|
||||
migrations.RemoveIndex(model_name='internetcontract', name='it_manager__locatio_6abc78_idx'),
|
||||
migrations.AddField(
|
||||
model_name='internetcontract',
|
||||
name='service_type_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'contract_service_type', 'is_active': True},
|
||||
related_name='contracts_by_service_type', to='core.directory', verbose_name='Тип услуги',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='internetcontract',
|
||||
name='connection_type_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, blank=True, on_delete=models.deletion.SET_NULL,
|
||||
limit_choices_to={'directory_type__code': 'contract_connection_type', 'is_active': True},
|
||||
related_name='contracts_by_connection_type', to='core.directory', verbose_name='Технология подключения',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='internetcontract',
|
||||
name='status_new',
|
||||
field=models.ForeignKey(
|
||||
null=True, on_delete=models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'contract_status', 'is_active': True},
|
||||
related_name='contracts_by_status', to='core.directory', verbose_name='Статус',
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
from core.models.directories import directory_default
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('it_manager', '0016_contract_choices_to_directory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name='internetcontract', name='service_type'),
|
||||
migrations.RemoveField(model_name='internetcontract', name='connection_type'),
|
||||
migrations.RemoveField(model_name='internetcontract', name='status'),
|
||||
migrations.RenameField(model_name='internetcontract', old_name='service_type_new', new_name='service_type'),
|
||||
migrations.RenameField(model_name='internetcontract', old_name='connection_type_new', new_name='connection_type'),
|
||||
migrations.RenameField(model_name='internetcontract', old_name='status_new', new_name='status'),
|
||||
migrations.AlterField(
|
||||
model_name='internetcontract',
|
||||
name='service_type',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'contract_service_type', 'is_active': True},
|
||||
related_name='contracts_by_service_type', to='core.directory', verbose_name='Тип услуги',
|
||||
default=directory_default('contract_service_type', 'internet'),
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='internetcontract',
|
||||
name='connection_type',
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
limit_choices_to={'directory_type__code': 'contract_connection_type', 'is_active': True},
|
||||
related_name='contracts_by_connection_type', to='core.directory', verbose_name='Технология подключения',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='internetcontract',
|
||||
name='status',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
limit_choices_to={'directory_type__code': 'contract_status', 'is_active': True},
|
||||
related_name='contracts_by_status', to='core.directory', verbose_name='Статус',
|
||||
default=directory_default('contract_status', 'active'),
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='internetcontract',
|
||||
options={'ordering': ['location', 'provider'], 'verbose_name': 'Договор с провайдером', 'verbose_name_plural': 'Договоры с провайдерами'},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def build_catalog(apps, schema_editor):
|
||||
"""Створити EquipmentModel з унікальних комбінацій type+brand+model
|
||||
існуючого обладнання і прив'язати кожен екземпляр до своєї моделі."""
|
||||
Equipment = apps.get_model('it_manager', 'Equipment')
|
||||
EquipmentModel = apps.get_model('it_manager', 'EquipmentModel')
|
||||
|
||||
cache = {}
|
||||
for eq in Equipment.objects.all():
|
||||
key = (eq.equipment_type_id, eq.brand_id, eq.model)
|
||||
model = cache.get(key)
|
||||
if model is None:
|
||||
model, _ = EquipmentModel.objects.get_or_create(
|
||||
equipment_type_id=eq.equipment_type_id,
|
||||
brand_id=eq.brand_id,
|
||||
name=eq.model,
|
||||
)
|
||||
cache[key] = model
|
||||
eq.equipment_model = model
|
||||
eq.save(update_fields=['equipment_model'])
|
||||
|
||||
|
||||
def unbuild_catalog(apps, schema_editor):
|
||||
"""Зворотній перенос: повернути type/brand/model на екземпляр з моделі."""
|
||||
Equipment = apps.get_model('it_manager', 'Equipment')
|
||||
for eq in Equipment.objects.select_related('equipment_model').all():
|
||||
m = eq.equipment_model
|
||||
eq.equipment_type_id = m.equipment_type_id
|
||||
eq.brand_id = m.brand_id
|
||||
eq.model = m.name
|
||||
eq.save(update_fields=['equipment_type', 'brand', 'model'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
('it_manager', '0017_contract_choices_finalize'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. Каталог моделей
|
||||
migrations.CreateModel(
|
||||
name='EquipmentModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Модель')),
|
||||
('warranty_months', models.PositiveIntegerField(blank=True, help_text='Стандартный срок гарантии для этой модели', null=True, verbose_name='Гарантия (мес.)')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Примечания')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('brand', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'equipment_brand', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='models_by_brand', to='core.directory', verbose_name='Бренд')),
|
||||
('equipment_type', models.ForeignKey(limit_choices_to={'directory_type__code': 'equipment_type', 'is_active': True}, on_delete=django.db.models.deletion.PROTECT, related_name='models_by_type', to='core.directory', verbose_name='Тип')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Модель оборудования',
|
||||
'verbose_name_plural': 'Модели оборудования (каталог)',
|
||||
'ordering': ['equipment_type__name', 'brand__name', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='equipmentmodel',
|
||||
constraint=models.UniqueConstraint(fields=('equipment_type', 'brand', 'name'), name='unique_equipment_model'),
|
||||
),
|
||||
# 2. FK на модель (тимчасово nullable для перенесення)
|
||||
migrations.AddField(
|
||||
model_name='equipment',
|
||||
name='equipment_model',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='units', to='it_manager.equipmentmodel', verbose_name='Модель'),
|
||||
),
|
||||
# 3. Перенесення даних
|
||||
migrations.RunPython(build_catalog, unbuild_catalog),
|
||||
# 4. Тепер модель обов'язкова
|
||||
migrations.AlterField(
|
||||
model_name='equipment',
|
||||
name='equipment_model',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='units', to='it_manager.equipmentmodel', verbose_name='Модель'),
|
||||
),
|
||||
# 5. Прибрати старі поля з екземпляра
|
||||
migrations.RemoveField(model_name='equipment', name='brand'),
|
||||
migrations.RemoveField(model_name='equipment', name='equipment_type'),
|
||||
migrations.RemoveField(model_name='equipment', name='model'),
|
||||
# 6. Оновити службове
|
||||
migrations.AlterModelOptions(
|
||||
name='equipment',
|
||||
options={'ordering': ['equipment_model__equipment_type__name', 'equipment_model__name'], 'verbose_name': 'Оборудование', 'verbose_name_plural': 'Оборудование'},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='equipment',
|
||||
index=models.Index(fields=['equipment_model'], name='it_manager__equipme_5b76a5_idx'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consumable',
|
||||
name='equipment',
|
||||
field=models.ForeignKey(blank=True, limit_choices_to={'equipment_model__equipment_type__code': 'printer'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='consumables', to='it_manager.equipment', verbose_name='Устройство'),
|
||||
),
|
||||
]
|
||||
@@ -1,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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('it-manager/', views.it_manager_view, name='it_manager'),
|
||||
path('it-manager/equipment/models/create/', views.equipment_model_create_view, name='it_equipment_model_create'),
|
||||
path('it-manager/equipment/bulk-create/', views.equipment_bulk_create_view, name='it_equipment_bulk_create'),
|
||||
path('it-manager/equipment/bulk-assign/', views.equipment_bulk_assign_view, name='it_equipment_bulk_assign'),
|
||||
path('it-manager/equipment/<int:pk>/update/', views.equipment_update_view, name='it_equipment_update'),
|
||||
path('it-manager/tickets/<int:pk>/update/', views.ticket_update_view, name='it_ticket_update'),
|
||||
path('it-manager/tickets/<int:pk>/comment/', views.ticket_comment_view, name='it_ticket_comment'),
|
||||
path('it-manager/worklog/create/', views.worklog_create_view, name='it_worklog_create'),
|
||||
path('it-manager/consumables/create/', views.consumable_create_view, name='it_consumable_create'),
|
||||
path('it-manager/consumables/<int:pk>/update/', views.consumable_update_view, name='it_consumable_update'),
|
||||
path('it-manager/providers/create/', views.provider_create_view, name='it_provider_create'),
|
||||
path('it-manager/contracts/create/', views.contract_create_view, name='it_contract_create'),
|
||||
path('it-manager/contracts/<int:pk>/update/', views.contract_update_view, name='it_contract_update'),
|
||||
path('it-manager/servicecalls/create/', views.servicecall_create_view, name='it_servicecall_create'),
|
||||
]
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user