fix permisions

This commit is contained in:
2026-09-14 14:09:37 +03:00
parent f46e0d90ba
commit f0a23caa7b
6 changed files with 241 additions and 9 deletions
+5 -1
View File
@@ -208,7 +208,10 @@ class EmployeeAdmin(ModelAdmin):
'user_location_type', 'user_pharmacy', 'department_display', 'post',
'is_active', 'twofa_status', 'hire_date'
]
list_filter = ['groups', 'is_active', 'is_staff', 'user_location_type', 'department', 'totp_enabled']
list_filter = [
'groups', 'is_active', 'is_staff', 'user_location_type',
'department', 'totp_enabled', 'it_work_channel',
]
search_fields = ['username', 'email', 'last_name', 'first_name', 'middle_name']
list_select_related = ['user_pharmacy', 'department', 'post']
readonly_fields = ('photo_preview',)
@@ -245,6 +248,7 @@ class EmployeeAdmin(ModelAdmin):
'user_location_type', 'user_organization',
'user_pharmacy', 'user_location',
'department', 'post', 'manager',
'it_work_channel',
),
'classes': ('tab',),
}),
@@ -0,0 +1,58 @@
# Generated manually for Employee.it_work_channel
from django.db import migrations, models
def seed_it_channels(apps, schema_editor):
Employee = apps.get_model('employees', 'Employee')
# last_name (normalized lower) → channel
mapping = {
'апродов': 'remote',
'когутенко': 'field',
'хижняк': 'office',
'бабич': 'infra',
}
for emp in Employee.objects.all().only('id', 'last_name', 'it_work_channel'):
key = (emp.last_name or '').strip().lower()
channel = mapping.get(key)
if channel and not emp.it_work_channel:
emp.it_work_channel = channel
emp.save(update_fields=['it_work_channel'])
def unseed_it_channels(apps, schema_editor):
Employee = apps.get_model('employees', 'Employee')
Employee.objects.exclude(it_work_channel='').update(it_work_channel='')
class Migration(migrations.Migration):
dependencies = [
('employees', '0020_alter_employeehrrequest_substitute'),
]
operations = [
migrations.AddField(
model_name='employee',
name='it_work_channel',
field=models.CharField(
blank=True,
choices=[
('', '— не задано (авто из роли IT-отчёта)'),
('remote', 'Удалённо (без выезда)'),
('field', 'С выездом'),
('office', 'Офис'),
('infra', 'Инфраструктура / руководство'),
],
default='',
help_text=(
'Для месячного IT-отчёта: как учитывать задачи сотрудника '
'(удалённо / с выездом / офис / инфра). '
'Объект заявки (аптека А35…) сам по себе выездом не считается.'
),
max_length=16,
verbose_name='Канал работ (IT-отчёт)',
),
),
migrations.RunPython(seed_it_channels, unseed_it_channels),
]
+20
View File
@@ -167,6 +167,26 @@ class Employee(AbstractBaseUser, PermissionsMixin, BaseModel):
ad_sync_enabled = models.BooleanField(_('Синхронизация AD'), default=False,
help_text=_('Только для офисных сотрудников'))
IT_WORK_CHANNEL_CHOICES = [
('', _('— не задано (авто из роли IT-отчёта)')),
('remote', _('Удалённо (без выезда)')),
('field', _('С выездом')),
('office', _('Офис')),
('infra', _('Инфраструктура / руководство')),
]
it_work_channel = models.CharField(
_('Канал работ (IT-отчёт)'),
max_length=16,
choices=IT_WORK_CHANNEL_CHOICES,
blank=True,
default='',
help_text=_(
'Для месячного IT-отчёта: как учитывать задачи сотрудника '
'(удалённо / с выездом / офис / инфра). '
'Объект заявки (аптека А35…) сам по себе выездом не считается.'
),
)
default_page_size = models.PositiveSmallIntegerField(
_('Записей на странице'), choices=[(n, str(n)) for n in PAGE_SIZE_CHOICES],
default=DEFAULT_PAGE_SIZE,
+2
View File
@@ -224,6 +224,7 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = None)
name = (staff_meta or {}).get('name') or tech.get_full_name() or tech.username
short = (staff_meta or {}).get('short') or name
role = (staff_meta or {}).get('role') or ''
it_work_channel = getattr(tech, 'it_work_channel', '') or ''
tasks = []
systems = []
for log in tech_logs:
@@ -271,6 +272,7 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = None)
'name': name,
'short': short,
'role': role,
'it_work_channel': it_work_channel,
'tasks': tasks,
'systems': systems,
'tasks_count': len(tasks),
+76 -8
View File
@@ -27,6 +27,71 @@ STAFF_PHRASES = {
},
}
# Явные bucket в company.json staff[].bucket имеют приоритет после STAFF_PHRASES.
_ROLE_BUCKET_HINTS = (
(r'выезд', 'field'),
(r'удал[её]н', 'remote'),
(r'remote|help\s*desk|l1\b', 'remote'),
(r'офис', 'office'),
(r'руководител|архитектор|infra|инфраструктур', 'infra'),
)
_OFFICE_OBJECTS = frozenset({'ОФИС', 'ОФІС', ''})
def _bucket_from_role_text(role: str) -> str | None:
low = (role or '').lower()
if not low:
return None
for pat, bucket in _ROLE_BUCKET_HINTS:
if re.search(pat, low, re.I):
return bucket
return None
def _staff_meta_by_id(company: dict | None) -> dict[str, dict]:
return {
s['id']: s
for s in (company or {}).get('staff') or []
if s.get('id')
}
def resolve_work_bucket(
staff_id: str,
emp: dict,
company: dict | None = None,
) -> str:
"""
Канал работ для итогов: remote | field | office | infra.
Приоритет:
1) Employee.it_work_channel (поле в карточке сотрудника)
2) STAFF_PHRASES / company.json bucket / роль
3) unknown → в подсчёте аптека ≠ выезд
Объект «А35» в WorkLog — это *о чём* заявка, а не факт выезда.
"""
channel = (emp.get('it_work_channel') or emp.get('work_channel') or '').strip().lower()
if channel in ('remote', 'field', 'office', 'infra'):
return channel
phrase = STAFF_PHRASES.get(staff_id) or {}
if phrase.get('bucket'):
return phrase['bucket']
meta = _staff_meta_by_id(company).get(staff_id) or {}
explicit = (meta.get('bucket') or '').strip().lower()
if explicit in ('remote', 'field', 'office', 'infra'):
return explicit
for role in (meta.get('role') or '', emp.get('role') or ''):
inferred = _bucket_from_role_text(role)
if inferred:
return inferred
return 'unknown'
FIELD_HINTS = [
(r'mikro.?tik|роутер', 'замена роутеров на MikroTik'),
(r'принтер|мфу|печат', 'установка и обслуживание принтеров/МФУ'),
@@ -123,7 +188,7 @@ def build_conclusions(report: dict, company: dict | None = None) -> list[dict]:
counts = {'remote': 0, 'field': 0, 'office': 0, 'infra': 0}
for staff_id, emp in by_id.items():
bucket = (STAFF_PHRASES.get(staff_id) or {}).get('bucket')
bucket = resolve_work_bucket(staff_id, emp, company)
n = int(emp.get('tasks_count') or 0) + int(emp.get('systems_count') or 0)
if bucket == 'infra':
counts['infra'] += n
@@ -134,14 +199,17 @@ def build_conclusions(report: dict, company: dict | None = None) -> list[dict]:
elif bucket == 'office':
counts['office'] += n
else:
# неизвестный сотрудник — по объектам
office_n = sum(
1 for t in (emp.get('tasks') or [])
if (t.get('object') or '').upper() in ('ОФИС', 'ОФІС', '')
)
field_n = len(emp.get('tasks') or []) - office_n
# unknown: объект аптеки в заявке ≠ выезд (часто удалённая поддержка точки)
office_n = 0
remote_n = 0
for t in emp.get('tasks') or []:
if (t.get('object') or '').upper() in _OFFICE_OBJECTS:
office_n += 1
else:
remote_n += 1
remote_n += int(emp.get('systems_count') or 0)
counts['office'] += office_n
counts['field'] += max(0, field_n)
counts['remote'] += remote_n
operational = {
'remote': counts['remote'],
+80
View File
@@ -207,6 +207,86 @@ class MonthlyReportTests(TestCase):
self.assertTrue(last.get('bullets'))
self.assertTrue(any('удаленно' in b for b in last['bullets']))
def test_pharmacy_object_is_not_field_visit_for_remote_or_unknown(self):
"""Удалёнка и неизвестные сотрудники с объектом «А35» не должны попадать в «с выездом»."""
from apps.reports.it_monthly.conclusions import resolve_work_bucket
self.assertEqual(
resolve_work_bucket(
'aprodov',
{'role': 'Специалист удалённой поддержки', 'tasks': [{'object': 'А35'}]},
{},
),
'remote',
)
# Поле карточки сотрудника важнее STAFF_PHRASES / объекта аптеки
self.assertEqual(
resolve_work_bucket(
'kogutenko',
{'it_work_channel': 'remote', 'tasks': [{'object': 'А12'}]},
{},
),
'remote',
)
company = {
'staff': [
{
'id': 'new_remote',
'name': 'Тестов Удалённый',
'role': 'Специалист удалённой поддержки',
'bucket': 'remote',
},
],
}
self.assertEqual(
resolve_work_bucket('new_remote', {'role': '', 'tasks': []}, company),
'remote',
)
# unmatched: аптека → remote, не field
self.assertEqual(
resolve_work_bucket(
'user-99',
{'role': '', 'tasks': [{'object': 'А35', 'work': 'ПРРО'}], 'tasks_count': 1},
company,
),
'unknown',
)
report = {
'month': 8,
'total_tasks': 40,
'summary': [{'name': 'ПРРО', 'count': 40, 'people': 'X'}],
'employees': [
{
'id': 'aprodov',
'name': 'Апродов',
'role': 'Специалист удалённой поддержки',
'it_work_channel': 'remote',
'tasks_count': 20,
'systems_count': 0,
'tasks': [{'object': 'А35', 'work': 'ПРРО'}, {'object': 'Т2', 'work': 'Терминал'}],
'systems': [],
},
{
'id': 'user-42',
'name': 'Удалённый сотрудник',
'role': '',
'it_work_channel': 'remote',
'tasks_count': 20,
'systems_count': 0,
'tasks': [{'object': 'А10', 'work': 'Helpdesk'}],
'systems': [],
},
],
}
conclusions = build_conclusions(report, company)
bullets = conclusions[-1].get('bullets') or []
field_line = next(b for b in bullets if 'выезд' in b)
remote_line = next(b for b in bullets if 'удален' in b)
# все 40 задач — remote; field должен быть ~0%
self.assertIn('~0%', field_line)
self.assertIn('~100%', remote_line)
def test_split_semicolon_work_items(self):
items = split_work_items('восстановление печати на МФУ; добавление баз; настройка сканера')
self.assertEqual(len(items), 3)