3e969e29dc
Модель и сервис событий календаря, вью и страница календаря. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
308 lines
14 KiB
Python
308 lines
14 KiB
Python
import datetime
|
||
from datetime import date, timedelta
|
||
|
||
from django.core.exceptions import ValidationError
|
||
from django.test import TestCase
|
||
|
||
from apps.visit_manager.models import Holiday, WorkShift
|
||
|
||
|
||
class EasterCalculationTests(TestCase):
|
||
"""Православная Пасха (юлианская + 13 дней) — сверка с известными датами."""
|
||
|
||
def test_known_orthodox_easter_dates(self):
|
||
calc = Holiday.objects._calculate_easter
|
||
self.assertEqual(calc(2023), date(2023, 4, 16))
|
||
self.assertEqual(calc(2024), date(2024, 5, 5))
|
||
self.assertEqual(calc(2025), date(2025, 4, 20))
|
||
self.assertEqual(calc(2026), date(2026, 4, 12))
|
||
|
||
|
||
class HolidayLogicTests(TestCase):
|
||
"""Чистая логика Holiday (без БД): компенсация, валидация, даты."""
|
||
|
||
def test_compensation_on_non_working_holiday(self):
|
||
h = Holiday(date=date(2025, 1, 1), is_working_day=False, overtime_rate=2.0)
|
||
res = h.calculate_work_compensation(hours_worked=8, base_hourly_rate=100)
|
||
self.assertEqual(res['regular_pay'], 0)
|
||
self.assertEqual(res['holiday_bonus'], 1600) # 8 * 100 * 2.0
|
||
self.assertEqual(res['total_pay'], 1600)
|
||
self.assertTrue(res['is_holiday_work'])
|
||
|
||
def test_compensation_on_working_holiday(self):
|
||
h = Holiday(date=date(2025, 1, 1), is_working_day=True, overtime_rate=2.0)
|
||
res = h.calculate_work_compensation(hours_worked=8, base_hourly_rate=100)
|
||
self.assertEqual(res['regular_pay'], 800)
|
||
self.assertEqual(res['holiday_bonus'], 0)
|
||
self.assertFalse(res['is_holiday_work'])
|
||
|
||
def test_clean_rejects_non_positive_overtime_rate(self):
|
||
with self.assertRaises(ValidationError):
|
||
Holiday(name='X', date=date(2025, 1, 1), overtime_rate=0).clean()
|
||
|
||
def test_clean_accepts_positive_overtime_rate(self):
|
||
Holiday(name='X', date=date(2025, 1, 1), overtime_rate=1.5).clean() # не должно бросать
|
||
|
||
def test_days_until_future_and_past(self):
|
||
future = Holiday(name='F', date=date.today() + timedelta(days=5))
|
||
self.assertEqual(future.days_until, 5)
|
||
past = Holiday(name='P', date=date.today() - timedelta(days=1))
|
||
self.assertIsNone(past.days_until)
|
||
|
||
def test_is_today(self):
|
||
self.assertTrue(Holiday(name='T', date=date.today()).is_today)
|
||
self.assertFalse(Holiday(name='N', date=date.today() + timedelta(days=1)).is_today)
|
||
|
||
def test_year_property(self):
|
||
self.assertEqual(Holiday(name='Y', date=date(2024, 8, 24)).year, 2024)
|
||
|
||
def test_next_occurrence_recurring(self):
|
||
h = Holiday(name='Indep', date=date(2020, 8, 24), is_recurring=True)
|
||
self.assertEqual(h.get_next_occurrence(date(2025, 1, 1)), date(2025, 8, 24))
|
||
# уже прошло в этом году → следующий год
|
||
self.assertEqual(h.get_next_occurrence(date(2025, 9, 1)), date(2026, 8, 24))
|
||
|
||
def test_next_occurrence_non_recurring(self):
|
||
h = Holiday(name='One', date=date(2020, 8, 24), is_recurring=False)
|
||
self.assertIsNone(h.get_next_occurrence(date(2025, 1, 1)))
|
||
|
||
|
||
class HolidayManagerTests(TestCase):
|
||
"""Менеджер/queryset: генерация украинских праздников, поиск, периоды."""
|
||
|
||
def test_create_ukrainian_holidays_count_and_idempotent(self):
|
||
created = Holiday.objects.create_ukrainian_holidays(2025)
|
||
self.assertEqual(len(created), 12) # 9 фиксированных + 3 пасхальных
|
||
# повторный вызов ничего не дублирует (get_or_create по дате)
|
||
self.assertEqual(len(Holiday.objects.create_ukrainian_holidays(2025)), 0)
|
||
|
||
def test_fixed_and_easter_holidays_present(self):
|
||
Holiday.objects.create_ukrainian_holidays(2025)
|
||
self.assertTrue(Holiday.objects.is_holiday(date(2025, 1, 1))) # Новий рік
|
||
self.assertTrue(Holiday.objects.is_holiday(date(2025, 8, 24))) # Незалежність
|
||
self.assertTrue(Holiday.objects.is_holiday(date(2025, 4, 20))) # Пасха 2025
|
||
|
||
def test_is_holiday_false_for_ordinary_day(self):
|
||
Holiday.objects.create_ukrainian_holidays(2025)
|
||
self.assertFalse(Holiday.objects.is_holiday(date(2025, 3, 15)))
|
||
|
||
def test_is_working_holiday(self):
|
||
Holiday.objects.create(name='Робоче свято', date=date(2025, 6, 2), is_working_day=True)
|
||
Holiday.objects.create(name='Вихідне свято', date=date(2025, 6, 3), is_working_day=False)
|
||
self.assertTrue(bool(Holiday.objects.is_working_holiday(date(2025, 6, 2))))
|
||
self.assertFalse(bool(Holiday.objects.is_working_holiday(date(2025, 6, 3))))
|
||
|
||
def test_for_year_and_for_period(self):
|
||
Holiday.objects.create(name='A', date=date(2024, 12, 31))
|
||
Holiday.objects.create(name='B', date=date(2025, 1, 1))
|
||
Holiday.objects.create(name='C', date=date(2025, 6, 1))
|
||
self.assertEqual(Holiday.objects.for_year(2025).count(), 2)
|
||
period = Holiday.objects.for_period(date(2024, 12, 1), date(2025, 1, 15))
|
||
self.assertEqual(period.count(), 2) # 2024-12-31 и 2025-01-01
|
||
|
||
def test_get_next_holiday(self):
|
||
Holiday.objects.create(name='Past', date=date(2025, 1, 1))
|
||
Holiday.objects.create(name='Next', date=date(2025, 5, 1))
|
||
nxt = Holiday.objects.get_next_holiday(from_date=date(2025, 3, 1))
|
||
self.assertEqual(nxt.name, 'Next')
|
||
|
||
|
||
class WorkShiftTests(TestCase):
|
||
"""WorkShift.work_hours / is_complete — без обращения к БД."""
|
||
|
||
def test_work_hours_from_duration(self):
|
||
ws = WorkShift(duration=timedelta(hours=8, minutes=30))
|
||
self.assertEqual(ws.work_hours, 8.5)
|
||
|
||
def test_work_hours_none_without_duration(self):
|
||
self.assertIsNone(WorkShift().work_hours)
|
||
|
||
def test_is_complete_requires_both_logs(self):
|
||
self.assertFalse(WorkShift().is_complete)
|
||
self.assertFalse(WorkShift(log_in_id=1).is_complete)
|
||
self.assertTrue(WorkShift(log_in_id=1, log_out_id=2).is_complete)
|
||
|
||
|
||
class WorkingHoursTests(TestCase):
|
||
"""add_working_hours: рабочее окно 09:00–18:00, выходные и праздники."""
|
||
|
||
def _monday_9am(self):
|
||
from django.utils import timezone
|
||
d = date(2026, 7, 6)
|
||
while d.weekday() != 0:
|
||
d += timedelta(days=1)
|
||
return timezone.make_aware(datetime.datetime.combine(d, datetime.time(9, 0)))
|
||
|
||
def test_within_same_working_day(self):
|
||
from apps.visit_manager.services import add_working_hours
|
||
start = self._monday_9am()
|
||
due = add_working_hours(start, 5)
|
||
self.assertEqual(due.date(), start.date())
|
||
self.assertEqual(due.hour, 14)
|
||
|
||
def test_rolls_to_next_working_day(self):
|
||
from apps.visit_manager.services import add_working_hours
|
||
start = self._monday_9am()
|
||
due = add_working_hours(start, 11) # 9г пн -> 18:00, +2г -> вт 11:00
|
||
self.assertEqual(due.date(), start.date() + timedelta(days=1))
|
||
self.assertEqual(due.hour, 11)
|
||
|
||
def test_skips_weekend(self):
|
||
from django.utils import timezone
|
||
from apps.visit_manager.services import add_working_hours
|
||
friday = self._monday_9am().date() + timedelta(days=4)
|
||
start = timezone.make_aware(datetime.datetime.combine(friday, datetime.time(17, 0)))
|
||
due = add_working_hours(start, 2) # пт 1г -> сб/нд пропуск -> пн 10:00
|
||
self.assertEqual(due.weekday(), 0)
|
||
self.assertEqual(due.hour, 10)
|
||
|
||
def test_skips_holiday(self):
|
||
from django.utils import timezone
|
||
from apps.visit_manager.services import add_working_hours
|
||
start = self._monday_9am()
|
||
# робимо вівторок неробочим святом → 11 робочих годин перекотяться на середу
|
||
Holiday.objects.create(name='Тест-свято', date=start.date() + timedelta(days=1),
|
||
is_working_day=False)
|
||
due = add_working_hours(start, 11)
|
||
self.assertEqual(due.date(), start.date() + timedelta(days=2))
|
||
self.assertEqual(due.hour, 11)
|
||
|
||
|
||
class AttendanceAPIAuthzTests(TestCase):
|
||
"""Object-level authz для /api/attendance/."""
|
||
|
||
@classmethod
|
||
def setUpTestData(cls):
|
||
from django.contrib.auth import get_user_model
|
||
from core.models import Directory, DirectoryType
|
||
from apps.locations.models import Pharmacy
|
||
import io
|
||
from PIL import Image
|
||
|
||
Employee = get_user_model()
|
||
org_type = DirectoryType.objects.create(code='organization', name='Организация')
|
||
region_type = DirectoryType.objects.create(code='region', name='Регион')
|
||
region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса')
|
||
org = Directory.objects.create(directory_type=org_type, code='akc', name='АКС')
|
||
cls.location = Pharmacy.objects.create(
|
||
organization=org, location_region=region,
|
||
pharmacy_number=101, location_type='pharmacy',
|
||
)
|
||
cls.staff = Employee.objects.create_user(
|
||
username='att_staff', hire_date=date(2020, 1, 1),
|
||
first_name='S', last_name='Staff', is_staff=True,
|
||
mattermost_sync_enabled=False, ad_sync_enabled=False,
|
||
)
|
||
cls.other_location = Pharmacy.objects.create(
|
||
organization=org, location_region=region,
|
||
pharmacy_number=102, location_type='pharmacy',
|
||
)
|
||
loc_kw = dict(
|
||
user_location_type='pharmacy', user_pharmacy=cls.location,
|
||
)
|
||
cls.alice = Employee.objects.create_user(
|
||
username='att_alice', hire_date=date(2020, 1, 1),
|
||
first_name='A', last_name='Alice',
|
||
mattermost_sync_enabled=False, ad_sync_enabled=False,
|
||
**loc_kw,
|
||
)
|
||
cls.bob = Employee.objects.create_user(
|
||
username='att_bob', hire_date=date(2020, 1, 1),
|
||
first_name='B', last_name='Bob', manager=cls.alice,
|
||
mattermost_sync_enabled=False, ad_sync_enabled=False,
|
||
**loc_kw,
|
||
)
|
||
cls.carol = Employee.objects.create_user(
|
||
username='att_carol', hire_date=date(2020, 1, 1),
|
||
first_name='C', last_name='Carol',
|
||
mattermost_sync_enabled=False, ad_sync_enabled=False,
|
||
**loc_kw,
|
||
)
|
||
buf = io.BytesIO()
|
||
Image.new('RGB', (8, 8), color='red').save(buf, format='JPEG')
|
||
cls.photo_b64 = __import__('base64').b64encode(buf.getvalue()).decode()
|
||
|
||
def _register(self, user, employee_id, location_id=None):
|
||
self.client.force_login(user)
|
||
if user.is_staff: # staff проходит обязательный 2FA-гейт
|
||
user.totp_enabled = True
|
||
user.save(update_fields=['totp_enabled'])
|
||
session = self.client.session
|
||
session['2fa_verified'] = True
|
||
session.save()
|
||
return self.client.post(
|
||
'/api/attendance/register/',
|
||
{
|
||
'employee_id': employee_id,
|
||
'location_id': location_id or self.location.pk,
|
||
'direction': 'in',
|
||
'photo_b64': self.photo_b64,
|
||
},
|
||
content_type='application/json',
|
||
)
|
||
|
||
def test_user_can_register_self(self):
|
||
resp = self._register(self.alice, self.alice.pk)
|
||
self.assertEqual(resp.status_code, 201)
|
||
|
||
def test_user_cannot_register_other(self):
|
||
resp = self._register(self.alice, self.carol.pk)
|
||
self.assertEqual(resp.status_code, 403)
|
||
|
||
def test_staff_can_register_other(self):
|
||
resp = self._register(self.staff, self.carol.pk)
|
||
self.assertEqual(resp.status_code, 201)
|
||
|
||
def test_user_cannot_register_at_other_location(self):
|
||
resp = self._register(self.alice, self.alice.pk, location_id=self.other_location.pk)
|
||
self.assertEqual(resp.status_code, 403)
|
||
|
||
def test_staff_can_register_at_any_location(self):
|
||
resp = self._register(self.staff, self.carol.pk, location_id=self.other_location.pk)
|
||
self.assertEqual(resp.status_code, 201)
|
||
|
||
def test_list_scoped_to_self_and_reports(self):
|
||
from apps.visit_manager.services import register_attendance
|
||
import base64
|
||
photo = base64.b64decode(self.photo_b64)
|
||
register_attendance(
|
||
employee_id=self.alice.pk, location_id=self.location.pk,
|
||
direction='in', photo_bytes=photo,
|
||
)
|
||
register_attendance(
|
||
employee_id=self.bob.pk, location_id=self.location.pk,
|
||
direction='in', photo_bytes=photo,
|
||
)
|
||
register_attendance(
|
||
employee_id=self.carol.pk, location_id=self.location.pk,
|
||
direction='in', photo_bytes=photo,
|
||
)
|
||
self.client.force_login(self.alice)
|
||
resp = self.client.get('/api/attendance/')
|
||
self.assertEqual(resp.status_code, 200)
|
||
ids = {row['employee_id'] for row in resp.json()}
|
||
self.assertIn(self.alice.pk, ids)
|
||
self.assertIn(self.bob.pk, ids)
|
||
self.assertNotIn(self.carol.pk, ids)
|
||
|
||
def test_list_other_employee_forbidden(self):
|
||
self.client.force_login(self.alice)
|
||
resp = self.client.get(f'/api/attendance/?employee_id={self.carol.pk}')
|
||
self.assertEqual(resp.status_code, 403)
|
||
|
||
def test_photo_streams_not_redirect(self):
|
||
from apps.visit_manager.services import register_attendance
|
||
import base64
|
||
|
||
photo = base64.b64decode(self.photo_b64)
|
||
log = register_attendance(
|
||
employee_id=self.alice.pk, location_id=self.location.pk,
|
||
direction='in', photo_bytes=photo,
|
||
)
|
||
self.client.force_login(self.alice)
|
||
resp = self.client.get(f'/api/attendance/{log.pk}/photo/')
|
||
self.assertEqual(resp.status_code, 200)
|
||
self.assertFalse(300 <= resp.status_code < 400)
|
||
self.assertIn(resp['Content-Type'], ('image/jpeg', 'image/png', 'image/webp'))
|
||
self.assertTrue(b''.join(resp.streaming_content) or resp.content)
|