fix permisions
This commit is contained in:
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user