df137e1074
Три новых виджета к кастомизируемому дашборду: - 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>
417 lines
14 KiB
Python
417 lines
14 KiB
Python
"""Сбор событий месячного календаря портала."""
|
||
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
|