4d4537deff
- Публикация отчёта (модель ReportPublication + миграция, сервис publication). - Разделение прав: user_can_manage_it_monthly_report (правка) vs view; reports.view_all_departments в списке портальных прав. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
977 lines
40 KiB
Python
977 lines
40 KiB
Python
from datetime import date, datetime
|
||
import calendar
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import models
|
||
from django.db.models import Q, Sum
|
||
from django.http import HttpResponse, 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.it_manager.monthly_report import (
|
||
MONTHS_RU,
|
||
user_can_manage_it_monthly_report,
|
||
user_can_view_it_monthly_report,
|
||
)
|
||
from apps.reports.it_monthly import parse_month_id
|
||
from apps.locations.models.base import Location
|
||
from apps.locations.models.pharmacies import Pharmacy
|
||
from core.models.directories import Directory
|
||
from core.portal_access import deny_section_access
|
||
from core.utils.htmx import render_page
|
||
from core.utils.pagination import paginate
|
||
|
||
Employee = get_user_model()
|
||
|
||
|
||
def _it_locations_qs():
|
||
"""Локации для селектов IT: аптеки с организацией (чтобы отличать №12 у разных юрлиц)."""
|
||
return (
|
||
Location.objects.filter(is_active=True)
|
||
.select_related('location_region')
|
||
.prefetch_related(
|
||
models.Prefetch(
|
||
'pharmacy',
|
||
queryset=Pharmacy.objects.select_related('organization'),
|
||
),
|
||
)
|
||
.order_by('location_type', 'address')
|
||
)
|
||
|
||
|
||
def _split_it_locations(locations):
|
||
pharmacies, offices = [], []
|
||
for loc in locations:
|
||
if loc.location_type == 'pharmacy':
|
||
pharmacies.append(loc)
|
||
else:
|
||
# office + warehouse → «Офис / отдел»
|
||
offices.append(loc)
|
||
return pharmacies, offices
|
||
|
||
|
||
def _infer_assign_target(equipment) -> str:
|
||
if equipment.assigned_to_id:
|
||
return 'employee'
|
||
if equipment.location_id:
|
||
loc = getattr(equipment, 'location', None)
|
||
if loc is not None and loc.location_type == 'pharmacy':
|
||
return 'pharmacy'
|
||
return 'office'
|
||
return ''
|
||
|
||
|
||
def _parse_assign_post(request) -> tuple[str, str | None, str | None, str | None]:
|
||
"""assign_target, location_id, assigned_to_id, error_message."""
|
||
target = (request.POST.get('assign_target') or '').strip()
|
||
location_id = request.POST.get('location_id') or None
|
||
assigned_to_id = request.POST.get('assigned_to_id') or None
|
||
|
||
if not target:
|
||
return '', None, None, None
|
||
|
||
if target == 'employee':
|
||
if not assigned_to_id:
|
||
return target, None, None, 'Оберіть співробітника.'
|
||
return target, None, assigned_to_id, None
|
||
|
||
if target in ('pharmacy', 'office'):
|
||
if not location_id:
|
||
return target, None, None, (
|
||
'Оберіть аптеку.' if target == 'pharmacy' else 'Оберіть офіс / відділ.'
|
||
)
|
||
loc = Location.objects.filter(pk=location_id, is_active=True).first()
|
||
if not loc:
|
||
return target, None, None, 'Локацію не знайдено.'
|
||
if target == 'pharmacy' and loc.location_type != 'pharmacy':
|
||
return target, None, None, 'Оберіть аптеку зі списку аптек.'
|
||
if target == 'office' and loc.location_type == 'pharmacy':
|
||
return target, None, None, 'Оберіть офіс / відділ зі списку.'
|
||
return target, str(loc.pk), None, None
|
||
|
||
return target, None, None, 'Невідомий тип видачі.'
|
||
|
||
|
||
def _is_it_staff(user):
|
||
from core.portal_access import user_can_access
|
||
return user_can_access(user, 'it_manager')
|
||
|
||
|
||
def _it_assignees():
|
||
"""Кандидаты в исполнители: группа IT, а также staff/superuser
|
||
(если в группу IT ещё никого не добавили — селект не пустой)."""
|
||
return (
|
||
Employee.objects.filter(is_active=True)
|
||
.filter(Q(groups__name='IT') | Q(is_staff=True) | Q(is_superuser=True))
|
||
.distinct()
|
||
.order_by('last_name', 'first_name')
|
||
)
|
||
|
||
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 _close_open_assignments(equipment):
|
||
"""Закрити всі активні записи історії переміщень для одиниці."""
|
||
EquipmentAssignment.objects.filter(
|
||
equipment=equipment, returned_at__isnull=True,
|
||
).update(returned_at=timezone.now())
|
||
|
||
|
||
def _apply_equipment_issue(unit, *, location_id=None, assigned_to_id=None, user=None,
|
||
set_location=False, set_assignee=False, force_assigned_status=True):
|
||
"""Видача / переміщення одиниці з записом історії.
|
||
|
||
set_location / set_assignee — чи оновлювати відповідне поле
|
||
(для bulk: міняємо лише те, що передали у формі).
|
||
"""
|
||
new_location = location_id if set_location else unit.location_id
|
||
new_assignee = assigned_to_id if set_assignee else unit.assigned_to_id
|
||
|
||
loc_changed = str(unit.location_id or '') != str(new_location or '')
|
||
emp_changed = str(unit.assigned_to_id or '') != str(new_assignee or '')
|
||
if not loc_changed and not emp_changed:
|
||
return False
|
||
|
||
_close_open_assignments(unit)
|
||
if set_location:
|
||
unit.location_id = location_id or None
|
||
if set_assignee:
|
||
unit.assigned_to_id = assigned_to_id or None
|
||
|
||
has_target = bool(unit.location_id or unit.assigned_to_id)
|
||
if force_assigned_status and has_target:
|
||
assigned_status_id = _resolve_directory('equipment_status', 'assigned')
|
||
if assigned_status_id:
|
||
unit.status_id = assigned_status_id
|
||
elif not has_target:
|
||
stock_id = _resolve_directory('equipment_status', 'stock')
|
||
if stock_id:
|
||
unit.status_id = stock_id
|
||
|
||
unit.save()
|
||
if has_target:
|
||
EquipmentAssignment.objects.create(
|
||
equipment=unit,
|
||
assigned_to_id=unit.assigned_to_id,
|
||
location_id=unit.location_id,
|
||
assigned_by=user,
|
||
)
|
||
return True
|
||
|
||
|
||
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')
|
||
)
|
||
locations_all = list(_it_locations_qs())
|
||
it_pharmacies, it_offices = _split_it_locations(locations_all)
|
||
|
||
tickets_qs = (
|
||
Ticket.objects.select_related(
|
||
'requester', 'assignee', 'equipment', 'location',
|
||
'ticket_type', 'priority', 'status',
|
||
)
|
||
.prefetch_related(
|
||
'comments__author',
|
||
models.Prefetch(
|
||
'work_logs',
|
||
queryset=WorkLog.objects.select_related('technician', 'work_type').order_by('-date', '-created_at'),
|
||
),
|
||
)
|
||
.annotate(worklogs_count=models.Count('work_logs', distinct=True))
|
||
.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')
|
||
|
||
# Для формы журнала: сначала открытые заявки, потом остальные
|
||
open_ticket_codes = ('new', 'in_progress', 'waiting')
|
||
worklog_tickets = sorted(
|
||
tickets,
|
||
key=lambda t: (0 if t.status.code in open_ticket_codes else 1, -t.pk),
|
||
)
|
||
|
||
month_start = timezone.localdate().replace(day=1)
|
||
worklog_technician_raw = (request.GET.get('worklog_technician') or '').strip()
|
||
worklog_technician_id = int(worklog_technician_raw) if worklog_technician_raw.isdigit() else None
|
||
|
||
worklog_month_raw = (request.GET.get('worklog_month') or '').strip()
|
||
worklog_month_start = None
|
||
worklog_month_end = None
|
||
if worklog_month_raw and worklog_month_raw != 'all':
|
||
y, m = parse_month_id(worklog_month_raw)
|
||
last_day = calendar.monthrange(y, m)[1]
|
||
worklog_month_start = date(y, m, 1)
|
||
worklog_month_end = date(y, m, last_day)
|
||
worklog_month_id = f'{y:04d}-{m:02d}'
|
||
else:
|
||
worklog_month_id = 'all'
|
||
|
||
worklogs_qs = (
|
||
WorkLog.objects.select_related(
|
||
'technician', 'location', 'equipment', 'ticket', 'work_type',
|
||
)
|
||
.prefetch_related(
|
||
models.Prefetch(
|
||
'location__pharmacy',
|
||
queryset=Pharmacy.objects.select_related('organization'),
|
||
),
|
||
)
|
||
.order_by('-date', '-created_at')
|
||
)
|
||
if worklog_technician_id:
|
||
worklogs_qs = worklogs_qs.filter(technician_id=worklog_technician_id)
|
||
if worklog_month_start and worklog_month_end:
|
||
worklogs_qs = worklogs_qs.filter(
|
||
date__gte=worklog_month_start, date__lte=worklog_month_end,
|
||
)
|
||
worklogs_page = paginate(request, worklogs_qs, page_param='worklogs_page', size_param='worklogs_per_page')
|
||
|
||
# Статистика: выбранный месяц или текущий календарный
|
||
if worklog_month_start and worklog_month_end:
|
||
stats_start, stats_end = worklog_month_start, worklog_month_end
|
||
worklog_stats_label = (
|
||
f'{MONTHS_RU[worklog_month_start.month]} {worklog_month_start.year}'
|
||
)
|
||
else:
|
||
stats_start, stats_end = month_start, None
|
||
worklog_stats_label = 'этом месяце'
|
||
|
||
month_logs = WorkLog.objects.filter(date__gte=stats_start)
|
||
if stats_end is not None:
|
||
month_logs = month_logs.filter(date__lte=stats_end)
|
||
if worklog_technician_id:
|
||
month_logs = month_logs.filter(technician_id=worklog_technician_id)
|
||
month_hours = month_logs.aggregate(total=Sum('hours_spent'))['total'] or 0
|
||
month_count = month_logs.count()
|
||
|
||
worklog_month_choices = []
|
||
seen_months = set()
|
||
for d in WorkLog.objects.dates('date', 'month', order='DESC'):
|
||
mid = f'{d.year:04d}-{d.month:02d}'
|
||
seen_months.add(mid)
|
||
worklog_month_choices.append((mid, f'{MONTHS_RU[d.month]} {d.year}'))
|
||
today = timezone.localdate()
|
||
current_mid = f'{today.year:04d}-{today.month:02d}'
|
||
if current_mid not in seen_months:
|
||
worklog_month_choices.insert(
|
||
0, (current_mid, f'{MONTHS_RU[today.month]} {today.year}'),
|
||
)
|
||
|
||
worklog_technicians = (
|
||
Employee.objects.filter(is_active=True)
|
||
.filter(
|
||
Q(pk__in=WorkLog.objects.values('technician_id'))
|
||
| Q(groups__name='IT')
|
||
| Q(is_staff=True)
|
||
| Q(is_superuser=True)
|
||
)
|
||
.distinct()
|
||
.order_by('last_name', 'first_name')
|
||
)
|
||
can_edit_worklogs = user_can_manage_it_monthly_report(request.user)
|
||
|
||
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', 'parent_contract',
|
||
)
|
||
.prefetch_related('child_contracts')
|
||
.order_by('provider__name', 'contract_number')
|
||
)
|
||
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': locations_all,
|
||
'it_pharmacies': it_pharmacies,
|
||
'it_offices': it_offices,
|
||
'it_employees': Employee.objects.filter(is_active=True).order_by('last_name', 'first_name'),
|
||
|
||
'tickets': tickets,
|
||
'tickets_page': tickets_page,
|
||
'worklog_tickets': worklog_tickets,
|
||
'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': _it_assignees(),
|
||
|
||
'worklogs_page': worklogs_page,
|
||
'worklog_types': _directory_choices('worklog_type'),
|
||
'worklog_technicians': worklog_technicians,
|
||
'worklog_technician_id': worklog_technician_id,
|
||
'worklog_month_id': worklog_month_id,
|
||
'worklog_month_choices': worklog_month_choices,
|
||
'worklog_stats_label': worklog_stats_label,
|
||
'it_can_edit_worklogs': can_edit_worklogs,
|
||
'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'),
|
||
'framework_contracts': list(
|
||
InternetContract.objects.filter(is_framework=True)
|
||
.select_related('provider')
|
||
.order_by('provider__name', 'contract_number')
|
||
),
|
||
|
||
'service_calls_page': service_calls_page,
|
||
'service_calls_count': service_calls_count,
|
||
'servicecall_channels': _directory_choices('servicecall_channel'),
|
||
|
||
'it_can_view_monthly_report': user_can_view_it_monthly_report(request.user),
|
||
}
|
||
|
||
|
||
@login_required
|
||
def it_manager_view(request):
|
||
if not _is_it_staff(request.user):
|
||
return deny_section_access(request)
|
||
context = _it_manager_context(request)
|
||
tab = (request.GET.get('tab') or '').strip()
|
||
if tab in {
|
||
'equipment', 'tickets', 'worklog', 'consumables',
|
||
'providers', 'servicecalls',
|
||
}:
|
||
context['it_active_tab'] = tab
|
||
elif (
|
||
'worklog_technician' in request.GET
|
||
or 'worklog_month' in request.GET
|
||
or request.GET.get('worklogs_page')
|
||
):
|
||
context['it_active_tab'] = 'worklog'
|
||
return render_page(request, 'it-manager.html', context)
|
||
|
||
|
||
@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 = None
|
||
assigned_to_id = None
|
||
target, location_id, assigned_to_id, assign_err = _parse_assign_post(request)
|
||
if assign_err:
|
||
context_extra['equipment_form_error'] = assign_err
|
||
context = _it_manager_context(request)
|
||
context['it_active_tab'] = 'equipment'
|
||
context.update(context_extra)
|
||
return render_page(request, 'it-manager.html', context)
|
||
|
||
# якщо одразу видаємо — статус assigned, інакше stock
|
||
status_code = 'assigned' if (location_id or assigned_to_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
|
||
unit = Equipment.objects.create(
|
||
equipment_model=model,
|
||
serial_number=s,
|
||
status_id=status_id,
|
||
location_id=location_id,
|
||
assigned_to_id=assigned_to_id,
|
||
purchase_date=purchase_date,
|
||
warranty_until=warranty_until,
|
||
)
|
||
if location_id or assigned_to_id:
|
||
EquipmentAssignment.objects.create(
|
||
equipment=unit,
|
||
location_id=location_id,
|
||
assigned_to_id=assigned_to_id,
|
||
assigned_by=request.user,
|
||
)
|
||
created += 1
|
||
skipped = len(serials) - created
|
||
msg = f'Додано {created} од. «{model}»'
|
||
if (location_id or assigned_to_id) and created:
|
||
msg += ' і видано'
|
||
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')
|
||
target, location_id, assigned_to_id, assign_err = _parse_assign_post(request)
|
||
|
||
if not ids:
|
||
context_extra['equipment_form_error'] = 'Відмітьте хоча б один екземпляр.'
|
||
elif not target:
|
||
context_extra['equipment_form_error'] = 'Оберіть тип видачі.'
|
||
elif assign_err:
|
||
context_extra['equipment_form_error'] = assign_err
|
||
else:
|
||
units = Equipment.objects.filter(pk__in=ids)
|
||
count = 0
|
||
for unit in units:
|
||
if _apply_equipment_issue(
|
||
unit,
|
||
location_id=location_id,
|
||
assigned_to_id=assigned_to_id,
|
||
user=request.user,
|
||
set_location=True,
|
||
set_assignee=True,
|
||
):
|
||
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_code = request.POST.get('status') or ''
|
||
new_status_id = _resolve_directory('equipment_status', new_status_code)
|
||
target, location_id, assigned_to_id, assign_err = _parse_assign_post(request)
|
||
|
||
# Повернення на склад: статус stock або порожній тип видачі
|
||
if new_status_code == 'stock' or not target:
|
||
_close_open_assignments(equipment)
|
||
if new_status_code == 'stock' and new_status_id:
|
||
equipment.status_id = new_status_id
|
||
else:
|
||
stock_id = _resolve_directory('equipment_status', 'stock')
|
||
if stock_id:
|
||
equipment.status_id = stock_id
|
||
equipment.location_id = None
|
||
equipment.assigned_to_id = None
|
||
equipment.save()
|
||
elif assign_err:
|
||
context = _it_manager_context(request)
|
||
context['it_active_tab'] = 'equipment'
|
||
context['equipment_form_error'] = assign_err
|
||
return render_page(request, 'it-manager.html', context)
|
||
else:
|
||
_apply_equipment_issue(
|
||
equipment,
|
||
location_id=location_id,
|
||
assigned_to_id=assigned_to_id,
|
||
user=request.user,
|
||
set_location=True,
|
||
set_assignee=True,
|
||
force_assigned_status=True,
|
||
)
|
||
equipment.refresh_from_db()
|
||
if new_status_id and new_status_code not in ('', 'stock') and equipment.status.code != new_status_code:
|
||
equipment.status_id = new_status_id
|
||
equipment.save(update_fields=['status_id', 'updated_at'])
|
||
|
||
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()
|
||
|
||
worklog_error = ''
|
||
worklog_message = ''
|
||
if request.method == 'POST':
|
||
description = request.POST.get('description', '').strip()
|
||
work_type_raw = request.POST.get('work_type', '')
|
||
work_type_id = _resolve_directory('worklog_type', work_type_raw)
|
||
|
||
if not description:
|
||
worklog_error = 'Укажите описание работ.'
|
||
elif not work_type_id or work_type_raw == '__new__':
|
||
worklog_error = 'Выберите вид работ.'
|
||
else:
|
||
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
|
||
|
||
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
|
||
|
||
try:
|
||
WorkLog.objects.create(
|
||
date=log_date,
|
||
technician=request.user,
|
||
work_type_id=work_type_id,
|
||
location_id=location_id,
|
||
equipment_id=equipment_id,
|
||
ticket_id=ticket_id,
|
||
description=description,
|
||
result=request.POST.get('result', '').strip(),
|
||
hours_spent=hours_spent,
|
||
)
|
||
except Exception:
|
||
worklog_error = 'Не удалось сохранить запись. Проверьте локацию, заявку и оборудование.'
|
||
else:
|
||
if ticket_id:
|
||
worklog_message = 'Работа привязана к заявке и сохранена.'
|
||
else:
|
||
worklog_message = 'Запись о выполненной работе сохранена.'
|
||
|
||
context = _it_manager_context(request)
|
||
context['it_active_tab'] = 'worklog'
|
||
if worklog_message:
|
||
context['it_worklog_message'] = worklog_message
|
||
if worklog_error:
|
||
context['it_worklog_error'] = worklog_error
|
||
return render_page(request, 'it-manager.html', context)
|
||
|
||
|
||
@login_required
|
||
def worklog_update_view(request, pk):
|
||
"""Inline-редактирование записи журнала — только руководителю отдела."""
|
||
if not _is_it_staff(request.user):
|
||
return HttpResponseForbidden()
|
||
if not user_can_manage_it_monthly_report(request.user):
|
||
return HttpResponseForbidden()
|
||
if request.method != 'POST':
|
||
return HttpResponseForbidden()
|
||
|
||
log = get_object_or_404(WorkLog, pk=pk)
|
||
description = request.POST.get('description', '').strip()
|
||
work_type_raw = request.POST.get('work_type', '')
|
||
work_type_id = _resolve_directory('worklog_type', work_type_raw)
|
||
|
||
if not description:
|
||
return HttpResponse(
|
||
'<span style="color:var(--color-destructive); font-size:0.75rem;">Нужно описание</span>',
|
||
status=400,
|
||
)
|
||
if not work_type_id or work_type_raw == '__new__':
|
||
return HttpResponse(
|
||
'<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите вид работ</span>',
|
||
status=400,
|
||
)
|
||
|
||
try:
|
||
log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date()
|
||
except ValueError:
|
||
log_date = log.date
|
||
try:
|
||
hours_raw = (request.POST.get('hours_spent') or '').strip()
|
||
hours_spent = float(hours_raw) if hours_raw else None
|
||
if hours_spent == 0:
|
||
hours_spent = None
|
||
except ValueError:
|
||
hours_spent = log.hours_spent
|
||
|
||
technician_raw = (request.POST.get('technician_id') or '').strip()
|
||
technician_id = int(technician_raw) if technician_raw.isdigit() else log.technician_id
|
||
if not Employee.objects.filter(pk=technician_id, is_active=True).exists():
|
||
technician_id = log.technician_id
|
||
|
||
location_raw = (request.POST.get('location_id') or '').strip()
|
||
location_id = int(location_raw) if location_raw.isdigit() else None
|
||
|
||
log.date = log_date
|
||
log.technician_id = technician_id
|
||
log.work_type_id = work_type_id
|
||
log.description = description
|
||
log.result = request.POST.get('result', '').strip()
|
||
log.hours_spent = hours_spent
|
||
log.location_id = location_id
|
||
try:
|
||
log.save()
|
||
except Exception:
|
||
return HttpResponse(
|
||
'<span style="color:var(--color-destructive); font-size:0.75rem;">Ошибка сохранения</span>',
|
||
status=400,
|
||
)
|
||
return HttpResponse(
|
||
'<span style="color:hsl(152 45% 36%); font-size:0.75rem;">Сохранено</span>'
|
||
)
|
||
|
||
|
||
@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', ''))
|
||
is_framework = bool(request.POST.get('is_framework'))
|
||
location_id = None if is_framework else (request.POST.get('location_id') or None)
|
||
|
||
if provider_id and contract_number and start_date and service_type_id and (is_framework or location_id):
|
||
parent_id = request.POST.get('parent_contract_id') or None
|
||
contract = InternetContract(
|
||
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(),
|
||
is_framework=is_framework,
|
||
parent_contract_id=parent_id if not is_framework else None,
|
||
)
|
||
try:
|
||
contract.full_clean()
|
||
contract.save()
|
||
except ValidationError:
|
||
pass
|
||
|
||
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
|
||
parent_id = request.POST.get('parent_contract_id')
|
||
if parent_id is not None:
|
||
contract.parent_contract_id = parent_id or None
|
||
if 'is_framework' in request.POST:
|
||
contract.is_framework = bool(request.POST.get('is_framework'))
|
||
if contract.is_framework:
|
||
contract.parent_contract_id = None
|
||
contract.location_id = None
|
||
elif contract.is_framework:
|
||
contract.location_id = None
|
||
try:
|
||
contract.full_clean()
|
||
contract.save()
|
||
except ValidationError:
|
||
pass
|
||
|
||
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)
|