Files
ACORE/apps/visit_manager/calendar_service.py
T
lokses df137e1074 dashboard: виджеты «Ближайшие события», «Мои HR-заявки», «Мои IT-заявки»
Три новых виджета к кастомизируемому дашборду:
- upcoming — праздники, отпуска, ДР, смены и встречи на 2 недели вперёд;
  переиспользует сборщики календаря (с их логикой видимости) через новый
  calendar_service.upcoming_events()
- my_hr_requests — статусы моих кадровых заявок (отпуск/больничный/отгул)
- my_tickets — мои открытые обращения в IT-поддержку

Все три — в реестре DASHBOARD_WIDGETS, доступны в настройке дашборда и
наборах по умолчанию. Тесты: реестр, контекст HR-заявок, рендер виджетов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 18:53:06 +03:00

417 lines
14 KiB
Python
Raw 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 __future__ import annotations
import calendar as pycal
import datetime
from typing import Any
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext as _
from core.portal_access import user_can_manage_hr_portal
UKR_MONTHS = [
'', 'Січень', 'Лютий', 'Березень', 'Квітень', 'Травень', 'Червень',
'Липень', 'Серпень', 'Вересень', 'Жовтень', 'Листопад', 'Грудень',
]
LEAVE_TYPES = ('vacation', 'additional_vacation', 'sick_leave', 'day_off')
EVENT_CSS = {
'holiday': 'cal-chip-holiday',
'leave': 'cal-chip-leave',
'leave_pending': 'cal-chip-leave-pending',
'birthday': 'cal-chip-birthday',
'shift': 'cal-chip-shift',
'event': 'cal-chip-event',
'interview': 'cal-chip-interview',
'meeting': 'cal-chip-meeting',
}
def _parse_month(month_str: str | None) -> tuple[int, int]:
today = timezone.localdate()
if not month_str:
return today.year, today.month
try:
year, month = (int(x) for x in month_str.split('-'))
if not (1 <= month <= 12):
raise ValueError
return year, month
except (ValueError, TypeError):
return today.year, today.month
def _month_bounds(year: int, month: int) -> tuple[datetime.date, datetime.date]:
last = pycal.monthrange(year, month)[1]
return datetime.date(year, month, 1), datetime.date(year, month, last)
def _shift_month(year: int, month: int, delta: int) -> tuple[int, int]:
month += delta
while month < 1:
month += 12
year -= 1
while month > 12:
month -= 12
year += 1
return year, month
def _can_see_team_wide(user) -> bool:
return bool(getattr(user, 'is_staff', False) or user_can_manage_hr_portal(user))
def _colleague_filter(user) -> Q:
"""Фильтр сотрудников той же аптеки/отдела (для не-HR)."""
q = Q()
pharmacy_id = getattr(user, 'user_pharmacy_id', None)
department_id = getattr(user, 'department_id', None)
if pharmacy_id:
q |= Q(user_pharmacy_id=pharmacy_id)
if department_id:
q |= Q(department_id=department_id)
if not q:
q = Q(pk=user.pk)
return q
def _iter_days(date_from: datetime.date, date_to: datetime.date):
current = date_from
while current <= date_to:
yield current
current += datetime.timedelta(days=1)
def _make_event(
day: datetime.date,
*,
kind: str,
label: str,
subtype: str = '',
css: str | None = None,
meta: dict | None = None,
) -> dict[str, Any]:
return {
'date': day,
'kind': kind,
'label': label,
'subtype': subtype or kind,
'css': css or EVENT_CSS.get(kind, 'cal-chip-event'),
'meta': meta or {},
}
def _ensure_holidays(year: int):
from apps.visit_manager.models import Holiday
if not Holiday.objects.filter(date__year=year).exists():
Holiday.objects.create_ukrainian_holidays(year)
def _collect_holidays(start: datetime.date, end: datetime.date) -> list[dict]:
from apps.visit_manager.models import Holiday
_ensure_holidays(start.year)
if end.year != start.year:
_ensure_holidays(end.year)
events = []
for h in Holiday.objects.filter(date__range=(start, end)):
events.append(_make_event(
h.date,
kind='holiday',
label=h.name,
subtype=h.holiday_type,
meta={'id': h.pk, 'is_working_day': h.is_working_day},
))
return events
def _collect_leaves(user, start: datetime.date, end: datetime.date, scope: str) -> list[dict]:
from apps.employees.models import EmployeeHrRequest
qs = (
EmployeeHrRequest.objects
.filter(request_type__in=LEAVE_TYPES)
.filter(date_from__lte=end, date_to__gte=start)
.select_related('employee')
)
if scope == 'mine':
qs = qs.filter(employee=user).filter(status__in=('approved', 'pending'))
else:
qs = qs.filter(status='approved')
if not _can_see_team_wide(user):
colleague_ids = (
type(user).objects.filter(is_active=True)
.filter(_colleague_filter(user))
.values_list('pk', flat=True)
)
qs = qs.filter(employee_id__in=colleague_ids)
events = []
for req in qs:
day_start = max(req.date_from, start)
day_end = min(req.date_to, end)
initials = f'{req.employee.last_name[:1]}{req.employee.first_name[:1]}'.upper()
if scope == 'mine' and req.employee_id == user.pk:
label = req.get_request_type_display()
else:
label = f'{req.get_request_type_display()}: {initials}'
kind_css = 'leave_pending' if req.status == 'pending' else 'leave'
for day in _iter_days(day_start, day_end):
events.append(_make_event(
day,
kind='leave',
label=label,
subtype=req.request_type,
css=EVENT_CSS[kind_css],
meta={
'id': req.pk,
'status': req.status,
'employee_id': req.employee_id,
},
))
return events
def _collect_birthdays(user, start: datetime.date, end: datetime.date, scope: str) -> list[dict]:
from apps.employees.models import Employee
qs = Employee.objects.filter(is_active=True, birth_date__isnull=False)
if scope == 'mine':
qs = qs.filter(pk=user.pk)
elif not _can_see_team_wide(user):
qs = qs.filter(_colleague_filter(user))
events = []
months = {start.month}
if end.month != start.month:
months.add(end.month)
# Month view is always one calendar month
target_month = start.month
for emp in qs.filter(birth_date__month=target_month):
try:
day = datetime.date(start.year, emp.birth_date.month, emp.birth_date.day)
except ValueError:
# 29 Feb in non-leap year
day = datetime.date(start.year, 2, 28)
if not (start <= day <= end):
continue
if emp.pk == user.pk:
label = _('День рождения')
else:
label = _('ДР: %(name)s') % {
'name': f'{emp.last_name} {emp.first_name[:1]}.',
}
events.append(_make_event(
day,
kind='birthday',
label=label,
meta={'employee_id': emp.pk},
))
return events
def _collect_custom_events(user, start: datetime.date, end: datetime.date, scope: str) -> list[dict]:
from apps.visit_manager.models import CalendarEvent
qs = (
CalendarEvent.objects
.filter(date_from__lte=end, date_to__gte=start)
.select_related('created_by')
)
visible = Q(visibility='company') | Q(created_by=user)
if scope == 'team' and _can_see_team_wide(user):
visible |= Q(visibility='personal')
qs = qs.filter(visible)
events = []
for ev in qs:
day_start = max(ev.date_from, start)
day_end = min(ev.date_to, end)
time_label = ''
if ev.time_from:
time_label = f' {ev.time_from.strftime("%H:%M")}'
label = f'{ev.title}{time_label}'
css = EVENT_CSS.get(ev.event_type, EVENT_CSS['event'])
for day in _iter_days(day_start, day_end):
events.append(_make_event(
day,
kind='event',
label=label,
subtype=ev.event_type,
css=css,
meta={
'id': ev.pk,
'visibility': ev.visibility,
'created_by_id': ev.created_by_id,
'can_delete': (
ev.created_by_id == user.pk
or getattr(user, 'is_staff', False)
or user_can_manage_hr_portal(user)
),
},
))
return events
def _collect_shifts(user, start: datetime.date, end: datetime.date, scope: str) -> list[dict]:
from apps.visit_manager.models import WorkSchedule
employees = [user]
if scope == 'team' and _can_see_team_wide(user):
from apps.employees.models import Employee
location_ids = set()
pharmacy_id = getattr(user, 'user_pharmacy_id', None)
location_id = getattr(user, 'user_location_id', None)
if pharmacy_id:
location_ids.add(pharmacy_id)
if location_id:
location_ids.add(location_id)
if location_ids:
employees = list(
Employee.objects.filter(is_active=True).filter(
Q(pk=user.pk)
| Q(user_pharmacy_id__in=location_ids)
| Q(user_location_id__in=location_ids)
)[:40]
)
schedules = (
WorkSchedule.objects
.filter(employee__in=employees, is_active=True)
.filter(start_date__lte=end)
.filter(Q(end_date__isnull=True) | Q(end_date__gte=start))
.select_related('employee', 'schedule_template')
.prefetch_related('schedule_template__schedule_patterns__shift')
)
events = []
for schedule in schedules:
emp = schedule.employee
for day in _iter_days(start, end):
if not schedule.is_date_in_schedule(day):
continue
shift = schedule.get_shift_for_date(day)
if not shift or shift.is_day_off:
continue
time_part = ''
if shift.start_time and shift.end_time:
time_part = f' {shift.start_time.strftime("%H:%M")}–{shift.end_time.strftime("%H:%M")}'
if emp.pk == user.pk:
label = f'{shift.name}{time_part}'
else:
initials = f'{emp.last_name[:1]}{emp.first_name[:1]}'.upper()
label = f'{initials}: {shift.name}{time_part}'
events.append(_make_event(
day,
kind='shift',
label=label,
subtype=getattr(shift, 'shift_type', '') or 'shift',
meta={'employee_id': emp.pk, 'schedule_id': schedule.pk},
))
return events
def build_calendar_context(user, *, month_str: str | None = None, scope: str = 'mine') -> dict:
"""Контекст месячной сетки календаря для шаблона."""
if scope not in ('mine', 'team'):
scope = 'mine'
year, month = _parse_month(month_str)
start, end = _month_bounds(year, month)
today = timezone.localdate()
prev_y, prev_m = _shift_month(year, month, -1)
next_y, next_m = _shift_month(year, month, 1)
all_events: list[dict] = []
all_events.extend(_collect_holidays(start, end))
all_events.extend(_collect_leaves(user, start, end, scope))
all_events.extend(_collect_birthdays(user, start, end, scope))
all_events.extend(_collect_custom_events(user, start, end, scope))
all_events.extend(_collect_shifts(user, start, end, scope))
by_day: dict[datetime.date, list[dict]] = {}
holiday_dates = set()
for ev in all_events:
by_day.setdefault(ev['date'], []).append(ev)
if ev['kind'] == 'holiday' and not ev['meta'].get('is_working_day'):
holiday_dates.add(ev['date'])
# Сортировка чипов внутри дня: праздник → отпуск → ДР → смена → событие
kind_order = {'holiday': 0, 'leave': 1, 'birthday': 2, 'shift': 3, 'event': 4}
for day_events in by_day.values():
day_events.sort(key=lambda e: (kind_order.get(e['kind'], 9), e['label']))
# Сетка: понедельник = 0
first_weekday = start.weekday() # Mon=0
cells: list[dict | None] = [None] * first_weekday
for day_num in range(1, end.day + 1):
day = datetime.date(year, month, day_num)
cells.append({
'date': day,
'day': day_num,
'is_today': day == today,
'is_weekend': day.weekday() >= 5,
'is_holiday': day in holiday_dates,
'events': by_day.get(day, []),
})
while len(cells) % 7:
cells.append(None)
weeks = [cells[i:i + 7] for i in range(0, len(cells), 7)]
return {
'cal_year': year,
'cal_month': month,
'cal_month_label': f'{UKR_MONTHS[month]} {year}',
'cal_month_str': f'{year:04d}-{month:02d}',
'cal_prev_month': f'{prev_y:04d}-{prev_m:02d}',
'cal_next_month': f'{next_y:04d}-{next_m:02d}',
'cal_scope': scope,
'cal_weeks': weeks,
'cal_can_team': True,
'cal_default_date': (
today.isoformat()
if (today.year == year and today.month == month)
else start.isoformat()
),
'cal_event_types': [
('interview', _('Собеседование')),
('meeting', _('Встреча')),
('other', _('Событие')),
],
'cal_form_open': False,
'cal_form_errors': None,
'cal_form_message': None,
'cal_form_data': {},
}
def upcoming_events(user, *, days: int = 14, scope: str = 'mine') -> list[dict]:
"""Плоский список ближайших событий календаря на N дней вперёд от сегодня.
Переиспользует сборщики build_calendar_context (с их логикой видимости:
отпуска/ДР/смены/события — по scope и правам). Для виджета дашборда.
"""
if scope not in ('mine', 'team'):
scope = 'mine'
today = timezone.localdate()
end = today + datetime.timedelta(days=days)
events: list[dict] = []
events.extend(_collect_holidays(today, end))
events.extend(_collect_leaves(user, today, end, scope))
events.extend(_collect_birthdays(user, today, end, scope))
events.extend(_collect_custom_events(user, today, end, scope))
events.extend(_collect_shifts(user, today, end, scope))
kind_order = {'holiday': 0, 'leave': 1, 'birthday': 2, 'shift': 3, 'event': 4}
events = [e for e in events if e['date'] >= today]
events.sort(key=lambda e: (e['date'], kind_order.get(e['kind'], 9), e['label']))
return events