Files

1822 lines
71 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from core.location_access import accessible_pharmacies, scoped_queryset, has_all_locations
from django.core.exceptions import ValidationError
from django.db.models import Q, Count, Prefetch, Exists, OuterRef
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse, HttpResponseNotAllowed
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.views.decorators.http import require_POST
from django.utils.http import url_has_allowed_host_and_scheme
from django.conf import settings
from apps.employees.models.base import Employee
from apps.locations.models.pharmacies import Pharmacy
from core.models.directories import Directory
from core.models import ConnectorConfig
from core.employee_visibility import can_view_employee_pii
from core.portal_access import hr_manage_required, section_required, user_can_manage_hr_portal
from core.utils.htmx import render_page
from core.utils.pagination import paginate
from django.utils.translation import gettext_lazy as _
import logging
logger = logging.getLogger(__name__)
# Create your views here.
@login_required
def index_view(request):
return render(request, 'index.html')
@login_required
@require_POST
def access_preview_view(request):
"""Переключатель «смотреть как роль» (только DEBUG + staff)."""
from core.access_preview import (
ACCESS_PREVIEW_PRESETS,
SESSION_KEY,
access_preview_available,
)
if not access_preview_available(request.user):
return HttpResponseForbidden('Access preview только в DEBUG для staff.')
preview_id = (request.POST.get('preview') or '').strip()
allowed = {p.id for p in ACCESS_PREVIEW_PRESETS}
if preview_id not in allowed:
preview_id = ''
if preview_id:
request.session[SESSION_KEY] = preview_id
else:
request.session.pop(SESSION_KEY, None)
next_url = request.POST.get('next') or request.META.get('HTTP_REFERER') or '/'
if not url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
next_url = '/'
return redirect(next_url)
@login_required
def dashboard_view(request):
from django.utils import timezone as dj_timezone
from core.dashboard import build_dashboard_context
context = {'today': dj_timezone.localdate()}
context.update(build_dashboard_context(request))
return render_page(request, 'dashboard.html', context)
@login_required
def dashboard_customize_view(request):
"""Настройка персональной раскладки дашборда: вкл/выкл + порядок виджетов."""
from core import dashboard as dash
if request.method == 'POST':
if request.POST.get('reset'):
dash.reset_layout(request.user)
return redirect('dashboard')
order = [k.strip() for k in (request.POST.get('order') or '').split(',') if k.strip()]
enabled = request.POST.getlist('enabled')
if not order: # JS отключён — берём порядок чекбоксов
order = enabled
widths = {k: request.POST.get(f'width_{k}', dash.DEFAULT_WIDTH) for k in enabled}
dash.save_layout(request.user, order, enabled, widths=widths)
return redirect('dashboard')
context = {'rows': dash.customize_rows(request.user)}
return render_page(request, 'dashboard_customize.html', context)
@login_required
def employees_view(request):
"""Список перенесён в HR-портал (read-only для всех, управление — для HR)."""
return redirect('hr_portal')
def _can_view_employee_pii(request, employee) -> bool:
return can_view_employee_pii(request.user, employee)
def _save_own_profile(employee, post, files):
"""Збереження полів, які співробітник редагує сам (тільки передані в POST)."""
from core.utils.pagination import PAGE_SIZE_CHOICES
if files.get('photo'):
employee.photo = files['photo']
if 'phone' in post:
employee.phone = post.get('phone', '').strip()
if 'personal_phone' in post:
employee.personal_phone = post.get('personal_phone', '').strip()
if 'personal_email' in post:
employee.personal_email = post.get('personal_email', '').strip()
if 'emergency_contact_name' in post:
employee.emergency_contact_name = post.get('emergency_contact_name', '').strip()
if 'emergency_contact_phone' in post:
employee.emergency_contact_phone = post.get('emergency_contact_phone', '').strip()
if 'emergency_contact_relation' in post:
employee.emergency_contact_relation = post.get('emergency_contact_relation', '').strip()
if 'default_page_size' in post:
try:
page_size = int(post.get('default_page_size', ''))
except (TypeError, ValueError):
page_size = None
if page_size in PAGE_SIZE_CHOICES:
employee.default_page_size = page_size
unrelated_fields = [
f.name for f in Employee._meta.fields if f.name not in PROFILE_EDITABLE_FIELDS
]
employee.full_clean(exclude=unrelated_fields)
employee.save()
def _employee_profile_context(request, employee, *, active_tab: str = 'overview', **extra):
from core.utils.pagination import PAGE_SIZE_CHOICES
can_view_pii = _can_view_employee_pii(request, employee)
is_own_profile = request.user.pk == employee.pk
allowed_tabs = ['overview', 'contacts', 'work']
if is_own_profile:
allowed_tabs.append('settings')
if active_tab not in allowed_tabs:
active_tab = 'overview'
if active_tab == 'contacts' and not can_view_pii and not is_own_profile:
active_tab = 'overview'
context = {
'employee': employee,
'can_view_pii': can_view_pii,
'is_own_profile': is_own_profile,
'can_edit': request.user.is_staff,
'profile_active_tab': active_tab,
'page_size_choices': PAGE_SIZE_CHOICES,
'subordinates': employee.subordinates.filter(is_active=True).select_related('post')
if employee.is_manager else [],
'manager_chain': employee.get_manager_chain(),
'saved': False,
'errors': None,
}
from apps.tasks.models import Task
employee_tasks = (
Task.objects.filter(Q(responsible=employee) | Q(co_executors=employee))
.exclude(status='cancelled')
.distinct().order_by('-created_at')
)
shown_limit = 10
context['recent_tasks'] = list(employee_tasks[:shown_limit])
context['recent_tasks_total'] = employee_tasks.count()
context['recent_tasks_hidden'] = max(0, context['recent_tasks_total'] - shown_limit)
if can_view_pii:
context['last_attendance'] = (
employee.attendance_logs.select_related('location').order_by('-timestamp').first()
)
else:
context['last_attendance'] = None
context.update(extra)
if is_own_profile:
from core.portal_access import user_can_manage_hr_portal
context.update(_hr_requests_context(request, user_can_manage_hr_portal(request.user)))
context.update(_twofa_settings_context(employee))
return context
def _twofa_settings_context(user):
from core.security import user_account_mandatory_2fa, user_can_disable_2fa
return {
'twofa_enabled': bool(user.totp_enabled),
'twofa_mandatory': user_account_mandatory_2fa(user),
'twofa_can_disable': user_can_disable_2fa(user),
'twofa_backup_remaining': len(user.totp_backup_codes or []),
}
def _profile_settings_response(request, **extra):
employee = request.user
employee = (
Employee.objects.select_related(
'post', 'department', 'user_organization',
'user_pharmacy__organization', 'user_location', 'manager__post',
).prefetch_related('groups').get(pk=employee.pk)
)
context = _employee_profile_context(
request, employee, active_tab='settings', **extra,
)
return render_page(request, 'employee-profile.html', context)
@login_required
def employee_detail_view(request, pk):
employee = get_object_or_404(
Employee.objects.select_related(
'post', 'department', 'user_organization',
'user_pharmacy__organization', 'user_location', 'manager__post',
).prefetch_related('groups'),
pk=pk,
)
is_own_profile = request.user.pk == employee.pk
active_tab = (request.GET.get('tab') or request.POST.get('profile_tab') or 'overview').strip()
if request.method == 'POST':
if not is_own_profile:
from django.http import HttpResponseForbidden
return HttpResponseForbidden()
saved = False
errors = None
try:
_save_own_profile(employee, request.POST, request.FILES)
saved = True
except ValidationError as exc:
errors = list(exc.messages)
if active_tab not in ('contacts', 'settings'):
active_tab = 'contacts'
context = _employee_profile_context(
request, employee, active_tab=active_tab, saved=saved, errors=errors,
)
return render_page(request, 'employee-profile.html', context)
context = _employee_profile_context(request, employee, active_tab=active_tab)
return render_page(request, 'employee-profile.html', context)
def build_region_tree(region_nodes):
allowed_types = {'region', 'district', 'city', 'city_district'}
tree = []
def lookup_node(list_nodes, node_id):
for item in list_nodes:
if item['id'] == node_id:
return item
return None
for region in region_nodes:
ancestors = [node for node in list(region.get_ancestors()) if node.directory_type.code in allowed_types]
parent_list = tree
for ancestor in ancestors:
existing = lookup_node(parent_list, ancestor.id)
if existing is None:
existing = {
'id': ancestor.id,
'name': ancestor.name,
'children': []
}
parent_list.append(existing)
parent_list = existing['children']
return tree
def pharmacies_page_context(request, **extra):
pharmacies = accessible_pharmacies(request.user).select_related(
'organization', 'location_region', 'head_pharmacist',
).order_by('organization__name', 'pharmacy_number')
active_count = pharmacies.filter(is_active=True).count()
regions = pharmacies.values_list('location_region__name', flat=True).distinct().order_by('location_region__name')
region_ids = sorted({rid for rid in pharmacies.values_list('location_region_id', flat=True).distinct() if rid})
region_nodes = Directory.objects.filter(id__in=region_ids).select_related('parent', 'directory_type')
context = {
'pharmacies': pharmacies,
'active_count': active_count,
'regions': regions,
'region_tree': build_region_tree(region_nodes),
}
context.update(extra)
return context
@login_required
@section_required('pharmacies')
def pharmacies_view(request):
return render_page(request, 'pharmacies.html', pharmacies_page_context(request))
@login_required
def dislocation_view(request):
"""Дислокація: аптеки на карті/у таблиці разом з їхнім персоналом (замінює вкладку «Аптеки» на /structure/).
Справочник сети открыт всем сотрудникам с доступом к разделу — без скоупа
accessible_pharmacies (в отличие от /pharmacies/ и операционных разделов).
"""
from apps.it_manager.models import Equipment, InternetContract
from apps.locations.dislocation_columns import (
all_column_defs, column_defs_for, resolve_columns_for_user,
)
from apps.locations.models import DislocationColumnPreset, Pharmacy
from apps.power_assets.models import PowerAsset
mikrotik_q = (
Q(equipment_model__brand__name__icontains='mikrotik')
| Q(equipment_model__name__icontains='mikrotik')
| Q(equipment_model__brand__name__icontains='мікротік')
)
has_generator = Exists(
PowerAsset.objects.filter(
location_id=OuterRef('pk'),
asset_type__code='generator',
).exclude(status__code='written_off')
)
has_inverter = Exists(
PowerAsset.objects.filter(
location_id=OuterRef('pk'),
asset_type__code='inverter',
).exclude(status__code='written_off')
)
has_mikrotik = Exists(
Equipment.objects.filter(
location_id=OuterRef('pk'),
equipment_model__equipment_type__code='router',
)
.filter(mikrotik_q)
.exclude(status__code='written_off')
)
has_gpon = Exists(
InternetContract.objects.filter(
location_id=OuterRef('pk'),
status__code='active',
is_framework=False,
).filter(
Q(connection_type__code='gpon')
| Q(connection_type__name__icontains='gpon')
| Q(connection_type__name__icontains='гпон')
)
)
active_internet_qs = (
InternetContract.objects
.filter(status__code='active', is_framework=False)
.select_related('provider', 'connection_type')
.order_by('provider__name', 'contract_number')
)
pharmacies = (
Pharmacy.objects.all()
.select_related(
'organization', 'location_region', 'head_pharmacist',
'curator', 'responsible_manager',
)
.prefetch_related(
Prefetch(
'pharmacy_employees',
queryset=Employee.objects.filter(is_active=True).select_related('post'),
to_attr='active_staff',
),
Prefetch(
'internet_contracts',
queryset=active_internet_qs,
to_attr='active_internet',
),
)
.annotate(
staff_count=Count('pharmacy_employees', filter=Q(pharmacy_employees__is_active=True)),
has_generator=has_generator,
has_inverter=has_inverter,
has_mikrotik_router=has_mikrotik,
has_gpon=has_gpon,
)
.order_by('organization__name', 'pharmacy_number')
)
pharmacies = list(pharmacies)
for pharmacy in pharmacies:
if pharmacy.staff_count == 0:
pharmacy.staff_status = 'danger'
elif not pharmacy.head_pharmacist_id:
pharmacy.staff_status = 'warning'
else:
pharmacy.staff_status = 'ok'
pharmacy.has_map_location = pharmacy.has_coordinates
contracts = getattr(pharmacy, 'active_internet', []) or []
pharmacy.dislo_provider = ', '.join(dict.fromkeys(
c.provider.name for c in contracts if c.provider_id
))
pharmacy.dislo_contract = ', '.join(
c.contract_number for c in contracts if c.contract_number
)
active_count = sum(1 for p in pharmacies if p.is_active)
pharmacies_no_location = sum(1 for p in pharmacies if not p.has_map_location)
region_ids = sorted({p.location_region_id for p in pharmacies if p.location_region_id})
region_nodes = Directory.objects.filter(id__in=region_ids).select_related('parent', 'directory_type')
region_tree = build_region_tree(region_nodes)
# --- Офіс/склад: телефонний довідник по відділах ---
office_employees = list(
Employee.objects.filter(is_active=True, user_location_type__in=['office', 'warehouse'])
.select_related('post', 'department')
.order_by('last_name', 'first_name')
)
by_dept = {}
for emp in office_employees:
by_dept.setdefault(emp.department_id, []).append(emp)
office_departments = list(
Directory.objects.filter(directory_type__code='department', is_active=True)
.order_by('_path')
)
for dept in office_departments:
dept.indent_depth = (dept._path or '').count('.')
dept.staff = by_dept.get(dept.id, [])
office_departments = [d for d in office_departments if d.staff]
office_no_department = by_dept.get(None, [])
# --- Сервисы / поставщики (location_type=vendor) ---
from apps.locations.forms import PortalVendorForm
from apps.locations.models.base import Location, location_sort_key
from apps.locations.views import user_can_manage_vendors
vendors = sorted(
Location.objects.filter(location_type='vendor')
.select_related('location_region')
.order_by('name', 'id'),
key=location_sort_key,
)
can_manage_vendors = user_can_manage_vendors(request.user)
vendor_form = getattr(request, '_dislo_vendor_form', None)
if vendor_form is None and can_manage_vendors:
vendor_form = PortalVendorForm()
visible_keys = resolve_columns_for_user(request.user)
visible_columns = column_defs_for(visible_keys)
visible_set = set(visible_keys)
all_defs = all_column_defs()
panel_column_defs = list(visible_columns) + [d for d in all_defs if d.key not in visible_set]
presets = list(
DislocationColumnPreset.objects
.select_related('department')
.order_by('name')
)
can_manage_presets = bool(getattr(request.user, 'is_staff', False))
departments = []
if can_manage_presets:
departments = list(
Directory.objects.filter(directory_type__code='department', is_active=True)
.order_by('_path', 'name')
)
context = {
'pharmacies': pharmacies,
'active_count': active_count,
'organizations_count': len({p.organization_id for p in pharmacies if p.organization_id}),
'pharmacies_no_head': sum(1 for p in pharmacies if not p.head_pharmacist_id),
'pharmacies_empty': sum(1 for p in pharmacies if p.staff_count == 0),
'pharmacies_no_location': pharmacies_no_location,
'region_tree': region_tree,
'office_departments': office_departments,
'office_no_department': office_no_department,
'office_employees_count': len(office_employees),
'vendors': vendors,
'vendors_active_count': sum(1 for v in vendors if v.is_active),
'can_manage_vendors': can_manage_vendors,
'vendor_form': vendor_form,
'vendor_form_message': getattr(request, '_dislo_vendor_message', None),
'vendor_form_error': getattr(request, '_dislo_vendor_error', None),
'dislo_active_tab': (
getattr(request, '_dislo_tab', None)
or (request.GET.get('tab') or '').strip()
or 'pharmacy'
),
'visible_columns': visible_columns,
'visible_column_keys': visible_keys,
'all_column_defs': all_defs,
'panel_column_defs': panel_column_defs,
'dislocation_presets': presets,
'dislocation_presets_payload': [
{'code': p.code, 'columns': list(p.columns or []), 'is_default': p.is_default}
for p in presets
],
'dislo_colspan': len(visible_columns) + 1,
'can_manage_dislo_presets': can_manage_presets,
'dislocation_departments': departments,
}
return render_page(request, 'dislocation.html', context)
@login_required
@require_POST
def dislocation_columns_view(request):
"""Сохраняет персональный набор колонок дислокации (или применяет пресет)."""
from apps.locations.dislocation_columns import normalize_columns
from apps.locations.models import DislocationColumnPreset
preset_code = (request.POST.get('preset_code') or '').strip()
if preset_code:
preset = get_object_or_404(DislocationColumnPreset, code=preset_code)
columns = normalize_columns(preset.columns)
else:
columns = normalize_columns(request.POST.getlist('columns'))
Employee.objects.filter(pk=request.user.pk).update(dislocation_columns=columns)
request.user.dislocation_columns = columns
return dislocation_view(request)
def _unique_dislo_preset_code(base: str, *, exclude_pk=None) -> str:
from django.utils.text import slugify
from apps.locations.models import DislocationColumnPreset
code = slugify(base) or 'preset'
code = code[:50]
candidate = code
n = 2
qs = DislocationColumnPreset.objects.all()
if exclude_pk:
qs = qs.exclude(pk=exclude_pk)
while qs.filter(code=candidate).exists():
suffix = f'-{n}'
candidate = f'{code[:50 - len(suffix)]}{suffix}'
n += 1
return candidate
@login_required
@require_POST
def dislocation_column_presets_view(request):
"""CRUD пресетов колонок дислокации (только staff)."""
from django.core.exceptions import PermissionDenied
from apps.locations.dislocation_columns import normalize_columns
from apps.locations.models import DislocationColumnPreset
if not getattr(request.user, 'is_staff', False):
raise PermissionDenied
action = (request.POST.get('action') or '').strip()
columns = normalize_columns(request.POST.getlist('columns'))
name = (request.POST.get('name') or '').strip()
code_raw = (request.POST.get('code') or '').strip()
preset_code = (request.POST.get('preset_code') or '').strip()
department_id = (request.POST.get('department_id') or '').strip()
is_default = request.POST.get('is_default') in ('1', 'true', 'on', 'yes')
department = None
if department_id.isdigit():
department = Directory.objects.filter(
pk=int(department_id),
directory_type__code='department',
is_active=True,
).first()
if action == 'create':
if not name:
return dislocation_view(request)
code = _unique_dislo_preset_code(code_raw or name)
DislocationColumnPreset.objects.create(
name=name,
code=code,
columns=columns,
department=department,
is_default=is_default,
)
elif action == 'update':
preset = get_object_or_404(DislocationColumnPreset, code=preset_code)
preset.columns = columns
if name:
preset.name = name
if code_raw and code_raw != preset.code:
preset.code = _unique_dislo_preset_code(code_raw, exclude_pk=preset.pk)
preset.department = department
preset.is_default = is_default
preset.save()
elif action == 'delete':
get_object_or_404(DislocationColumnPreset, code=preset_code).delete()
return dislocation_view(request)
@login_required
def calendar_view(request):
from apps.visit_manager.views_calendar import calendar_view as _calendar_view
return _calendar_view(request)
@login_required
@section_required('timetrack')
def timetrack_view(request):
from core.accounting_portal import render_accounting_portal
return render_accounting_portal(request, 'timetrack')
def _timetrack_context(request):
"""Облік робочого часу: агрегація змін. SoT — PVMS, якщо увімкнено."""
import datetime
from django.conf import settings
from django.utils import timezone
from django.db.models import Sum, Count, Q
UKR_MONTHS = ['', 'Січень', 'Лютий', 'Березень', 'Квітень', 'Травень', 'Червень',
'Липень', 'Серпень', 'Вересень', 'Жовтень', 'Листопад', 'Грудень']
today = timezone.localdate()
month_str = request.GET.get('month') or today.strftime('%Y-%m')
try:
year, month = (int(x) for x in month_str.split('-'))
period_start = datetime.date(year, month, 1)
except (ValueError, TypeError):
period_start = today.replace(day=1)
year, month = period_start.year, period_start.month
month_str = period_start.strftime('%Y-%m')
next_month_start = (datetime.date(year + 1, 1, 1) if month == 12
else datetime.date(year, month + 1, 1))
period_end = next_month_start - datetime.timedelta(days=1)
prev_month = (period_start - datetime.timedelta(days=1)).strftime('%Y-%m')
next_month = next_month_start.strftime('%Y-%m')
location_type = request.GET.get('location_type', '')
rows, counts, stats = None, None, None
source = 'acore'
use_pvms = bool(getattr(settings, 'PVMS_SYNC_ENABLED', False))
sot = bool(getattr(settings, 'PVMS_ATTENDANCE_SOT', False))
if use_pvms:
if not has_all_locations(request.user):
from django.core.exceptions import PermissionDenied
raise PermissionDenied('Сводка PVMS требует доступа ко всей сети.')
try:
from apps.portal_connector.pvms_client import PvmsClient
client = PvmsClient()
if client.enabled:
data = client.timetrack(
period_start.isoformat(),
period_end.isoformat(),
location_type=location_type if location_type in ('office', 'pharmacy', 'warehouse') else '',
)
rows = data.get('rows') or []
counts = data.get('counts') or {'all': 0, 'office': 0, 'pharmacy': 0}
stats = data.get('stats') or {
'employees': 0, 'shifts': 0, 'workdays': 0, 'hours': 0, 'incomplete': 0,
}
source = 'pvms'
except Exception:
if sot:
rows, counts, stats = [], {'all': 0, 'office': 0, 'pharmacy': 0}, {
'employees': 0, 'shifts': 0, 'workdays': 0, 'hours': 0, 'incomplete': 0,
}
source = 'pvms-error'
else:
rows = None
if rows is None:
from apps.visit_manager.models import WorkShift
shifts = (scoped_queryset(request.user, WorkShift.objects.all())
.filter(date__gte=period_start, date__lte=period_end)
.select_related('employee', 'location'))
if location_type in ('office', 'pharmacy', 'warehouse'):
shifts = shifts.filter(location__location_type=location_type)
per_emp = (shifts.values('employee_id', 'employee__last_name', 'employee__first_name')
.annotate(total=Sum('duration'),
days=Count('date', distinct=True),
incomplete=Count('id', filter=Q(left_at__isnull=True)))
.order_by('employee__last_name', 'employee__first_name'))
rows, total_hours = [], 0.0
for r in per_emp:
dur = r['total']
hours = round(dur.total_seconds() / 3600, 1) if dur else 0.0
total_hours += hours
rows.append({
'employee_id': r['employee_id'],
'name': f"{r['employee__last_name']} {r['employee__first_name']}".strip(),
'hours': hours,
'days': r['days'],
'incomplete': r['incomplete'],
'avg': round(hours / r['days'], 1) if r['days'] else 0.0,
})
base = scoped_queryset(request.user, WorkShift.objects.all()).filter(date__gte=period_start, date__lte=period_end)
counts = {
'all': base.values('employee_id').distinct().count(),
'office': base.filter(location__location_type='office').values('employee_id').distinct().count(),
'pharmacy': base.filter(location__location_type='pharmacy').values('employee_id').distinct().count(),
}
stats = {
'employees': len(rows),
'shifts': shifts.count(),
'workdays': shifts.values('date').distinct().count(),
'hours': round(total_hours, 1),
'incomplete': shifts.filter(left_at__isnull=True).count(),
}
fg, muted, warn = 'var(--color-foreground)', 'var(--color-muted-foreground)', 'var(--color-warning)'
metrics = [
{'label': _('Рабочих дней'), 'value': stats['workdays'], 'hint': _('с отметками'), 'color': fg},
{'label': _('Сотрудников'), 'value': stats['employees'], 'hint': _('отмечались'), 'color': fg},
{'label': _('Смен'), 'value': stats['shifts'], 'hint': _('за месяц'), 'color': fg},
{'label': _('Незакрытых'), 'value': stats['incomplete'],'hint': _('без ухода'), 'color': warn if stats['incomplete'] else muted},
{'label': _('Всего часов'), 'value': stats['hours'], 'hint': _('отработано'), 'color': fg},
]
page = paginate(request, rows)
context = {
'page': page,
'month': month_str,
'month_label': f'{UKR_MONTHS[month]} {year}',
'prev_month': prev_month,
'next_month': next_month,
'location_type': location_type,
'counts': counts,
'stats': stats,
'metrics': metrics,
'timetrack_source': source,
}
return context
@login_required
@section_required('reports')
def reports_view(request):
"""Совместимость: основной хаб — apps.reports.views.reports_hub_view."""
from apps.reports.views import reports_hub_view
return reports_hub_view(request)
@login_required
def documents_view(request):
from apps.nextcloud_connector.access import (
accessible_sections, department_tree_for_user, people_picker_payload,
)
from django.contrib.auth.models import Group
ctx = {
'sections': accessible_sections(request.user),
'department_tree': department_tree_for_user(request.user),
'empty_ids': set(),
}
ctx.update(people_picker_payload())
ctx['group_options'] = list(
Group.objects.order_by('name').values('pk', 'name')
)
return render_page(request, 'documents.html', ctx)
@login_required
def recruiting_view(request):
"""Рекрутинг перенесён во вкладку «Вакансии» HR-портала."""
return redirect('/hr-portal/?tab=vacancies')
@login_required
@section_required('structure')
def structure_view(request):
"""Структура офісу: відділи. Аптеки з їхнім персоналом — на /dislocation/."""
departments = list(
Directory.objects.filter(directory_type__code='department', is_active=True)
.order_by('_path')
)
office_employees = list(
Employee.objects.filter(is_active=True, user_location_type='office')
.select_related('post')
)
by_dept = {}
for emp in office_employees:
by_dept.setdefault(emp.department_id, []).append(emp)
palette = ['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#ec4899', '#06b6d4', '#64748b']
for i, dept in enumerate(departments):
emps = by_dept.get(dept.id, [])
dept.color = palette[i % len(palette)]
dept.indent_depth = (dept._path or '').count('.')
dept.employees_count = len(emps)
dept.posts_count = len({e.post_id for e in emps if e.post_id})
# Керівник: у нього є підлеглі у відділі, а його керівник — поза відділом
ids = {e.pk for e in emps}
managers_in_dept = {e.manager_id for e in emps if e.manager_id in ids}
candidates = [e for e in emps if e.pk in managers_in_dept]
dept.head = (
next((e for e in candidates if e.manager_id not in ids), None)
or (candidates[0] if candidates else None)
)
pharmacy_employees_count = Employee.objects.filter(
is_active=True, user_location_type='pharmacy',
).count()
context = {
'departments': departments,
'office_count': len(office_employees),
'office_no_dept': sum(1 for e in office_employees if not e.department_id),
'pharmacy_employees_count': pharmacy_employees_count,
}
return render_page(request, 'structure.html', context)
@login_required
def settings_view(request):
return redirect(f"{reverse('employee_detail', args=[request.user.pk])}?tab=settings")
# Поля, які співробітник редагує сам на /profile/. Кадрові поля (посада,
# відділ, дата прийому, username/робочий email тощо) — лише через Django admin.
PROFILE_EDITABLE_FIELDS = [
'photo', 'phone', 'personal_phone', 'personal_email',
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
'default_page_size',
]
@login_required
def profile_view(request):
"""Редирект на объединённую карточку сотрудника; POST — сохранение своих данных."""
if request.method == 'POST':
return employee_detail_view(request, pk=request.user.pk)
tab = (request.GET.get('tab') or 'contacts').strip()
from django.shortcuts import redirect
from django.urls import reverse
return redirect(f"{reverse('employee_detail', args=[request.user.pk])}?tab={tab}")
def _build_chat_context(request):
from apps.mattermost_connector.models import MattermostUser
from apps.mattermost_connector.services.token_service import ensure_personal_access_token
try:
cfg = ConnectorConfig.get_for('mattermost')
public_url = ''
if cfg:
public_url = (cfg.extra_config or {}).get('public_url', '').strip().rstrip('/')
if not public_url:
return {'chat_state': 'not_configured'}
if not request.user.mattermost_user_id:
return {'chat_state': 'not_synced'}
token = ensure_personal_access_token(request.user)
if not token:
return {'chat_state': 'token_error'}
colleagues = [
{
'mattermostId': row['mattermost_id'],
'name': (f"{row['django_user__first_name']} {row['django_user__last_name']}".strip()
or row['django_user__username']),
}
for row in MattermostUser.objects.filter(is_active=True)
.exclude(django_user=request.user)
.select_related('django_user')
.values('mattermost_id', 'django_user__first_name', 'django_user__last_name', 'django_user__username')
]
ws_url = public_url.replace('https://', 'wss://', 1).replace('http://', 'ws://', 1) + '/api/v4/websocket'
return {
'chat_state': 'ready',
'chat_config': {
# Same-origin proxy — иначе браузер упирается в CORS Mattermost.
'apiBase': '/chat/api',
'wsUrl': ws_url,
'token': token,
'currentUserId': request.user.mattermost_user_id,
},
'chat_colleagues': colleagues,
}
except Exception:
logger.exception('Ошибка построения контекста чата для user_id=%s', getattr(request.user, 'pk', None))
return {'chat_state': 'token_error'}
@login_required
def chat_view(request):
return render_page(request, 'chat.html', _build_chat_context(request))
@login_required
def chat_sync_now_view(request):
if request.method != 'POST':
return HttpResponseNotAllowed(['POST'])
from apps.mattermost_connector.services.sync_service import sync_employee
sync_employee(request.user)
request.user.refresh_from_db()
return render_page(request, 'chat.html', _build_chat_context(request))
def _static_page_view(template_name, section=None):
"""Фабрика простых страниц: HTMX-фрагмент или полный layout"""
@login_required
def view(request):
return render_page(request, template_name)
if section:
return section_required(section)(view)
return view
email_view = _static_page_view('email.html', 'email') # legacy; route in mail_connector.urls
purchases_view = _static_page_view('purchases.html', 'purchases')
@login_required
def pharmacy_detail_view(request, pk):
"""Аптека целиком: реквизиты, лицензия, адрес/карта + операційні вкладки.
Публічне (будь-який logged-in): основна картка, контакти, ліцензія.
Операційні блоки — за політикою PharmacyCardVisibility (адмінка).
Редагування — за полем edit_pharmacy тієї ж політики.
"""
from apps.locations.models import Pharmacy
from apps.locations.views import _pharmacy_detail_context
pharmacy = get_object_or_404(
accessible_pharmacies(request.user).select_related(
'organization', 'location_region', 'head_pharmacist__post',
'responsible_manager', 'curator',
).prefetch_related('phones'),
pk=pk,
)
edit_mode = request.GET.get('edit') == '1'
context = _pharmacy_detail_context(request, pharmacy, edit_mode=edit_mode)
return render_page(request, 'pharmacy-detail.html', context)
def _hr_department_tree():
"""Дерево відділів {id, name, count, children: [...]} для деревовидного фільтра HR-порталу."""
depts = list(
Directory.objects.filter(directory_type__code='department', is_active=True)
.select_related('parent').order_by('_path', 'name')
)
counts = dict(
Employee.objects.filter(is_active=True, department__isnull=False)
.values_list('department_id').annotate(c=Count('id'))
)
nodes = {d.pk: {'id': d.pk, 'name': d.name, 'count': counts.get(d.pk, 0), 'children': []} for d in depts}
roots = []
for d in depts:
node = nodes[d.pk]
parent_node = nodes.get(d.parent_id)
if parent_node is not None:
parent_node['children'].append(node)
else:
roots.append(node)
return roots
def _hr_requests_context(request, can_manage: bool) -> dict:
from apps.employees.models import EmployeeHrRequest
from apps.employees.services.hr_requests import (
available_hr_request_types,
build_leave_balance,
open_substitution_requests,
next_pending_vacation,
pending_hr_requests_count,
)
from django.utils import timezone as dj_timezone
year = dj_timezone.localdate().year
create_types = available_hr_request_types(request.user)
ctx = {
'hr_request_types': create_types,
'hr_request_types_all': EmployeeHrRequest.REQUEST_TYPES,
'my_hr_requests': list(
EmployeeHrRequest.objects.filter(employee=request.user)
.select_related(
'substitute',
'employee__user_pharmacy',
'employee__user_pharmacy__organization',
)
.order_by('-created_at')[:30]
),
'incoming_substitutions': open_substitution_requests(request.user),
'leave_balance': build_leave_balance(request.user, year=year),
'leave_balance_year': year,
'next_vacation_request': next_pending_vacation(request.user),
'hr_pending_requests': [],
'hr_pending_leave_count': 0,
'hr_request_form': {},
'hr_substitute_choices': [],
'can_decide_hr_requests': can_manage,
}
if can_manage:
# Подмены утверждает откликнувшийся сотрудник — в очереди HR остальные типы.
ctx['hr_pending_requests'] = list(
EmployeeHrRequest.objects.filter(status='pending')
.exclude(request_type='substitution')
.select_related(
'employee',
'substitute',
'employee__user_pharmacy',
'employee__user_pharmacy__organization',
)
.order_by('created_at')[:100]
)
ctx['hr_pending_leave_count'] = pending_hr_requests_count()
return ctx
def _parse_optional_date(raw: str):
from datetime import datetime
if not raw:
return None
try:
return datetime.strptime(raw.strip(), '%Y-%m-%d').date()
except ValueError:
return None
def _validation_error_messages(exc: ValidationError) -> list[str]:
if hasattr(exc, 'message_dict'):
try:
messages = []
for msgs in exc.message_dict.values():
messages.extend(msgs)
if messages:
return messages
except AttributeError:
pass
return list(exc.messages)
def _hr_request_form_from_post(post) -> dict:
return {
'type': (post.get('request_type') or '').strip(),
'date_from': (post.get('date_from') or '').strip(),
'date_to': (post.get('date_to') or '').strip(),
'note': post.get('note', ''),
'substitute_id': (post.get('substitute_id') or '').strip(),
}
def _hr_request_portal_response(request, *, form_open=False, form_data=None, **extra):
context = _hr_portal_context(request)
context['hr_active_tab'] = 'requests'
if form_open:
context['hr_request_form_open'] = True
if form_data is not None:
context['hr_request_form'] = form_data
context.update(extra)
return render_page(request, 'hr-portal.html', context)
def _hr_request_profile_response(request, *, form_open=False, form_data=None, **extra):
employee = request.user
employee = (
Employee.objects.select_related(
'post', 'department', 'user_organization',
'user_pharmacy__organization', 'user_location', 'manager__post',
).prefetch_related('groups').get(pk=employee.pk)
)
context = _employee_profile_context(
request, employee, active_tab='overview', **extra,
)
if form_open:
context['hr_request_form_open'] = True
if form_data is not None:
context['hr_request_form'] = form_data
return render_page(request, 'employee-profile.html', context)
def _hr_request_response(request, *, form_open=False, form_data=None, **extra):
if request.POST.get('return_to') == 'profile':
return _hr_request_profile_response(
request, form_open=form_open, form_data=form_data, **extra,
)
return _hr_request_portal_response(
request, form_open=form_open, form_data=form_data, **extra,
)
def _hr_portal_context(request):
from django.utils import timezone as dj_timezone
from datetime import timedelta
from apps.hiring.models import HiringRequest
today = dj_timezone.localdate()
can_manage = user_can_manage_hr_portal(request.user)
from apps.hiring.recruiting import recruiting_context
recruiting = recruiting_context(request)
can_view_recruiting = recruiting['can_view_recruiting']
employees = Employee.objects.filter(is_active=True).select_related(
'post', 'department', 'user_pharmacy',
).order_by('last_name', 'first_name')
hr_posts = list(
Directory.objects.filter(directory_type__code='post', is_active=True)
.prefetch_related('location_types').order_by('name')
)
hr_post_location_types = {
p.id: [lt.code for lt in p.location_types.all()] for p in hr_posts
}
tab = (request.GET.get('tab') or '').strip()
allowed_tabs = {'employees', 'requests'}
if can_manage:
allowed_tabs.update({'probation', 'trainings', 'vacancies', 'hiring'})
if can_view_recruiting:
allowed_tabs.add('vacancies')
hr_active_tab = tab if tab in allowed_tabs else 'employees'
context = {
'employees': employees,
'employees_total': employees.count(),
'hr_department_tree': _hr_department_tree(),
'hr_posts': hr_posts,
'hr_post_location_types': hr_post_location_types,
'today': today,
'can_manage_hiring': can_manage,
'hr_active_tab': hr_active_tab,
'employees_new': 0,
'employees_on_probation': [],
'employees_probation': 0,
'intern_slots_total': 0,
'intern_slots_free': 0,
'hiring_requests': paginate(request, HiringRequest.objects.none()),
'trial_hiring_requests': [],
'hr_access_systems': [],
'hr_access_location_types': {},
'hr_pharmacies': [],
'hr_locations': [],
'hr_trial_days_choices': range(1, 6),
}
context.update(_hr_requests_context(request, can_manage))
context.update(recruiting)
if not can_manage:
return context
from apps.locations.models.pharmacies import Pharmacy
from apps.locations.models import Location
month_ago = today - timedelta(days=30)
employees_on_probation = [e for e in employees if e.is_on_probation]
trial_hiring_count = HiringRequest.objects.filter(status='trial').count()
intern_total = 0
intern_free = 0
try:
from apps.hiring.models import InternAccount
intern_qs = InternAccount.objects.filter(is_active=True)
intern_total = intern_qs.count()
intern_free = intern_qs.filter(assigned_to__isnull=True).count()
except Exception:
pass
context.update({
'employees_new': employees.filter(hire_date__gte=month_ago).count(),
'employees_on_probation': employees_on_probation,
'employees_probation': trial_hiring_count + len(employees_on_probation),
'intern_slots_total': intern_total,
'intern_slots_free': intern_free,
})
hiring_requests_qs = HiringRequest.objects.select_related(
'employee', 'post', 'department', 'user_pharmacy', 'user_location',
'requested_by', 'decision_by', 'granted_ticket', 'intern_account',
).order_by('-created_at')
trial_hiring_qs = hiring_requests_qs.filter(status='trial')
hr_access_systems = list(
Directory.objects.filter(
directory_type__code='access_system', is_active=True,
).prefetch_related('location_types').order_by('name')
)
hiring_requests_page = paginate(
request, hiring_requests_qs, page_param='hiring_page', size_param='hiring_per_page',
)
from apps.employees.services.password_invite import (
build_invite_absolute_url,
get_active_invite_for_employee,
)
for hr in hiring_requests_page.object_list:
invite = None
if hr.employee_id:
invite = get_active_invite_for_employee(hr.employee)
hr.password_invite_url = (
build_invite_absolute_url(request, invite) if invite else None
)
context.update({
'hiring_requests': hiring_requests_page,
'trial_hiring_requests': list(trial_hiring_qs),
'hr_access_systems': hr_access_systems,
'hr_access_location_types': {
a.id: [lt.code for lt in a.location_types.all()] for a in hr_access_systems
},
'hr_pharmacies': accessible_pharmacies(request.user).filter(is_active=True).order_by('pharmacy_number'),
'hr_locations': Location.objects.filter(
location_type__in=['office', 'warehouse'], is_active=True,
).order_by('location_type', 'address'),
})
return context
@login_required
def hr_portal_view(request):
return render_page(request, 'hr-portal.html', _hr_portal_context(request))
@login_required
@hr_manage_required
def hiring_create_view(request):
hiring_form_errors = None
hiring_just_created = None
if request.method == 'POST':
from datetime import datetime
from apps.hiring.services import create_hiring_request
last_name = request.POST.get('last_name', '').strip()
first_name = request.POST.get('first_name', '').strip()
middle_name = request.POST.get('middle_name', '').strip()
location_type = request.POST.get('user_location_type', '')
hire_date_raw = request.POST.get('hire_date', '')
trial_days_raw = request.POST.get('trial_days', '3')
if last_name and first_name and location_type:
try:
hire_date = datetime.strptime(hire_date_raw, '%Y-%m-%d').date()
except ValueError:
from django.utils import timezone as dj_timezone
hire_date = dj_timezone.localdate()
try:
trial_days = max(1, min(5, int(trial_days_raw)))
except ValueError:
trial_days = 3
department_id = request.POST.get('department_id') or None
pharmacy_id = request.POST.get('pharmacy_id') or None
location_id = request.POST.get('location_id') or None
post_id = request.POST.get('post_id') or None
try:
hiring_just_created = create_hiring_request(
requester=request.user,
last_name=last_name,
first_name=first_name,
middle_name=middle_name,
candidate_email=request.POST.get('candidate_email', '').strip(),
candidate_phone=request.POST.get('candidate_phone', '').strip(),
inn_code=request.POST.get('inn_code', '').strip(),
user_location_type=location_type,
department_id=department_id,
user_pharmacy_id=pharmacy_id,
user_location_id=location_id,
post_id=post_id,
hire_date=hire_date,
trial_days=trial_days,
employment_type=request.POST.get('employment_type', '').strip(),
requested_access_ids=request.POST.getlist('requested_access'),
access_note=request.POST.get('access_note', '').strip(),
)
except ValidationError as exc:
hiring_form_errors = exc.message_dict if hasattr(exc, 'message_dict') else {
'__all__': list(exc.messages),
}
context = _hr_portal_context(request)
if hiring_form_errors:
context['hr_active_tab'] = 'probation'
context['hiring_form_errors'] = hiring_form_errors
context['hiring_form_open'] = True
elif hiring_just_created is not None:
# После успешного создания показываем вкладку «На испытании»
# со свежим списком и баннером с данными интерна.
from apps.hiring.models import HiringRequest
context['hiring_just_created'] = (
HiringRequest.objects
.select_related('intern_account', 'granted_ticket')
.get(pk=hiring_just_created.pk)
)
context['hr_active_tab'] = 'probation'
else:
context['hr_active_tab'] = 'probation'
context['hiring_form_open'] = True
return render_page(request, 'hr-portal.html', context)
@login_required
@hr_manage_required
def hiring_approve_view(request, pk):
from apps.hiring.models import HiringRequest
from apps.hiring.services import approve_hiring_request
if request.method == 'POST':
hiring_request = get_object_or_404(HiringRequest, pk=pk)
approve_hiring_request(hiring_request, decided_by=request.user, note=request.POST.get('note', '').strip())
context = _hr_portal_context(request)
context['hr_active_tab'] = 'probation'
return render_page(request, 'hr-portal.html', context)
@login_required
@hr_manage_required
def hiring_reject_view(request, pk):
from apps.hiring.models import HiringRequest
from apps.hiring.services import reject_hiring_request
if request.method == 'POST':
hiring_request = get_object_or_404(HiringRequest, pk=pk)
reject_hiring_request(hiring_request, decided_by=request.user, note=request.POST.get('note', '').strip())
context = _hr_portal_context(request)
context['hr_active_tab'] = 'probation'
return render_page(request, 'hr-portal.html', context)
@login_required
@hr_manage_required
@require_POST
def hiring_regenerate_invite_view(request, pk):
from apps.hiring.models import HiringRequest
from apps.employees.services.password_invite import create_password_invite
hiring_request = get_object_or_404(HiringRequest, pk=pk, status='approved')
if not hiring_request.employee_id:
return HttpResponseForbidden()
create_password_invite(hiring_request.employee, created_by=request.user)
context = _hr_portal_context(request)
context['hr_active_tab'] = 'probation'
return render_page(request, 'hr-portal.html', context)
@login_required
@hr_manage_required
def hiring_credentials_pdf_view(request, pk):
from apps.hiring.models import HiringRequest
from apps.hiring.services import build_hiring_credentials_pdf
hiring_request = get_object_or_404(HiringRequest, pk=pk)
content = build_hiring_credentials_pdf(hiring_request, request=request)
if content is None:
return HttpResponseForbidden()
filename = f'credentials-{hiring_request.employee.username}.pdf'
response = HttpResponse(content, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@login_required
@section_required('databases')
def databases_view(request):
from core.directory_portal import databases_view as _databases_view
return _databases_view(request)
@login_required
def it_support_view(request):
"""Підтримка IT: створення заявки та мої заявки"""
from apps.it_manager.models import Ticket
from apps.it_manager.views import _directory_choices, _resolve_directory
ticket_created = None
if request.method == 'POST':
title = request.POST.get('title', '').strip()
description = request.POST.get('description', '').strip()
ticket_type_id = _resolve_directory('ticket_type', request.POST.get('ticket_type', '')) \
or _resolve_directory('ticket_type', 'other')
priority_id = _resolve_directory('ticket_priority', request.POST.get('priority', '')) \
or _resolve_directory('ticket_priority', 'medium')
if title and description:
ticket_created = Ticket.objects.create(
title=title,
description=description,
ticket_type_id=ticket_type_id,
priority_id=priority_id,
requester=request.user,
)
my_tickets_qs = Ticket.objects.filter(requester=request.user).select_related(
'ticket_type', 'priority', 'status',
).order_by('-created_at')
my_tickets_page = paginate(request, my_tickets_qs, default_size=10, page_param='tickets_page', size_param='tickets_per_page')
context = {
'ticket_types': _directory_choices('ticket_type'),
'ticket_priorities': _directory_choices('ticket_priority'),
'my_tickets': my_tickets_page,
'my_open_count': Ticket.objects.filter(
requester=request.user, status__code__in=['new', 'in_progress', 'waiting'],
).count(),
'ticket_created': ticket_created,
}
return render_page(request, 'it-support.html', context)
# Довідники, у яких дозволено "на льоту" додавати нові записи прямо з форм.
# None — може будь-який залогінений (форма відкрита всім, напр. /it-support/);
# 'it' — лише IT-група (форма доступна тільки на /it-manager/).
DIRECTORY_QUICK_ADD_TYPES = {
'ticket_type': None,
'ticket_priority': None,
'equipment_type': 'it',
'equipment_brand': 'it',
'worklog_type': 'it',
'consumable_type': 'it',
'consumable_color': 'it',
'contract_service_type': 'it',
'contract_connection_type': 'it',
'servicecall_channel': 'it',
}
@login_required
def directory_quick_add_view(request):
"""Швидке додавання нового запису в довідник прямо з форми (select), без переходу в admin."""
if request.method != 'POST':
return HttpResponseNotAllowed(['POST'])
from apps.it_manager.views import _is_it_staff
from core.models.directories import DirectoryType
type_code = request.POST.get('type_code', '')
name = request.POST.get('name', '').strip()
if type_code not in DIRECTORY_QUICK_ADD_TYPES or not name:
return JsonResponse({'error': 'Некоректні дані'}, status=400)
required_role = DIRECTORY_QUICK_ADD_TYPES[type_code]
if required_role == 'it' and not _is_it_staff(request.user):
return HttpResponseForbidden()
directory_type = DirectoryType.objects.filter(code=type_code).first()
if not directory_type:
return JsonResponse({'error': 'Довідник не знайдено'}, status=400)
directory, _ = Directory.objects.get_or_create(
directory_type=directory_type, name=name, defaults={'is_active': True},
)
return JsonResponse({'id': directory.id, 'code': directory.code, 'name': directory.name})
LOGIN_MAX_ATTEMPTS = 5 # неудачных попыток
LOGIN_BLOCK_SECONDS = 5 * 60 # окно блокировки
TWOFA_MAX_ATTEMPTS = 5
TWOFA_BLOCK_SECONDS = 5 * 60
def _login_throttle_keys(request, username: str | None = None):
"""Per-username primary; IP secondary (shared behind Traefik is OK as soft limit)."""
keys = []
uname = (username or request.POST.get('username') or '').strip().lower()
if uname:
keys.append(f'login_attempts:user:{uname}')
ip = request.META.get('REMOTE_ADDR', '') or 'unknown'
keys.append(f'login_attempts:ip:{ip}')
return keys
def _throttle_blocked(keys) -> bool:
return any(cache.get(k, 0) >= LOGIN_MAX_ATTEMPTS for k in keys)
def _throttle_hit(keys) -> None:
for k in keys:
attempts = cache.get(k, 0)
cache.set(k, attempts + 1, LOGIN_BLOCK_SECONDS)
def _throttle_clear(keys) -> None:
for k in keys:
cache.delete(k)
def login_view(request):
if request.method == 'POST':
username = request.POST.get('username')
keys = _login_throttle_keys(request, username)
if _throttle_blocked(keys):
return render(request, 'login.html', {
'login_error': 'Слишком много попыток входа. Повторите позже.',
}, status=429)
password = request.POST.get('password')
user = authenticate(request, username=username, password=password)
if user is not None:
_throttle_clear(keys)
login(request, user)
from apps.mail_connector.login_hooks import (
on_portal_auth_complete,
on_portal_login,
should_defer_mail_activation,
)
on_portal_login(request, user, password)
if not should_defer_mail_activation(user):
on_portal_auth_complete(request, user)
next_url = request.GET.get('next') or request.POST.get('next') or '/'
if not url_has_allowed_host_and_scheme(next_url, allowed_hosts=settings.ALLOWED_HOSTS):
next_url = '/'
return redirect(next_url)
_throttle_hit(keys)
return render(request, 'login.html', {
'login_error': 'Неверный логин или пароль',
'setup_success': request.GET.get('setup') == '1',
'setup_username': request.GET.get('user', ''),
})
return render(request, 'login.html', {
'setup_success': request.GET.get('setup') == '1',
'setup_username': request.GET.get('user', ''),
})
@require_POST
def logout_view(request):
logout(request)
return redirect('login')
# ── Двухфакторная аутентификация (TOTP) ──────────────────────────────────
@login_required
def twofa_setup_view(request):
"""Настройка 2FA: QR + ручной ключ, подтверждение кодом → включение."""
from apps.employees.services import twofa
from core.security import log_security_event
user = request.user
return_to = (request.GET.get('return_to') or request.POST.get('return_to') or '').strip()
profile_flow = return_to == 'profile'
if user.totp_enabled:
if profile_flow:
return _profile_settings_response(request, twofa_message='2FA уже включена.')
return redirect('/')
reset_notice = ''
if request.GET.get('reset') == '1':
reset_notice = (
'Сохранённый ключ 2FA повреждён или не расшифровывается '
'(например, после смены FIELD_ENCRYPTION_KEY). Настройте 2FA заново.'
)
secret = request.session.get('2fa_setup_secret')
if not secret:
secret = twofa.generate_secret()
request.session['2fa_setup_secret'] = secret
error = ''
if request.method == 'POST':
if twofa.verify_totp(secret, request.POST.get('code', '')):
codes, hashes = twofa.generate_backup_codes()
user.totp_secret = secret
user.totp_enabled = True
user.totp_backup_codes = hashes
user.save(update_fields=['totp_secret', 'totp_enabled', 'totp_backup_codes'])
request.session.pop('2fa_setup_secret', None)
request.session['2fa_verified'] = True
from apps.mail_connector.login_hooks import on_portal_auth_complete
on_portal_auth_complete(request, user)
log_security_event('twofa_enabled', actor=user, target=user.username)
if profile_flow:
return _profile_settings_response(
request,
twofa_backup_codes=codes,
twofa_message='2FA успешно включена. Сохраните резервные коды.',
)
request.session['2fa_backup_codes'] = codes
return redirect('twofa_backup_codes')
error = 'Неверный код. Проверьте время на устройстве и попробуйте снова.'
uri = twofa.provisioning_uri(user, secret)
setup_ctx = {
'twofa_setup_secret': secret,
'twofa_setup_qr': twofa.qr_data_uri(uri),
'twofa_setup_error': error,
'twofa_reset_notice': reset_notice,
'twofa_show_setup': True,
}
if profile_flow:
return _profile_settings_response(request, **setup_ctx)
return render(request, '2fa-setup.html', {
'secret': secret, 'qr': twofa.qr_data_uri(uri), 'error': error,
'reset_notice': reset_notice,
})
@login_required
@require_POST
def twofa_disable_view(request):
"""Отключение добровольной 2FA (не для staff/IT)."""
from apps.employees.services import twofa
from core.security import log_security_event, user_can_disable_2fa
user = request.user
if not user_can_disable_2fa(user):
return _profile_settings_response(
request, twofa_error='Отключение 2FA недоступно для вашего аккаунта.',
)
if not twofa.verify_totp(user.totp_secret, request.POST.get('code', '')):
return _profile_settings_response(request, twofa_error='Неверный код из приложения.')
twofa.disable(user)
request.session.pop('2fa_verified', None)
log_security_event('twofa_disabled', actor=user, target=user.username)
return _profile_settings_response(request, twofa_message='2FA отключена.')
@login_required
@require_POST
def twofa_regenerate_backup_view(request):
"""Новые резервные коды (старые перестают работать)."""
from apps.employees.services import twofa
from core.security import log_security_event
user = request.user
if not user.totp_enabled:
return _profile_settings_response(request, twofa_error='2FA не включена.')
if not twofa.verify_totp(user.totp_secret, request.POST.get('code', '')):
return _profile_settings_response(request, twofa_error='Неверный код из приложения.')
codes, hashes = twofa.generate_backup_codes()
user.totp_backup_codes = hashes
user.save(update_fields=['totp_backup_codes'])
log_security_event('twofa_backup_regenerated', actor=user, target=user.username)
return _profile_settings_response(
request,
twofa_backup_codes=codes,
twofa_message='Новые резервные коды сгенерированы. Сохраните их — старые больше не работают.',
)
@login_required
def twofa_backup_codes_view(request):
"""Показ резервных кодов один раз (после включения 2FA)."""
codes = request.session.pop('2fa_backup_codes', None)
if not codes:
return redirect('/')
return render(request, '2fa-backup-codes.html', {'codes': codes})
def twofa_verify_view(request):
"""Ввод TOTP/резервного кода после входа (сессия ещё не 2fa_verified)."""
from apps.employees.services import twofa
user = request.user
if not user.is_authenticated:
return redirect('login')
if not user.totp_enabled:
return redirect('twofa_setup')
if twofa.reset_invalid_2fa(user):
return redirect(f"{reverse('twofa_setup')}?reset=1")
next_url = request.GET.get('next') or request.POST.get('next') or '/'
if not url_has_allowed_host_and_scheme(next_url, allowed_hosts=settings.ALLOWED_HOSTS):
next_url = '/'
if request.session.get('2fa_verified'):
return redirect(next_url)
throttle_key = f'2fa_attempts:{user.pk}'
error = ''
if request.method == 'POST':
attempts = cache.get(throttle_key, 0)
if attempts >= TWOFA_MAX_ATTEMPTS:
return render(
request,
'2fa-verify.html',
{
'error': 'Слишком много попыток. Повторите позже.',
'next': next_url,
},
status=429,
)
code = request.POST.get('code', '')
if twofa.verify_totp(user.totp_secret, code) or twofa.verify_and_consume_backup(user, code):
cache.delete(throttle_key)
request.session['2fa_verified'] = True
from apps.mail_connector.login_hooks import on_portal_auth_complete
on_portal_auth_complete(request, user)
return redirect(next_url)
cache.set(throttle_key, attempts + 1, TWOFA_BLOCK_SECONDS)
error = 'Неверный код.'
return render(request, '2fa-verify.html', {'error': error, 'next': next_url})
@login_required
@hr_manage_required
def modal_add_employee_view(request):
return render(request, 'partials/modal-add-employee.html')
@login_required
@require_POST
def hr_request_create_view(request):
from django.db import DatabaseError
from apps.employees.models import Employee
from apps.employees.services.hr_requests import create_hr_request
form_data = _hr_request_form_from_post(request.POST)
request_type = form_data['type']
date_from = _parse_optional_date(form_data['date_from'])
date_to = _parse_optional_date(form_data['date_to'])
note = form_data['note']
substitute = None
sid = form_data.get('substitute_id') or ''
if sid.isdigit():
substitute = Employee.objects.filter(pk=int(sid), is_active=True).first()
try:
created = create_hr_request(
employee=request.user,
request_type=request_type,
date_from=date_from,
date_to=date_to,
note=note,
substitute=substitute,
)
return _hr_request_response(request, hr_request_created=created)
except ValidationError as exc:
return _hr_request_response(
request,
hr_request_errors=_validation_error_messages(exc),
form_open=True,
form_data=form_data,
)
except DatabaseError:
return _hr_request_response(
request,
hr_request_errors=[
'Не удалось сохранить заявку. Обратитесь к администратору или выполните migrate.',
],
form_open=True,
form_data=form_data,
)
@login_required
@require_POST
def hr_request_cancel_view(request, pk):
from apps.employees.models import EmployeeHrRequest
from apps.employees.services.hr_requests import cancel_hr_request
req = get_object_or_404(EmployeeHrRequest, pk=pk)
try:
cancel_hr_request(req, actor=request.user)
except ValidationError as exc:
errors = list(exc.messages)
return _hr_request_response(request, hr_request_errors=errors)
return _hr_request_response(request)
@login_required
@hr_manage_required
@require_POST
def hr_request_approve_view(request, pk):
from apps.employees.models import EmployeeHrRequest
from apps.employees.services.hr_requests import decide_hr_request
req = get_object_or_404(EmployeeHrRequest, pk=pk)
if req.request_type == 'substitution':
return _hr_request_portal_response(
request,
hr_request_errors=['Подмену подтверждает выбранный сотрудник, не HR.'],
)
try:
decide_hr_request(req, decided_by=request.user, approve=True)
except ValidationError as exc:
return _hr_request_portal_response(request, hr_request_errors=list(exc.messages))
return _hr_request_portal_response(request)
@login_required
@hr_manage_required
@require_POST
def hr_request_reject_view(request, pk):
from apps.employees.models import EmployeeHrRequest
from apps.employees.services.hr_requests import decide_hr_request
req = get_object_or_404(EmployeeHrRequest, pk=pk)
if req.request_type == 'substitution':
return _hr_request_portal_response(
request,
hr_request_errors=['Подмену отклоняет выбранный сотрудник.'],
)
try:
decide_hr_request(
req,
decided_by=request.user,
approve=False,
decision_note=request.POST.get('decision_note', ''),
)
except ValidationError as exc:
return _hr_request_portal_response(request, hr_request_errors=list(exc.messages))
return _hr_request_portal_response(request)
@login_required
@require_POST
def hr_substitution_confirm_view(request, pk):
from apps.employees.models import EmployeeHrRequest
from apps.employees.services.hr_requests import confirm_substitution
req = get_object_or_404(EmployeeHrRequest, pk=pk)
try:
confirm_substitution(req, actor=request.user)
except ValidationError as exc:
return _hr_request_response(request, hr_request_errors=list(exc.messages))
return _hr_request_response(request)
@login_required
@require_POST
def hr_substitution_decline_view(request, pk):
from apps.employees.models import EmployeeHrRequest
from apps.employees.services.hr_requests import decline_substitution
req = get_object_or_404(EmployeeHrRequest, pk=pk)
try:
decline_substitution(
req,
actor=request.user,
decision_note=request.POST.get('decision_note', ''),
)
except ValidationError as exc:
return _hr_request_response(request, hr_request_errors=list(exc.messages))
return _hr_request_response(request)