From 5a274f7f90361c504e9ade10bd2ae9677c8f8381 Mon Sep 17 00:00:00 2001 From: lokses Date: Sat, 25 Jul 2026 01:01:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B0=D0=B9=D0=BC:=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE-=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=20IT=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B8=20=D0=B7=20=D1=87?= =?UTF-8?q?=D0=B5=D0=BA-=D0=BB=D0=B8=D1=81=D1=82=D0=BE=D0=BC=20+=20=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D1=96=D1=89=D0=B5=D0=BD=D0=BD=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При створенні HR-заявки з обраними базами/доступами тепер одразу ставиться задача IT-відділу (замість тікета на затвердженні): - TaskChecklistItem: чек-лист задачі, виконавець відмічає зроблене (task-detail: чекбокси, лічильник виконаного, хто/коли відмітив) - create_hiring_request → _create_access_task: задача з чек-листом по базах, creator = HR (постановник), co_executors = IT-група (тригерить сповіщення ботом Ричард), priority=high, due_date рахується за робочим часом - add_working_hours (visit_manager/services): дедлайн за робочим вікном 09:00–18:00 з урахуванням вихідних і свят (Holiday) - Постановнику (HR) бот пише, що заявку взято в роботу, строк — 24 год робочого часу (до розрахованої дати) - approve більше не створює дублюючий IT-тікет (доступи вже задачею) - HiringRequest.access_task; чек-лист в адмінці задач (inline) - Тести: hiring (задача+чек-лист, approve без тікета), робочі години (вихідні/свята), відмітка чек-листа через в'ю Co-Authored-By: Claude Opus 4.8 --- .../0003_hiringrequest_access_task.py | 20 +++++ apps/hiring/models.py | 5 ++ apps/hiring/services.py | 80 +++++++++++++------ apps/hiring/tests.py | 61 ++++++++++---- apps/tasks/admin.py | 12 ++- .../migrations/0004_taskchecklistitem.py | 37 +++++++++ apps/tasks/models.py | 39 +++++++++ apps/tasks/tests.py | 34 ++++++++ apps/tasks/views.py | 5 ++ apps/visit_manager/services.py | 44 +++++++++- apps/visit_manager/tests.py | 45 +++++++++++ templates/pages/task-detail.html | 25 ++++++ 12 files changed, 366 insertions(+), 41 deletions(-) create mode 100644 apps/hiring/migrations/0003_hiringrequest_access_task.py create mode 100644 apps/tasks/migrations/0004_taskchecklistitem.py diff --git a/apps/hiring/migrations/0003_hiringrequest_access_task.py b/apps/hiring/migrations/0003_hiringrequest_access_task.py new file mode 100644 index 0000000..a683f2d --- /dev/null +++ b/apps/hiring/migrations/0003_hiringrequest_access_task.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.3 on 2026-07-24 21:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hiring', '0002_seed_access_systems'), + ('tasks', '0004_taskchecklistitem'), + ] + + operations = [ + migrations.AddField( + model_name='hiringrequest', + name='access_task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='tasks.task', verbose_name='Задача на видачу доступів'), + ), + ] diff --git a/apps/hiring/models.py b/apps/hiring/models.py index 4c2c37d..e053092 100644 --- a/apps/hiring/models.py +++ b/apps/hiring/models.py @@ -93,6 +93,11 @@ class HiringRequest(BaseModel): null=True, blank=True, related_name='hiring_requests', verbose_name=_('Заявка в IT'), ) + access_task = models.ForeignKey( + 'tasks.Task', on_delete=models.SET_NULL, + null=True, blank=True, + related_name='+', verbose_name=_('Задача на видачу доступів'), + ) class Meta: verbose_name = _('Заявка на найм') diff --git a/apps/hiring/services.py b/apps/hiring/services.py index fb7a2ef..c5acab1 100644 --- a/apps/hiring/services.py +++ b/apps/hiring/services.py @@ -8,7 +8,6 @@ from pytils.translit import slugify from apps.employees.models.base import Employee from apps.hiring.models import HiringRequest -from apps.it_manager.models import Ticket def _generate_username(last_name, first_name): @@ -68,47 +67,82 @@ def create_hiring_request(*, requester, last_name, first_name, middle_name='', ) if requested_access_ids: hiring_request.requested_access.set(requested_access_ids) + _create_access_task(hiring_request, requester) return hiring_request +def _create_access_task(hiring_request, requester): + """Ставить задачу IT-відділу на видачу доступів новому співробітнику + (чек-лист по обраних базах), призначає її IT-групі, рахує строк за + робочим часом і повідомляє постановника.""" + from apps.tasks.models import Task, TaskChecklistItem + from apps.visit_manager.services import add_working_hours + + accesses = list(hiring_request.requested_access.all()) + if not accesses: + return None + + fio = f'{hiring_request.last_name} {hiring_request.first_name}'.strip() + due_dt = add_working_hours(timezone.now(), 24) + + parts = ['Надати новому співробітнику доступ до обраних систем/баз (відмітьте виконане у чек-листі).'] + if hiring_request.post_id: + parts.append(f'Посада: {hiring_request.post.name}.') + if hiring_request.access_note: + parts.append(f'Примітка: {hiring_request.access_note}') + + task = Task.objects.create( + title=f'Доступи для {fio}', + description=' '.join(parts), + creator=requester, + priority='high', + due_date=due_dt.date(), + ) + for i, access in enumerate(accesses): + TaskChecklistItem.objects.create(task=task, label=f'Додати в «{access.name}»', order=i) + + # Призначаємо IT-відділ (co_executors) — це тригерить сповіщення виконавцям (сигнали tasks). + it_staff = list(Employee.objects.filter(groups__name='IT', is_active=True)) + if it_staff: + task.co_executors.add(*it_staff) + + _notify_requester_taken(requester, fio, due_dt) + + hiring_request.access_task = task + hiring_request.save(update_fields=['access_task', 'updated_at']) + return task + + +def _notify_requester_taken(requester, fio, due_dt): + """Повідомити постановника (HR), що заявку взято в роботу і строк — 24 год робочого часу.""" + from apps.mattermost_connector.services.bot_service import send_direct_message + local = timezone.localtime(due_dt) + message = ( + f'Заявку на доступи для {fio} прийнято в роботу IT-відділом. ' + f'Виконаємо протягом 24 годин робочого часу — орієнтовно до {local:%d.%m %H:%M}.' + ) + send_direct_message(requester, message) + + @transaction.atomic def approve_hiring_request(hiring_request, decided_by, note=''): if hiring_request.status != 'trial': return hiring_request + # Доступи видаються задачею IT-відділу ще на етапі створення заявки + # (_create_access_task), тому затвердження лише завершує випробувальний термін. employee = hiring_request.employee if employee is not None: employee.probation_end_date = timezone.localdate() employee.save(update_fields=['probation_end_date']) - access_names = list(hiring_request.requested_access.values_list('name', flat=True)) - description_lines = [ - f'Новий співробітник: {hiring_request.last_name} {hiring_request.first_name}', - ] - if hiring_request.post_id: - description_lines.append(f'Посада: {hiring_request.post.name}') - if access_names: - description_lines.append('Потрібні доступи: ' + ', '.join(access_names)) - if hiring_request.access_note: - description_lines.append(f'Примітка: {hiring_request.access_note}') - - from core.models.directories import Directory - ticket = Ticket.objects.create( - title=f'Надати доступи: {hiring_request.last_name} {hiring_request.first_name}', - description='\n'.join(description_lines), - ticket_type_id=Directory.objects.get(directory_type__code='ticket_type', code='access').id, - priority_id=Directory.objects.get(directory_type__code='ticket_priority', code='medium').id, - requester=decided_by, - ) - hiring_request.status = 'approved' hiring_request.decision_by = decided_by hiring_request.decision_at = timezone.now() hiring_request.decision_note = note - hiring_request.granted_ticket = ticket hiring_request.save(update_fields=[ - 'status', 'decision_by', 'decision_at', 'decision_note', 'granted_ticket', 'updated_at', + 'status', 'decision_by', 'decision_at', 'decision_note', 'updated_at', ]) return hiring_request diff --git a/apps/hiring/tests.py b/apps/hiring/tests.py index 7b34def..39e0b88 100644 --- a/apps/hiring/tests.py +++ b/apps/hiring/tests.py @@ -1,13 +1,15 @@ """Тести бізнес-логіки заявок на найм: створення тимчасового акаунта, -затвердження (заявка в IT) і відхилення (деактивація).""" +задача IT на доступи (чек-лист), затвердження і відхилення.""" from datetime import date, timedelta from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.test import TestCase +from core.models import Directory from apps.hiring.models import HiringRequest from apps.hiring.services import approve_hiring_request, create_hiring_request, reject_hiring_request -from apps.it_manager.models import Ticket +from apps.tasks.models import Task Employee = get_user_model() @@ -87,23 +89,56 @@ class HiringServiceTests(TestCase): self.assertNotEqual(hr1.employee.username, hr2.employee.username) - def test_approve_creates_it_ticket_and_ends_probation(self): + # --- задача IT на доступи --- + + def _access_ids(self, n=2): + ids = list(Directory.objects.filter(directory_type__code='access_system', is_active=True) + .values_list('id', flat=True)[:n]) + self.assertTrue(ids, 'у тестовій БД немає довідника access_system') + return ids + + def test_create_with_access_creates_it_task_with_checklist(self): + it_group, _ = Group.objects.get_or_create(name='IT') + it_user = make_employee(username='it.tech', email='it.tech@company.local', last_name='Айтішник') + it_user.groups.add(it_group) + + access_ids = self._access_ids(2) + hr = create_hiring_request( + requester=self.hr_user, last_name='Доступний', first_name='Кандидат', + user_location_type='pharmacy', hire_date=date.today() + timedelta(days=1), trial_days=3, + requested_access_ids=access_ids, + ) + + self.assertIsNotNone(hr.access_task) + task = hr.access_task + self.assertEqual(task.creator, self.hr_user) + self.assertEqual(task.priority, 'high') + self.assertIsNotNone(task.due_date) + self.assertEqual(task.checklist.count(), len(access_ids)) + self.assertIn(it_user, task.co_executors.all()) + # виконавець може відмічати пункти + item = task.checklist.first() + item.set_done(True, user=it_user) + self.assertTrue(task.checklist.get(pk=item.pk).is_done) + self.assertEqual(task.checklist_done, 1) + + def test_create_without_access_creates_no_task(self): + hr = create_hiring_request( + requester=self.hr_user, last_name='Без', first_name='Доступів', + user_location_type='pharmacy', hire_date=date.today() + timedelta(days=1), trial_days=3, + ) + self.assertIsNone(hr.access_task) + + def test_approve_ends_probation_without_ticket(self): hr = create_hiring_request( requester=self.hr_user, last_name='Кандидат', first_name='Марія', user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=5, ) - access = hr._meta.get_field('requested_access').related_model.objects.filter( - directory_type__code='access_system', - )[:2] - hr.requested_access.set(access) - approved = approve_hiring_request(hr, decided_by=self.hr_user, note='ok') self.assertEqual(approved.status, 'approved') self.assertEqual(approved.decision_by, self.hr_user) - self.assertIsNotNone(approved.granted_ticket) - self.assertEqual(approved.granted_ticket.ticket_type.code, 'access') - self.assertEqual(Ticket.objects.count(), 1) + self.assertIsNone(approved.granted_ticket) # доступи — задачею при створенні, не тікетом approved.employee.refresh_from_db() self.assertEqual(approved.employee.probation_end_date, date.today()) @@ -112,7 +147,6 @@ class HiringServiceTests(TestCase): requester=self.hr_user, last_name='Кандидат', first_name='Олег', user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=2, ) - rejected = reject_hiring_request(hr, decided_by=self.hr_user, note='не пройшов') self.assertEqual(rejected.status, 'rejected') @@ -126,10 +160,7 @@ class HiringServiceTests(TestCase): user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=2, ) approve_hiring_request(hr, decided_by=self.hr_user) - ticket_count = Ticket.objects.count() - reject_hiring_request(hr, decided_by=self.hr_user) hr.refresh_from_db() self.assertEqual(hr.status, 'approved') - self.assertEqual(Ticket.objects.count(), ticket_count) diff --git a/apps/tasks/admin.py b/apps/tasks/admin.py index b48658b..a0084b3 100644 --- a/apps/tasks/admin.py +++ b/apps/tasks/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from unfold.admin import ModelAdmin, TabularInline -from apps.tasks.models import Task, TaskComment +from apps.tasks.models import Task, TaskChecklistItem, TaskComment class TaskCommentInline(TabularInline): @@ -11,6 +11,14 @@ class TaskCommentInline(TabularInline): fields = ['author', 'text', 'created_at'] +class TaskChecklistItemInline(TabularInline): + model = TaskChecklistItem + extra = 0 + readonly_fields = ['done_by', 'done_at'] + fields = ['order', 'label', 'is_done', 'done_by', 'done_at'] + ordering = ['order', 'id'] + + @admin.register(Task) class TaskAdmin(ModelAdmin): list_display = ['title', 'status', 'priority', 'due_date', 'creator', 'created_at'] @@ -19,7 +27,7 @@ class TaskAdmin(ModelAdmin): list_select_related = ['creator'] filter_horizontal = ['responsible', 'co_executors', 'observers'] ordering = ['-created_at'] - inlines = [TaskCommentInline] + inlines = [TaskChecklistItemInline, TaskCommentInline] def save_model(self, request, obj, form, change): if not change and not obj.creator: diff --git a/apps/tasks/migrations/0004_taskchecklistitem.py b/apps/tasks/migrations/0004_taskchecklistitem.py new file mode 100644 index 0000000..381d3d9 --- /dev/null +++ b/apps/tasks/migrations/0004_taskchecklistitem.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.3 on 2026-07-24 21:45 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0003_remove_task_departments'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TaskChecklistItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('label', models.CharField(max_length=300, verbose_name='Пункт')), + ('is_done', models.BooleanField(default=False, verbose_name='Виконано')), + ('done_at', models.DateTimeField(blank=True, null=True, verbose_name='Виконано в')), + ('order', models.PositiveSmallIntegerField(default=0, verbose_name='Порядок')), + ('done_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Відмітив')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checklist', to='tasks.task', verbose_name='Задача')), + ], + options={ + 'verbose_name': 'Пункт чек-листа', + 'verbose_name_plural': 'Чек-лист задачі', + 'ordering': ['order', 'id'], + }, + ), + ] diff --git a/apps/tasks/models.py b/apps/tasks/models.py index 3e92a8a..996471b 100644 --- a/apps/tasks/models.py +++ b/apps/tasks/models.py @@ -107,6 +107,14 @@ class Task(BaseModel): self.completed_at = timezone.now() if status == 'done' else None self.save(update_fields=['status', 'completed_at', 'updated_at']) + @property + def checklist_total(self) -> int: + return self.checklist.count() + + @property + def checklist_done(self) -> int: + return self.checklist.filter(is_done=True).count() + class TaskComment(BaseModel): task = models.ForeignKey( @@ -126,3 +134,34 @@ class TaskComment(BaseModel): def __str__(self): return f'{self.author}: {self.text[:50]}' + + +class TaskChecklistItem(BaseModel): + """Пункт чек-листа задачі — виконавець просто відмічає зроблене + (напр. «Додати в 1С Бухгалтерія» при видачі доступів новому співробітнику).""" + task = models.ForeignKey( + Task, on_delete=models.CASCADE, + related_name='checklist', verbose_name=_('Задача'), + ) + label = models.CharField(_('Пункт'), max_length=300) + is_done = models.BooleanField(_('Виконано'), default=False) + done_at = models.DateTimeField(_('Виконано в'), null=True, blank=True) + done_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, related_name='+', verbose_name=_('Відмітив'), + ) + order = models.PositiveSmallIntegerField(_('Порядок'), default=0) + + class Meta: + verbose_name = _('Пункт чек-листа') + verbose_name_plural = _('Чек-лист задачі') + ordering = ['order', 'id'] + + def __str__(self): + return self.label + + def set_done(self, done: bool, user=None): + self.is_done = done + self.done_at = timezone.now() if done else None + self.done_by = user if done else None + self.save(update_fields=['is_done', 'done_at', 'done_by', 'updated_at']) diff --git a/apps/tasks/tests.py b/apps/tasks/tests.py index 85f2247..554e020 100644 --- a/apps/tasks/tests.py +++ b/apps/tasks/tests.py @@ -101,3 +101,37 @@ class TaskStatusTests(TestCase): t = self._task(status='done', completed_at=timezone.now()) t.set_status('in_progress') self.assertIsNone(t.completed_at) + + +class TaskChecklistViewTests(TestCase): + """Відмітка пунктів чек-листа в деталі задачі — тільки учасник.""" + + @classmethod + def setUpTestData(cls): + from apps.tasks.models import TaskChecklistItem + cls.creator = make_employee('cl_creator') + cls.executor = make_employee('cl_exec') + cls.outsider = make_employee('cl_out') + cls.task = Task.objects.create(title='T', creator=cls.creator) + cls.task.co_executors.add(cls.executor) + cls.item = TaskChecklistItem.objects.create(task=cls.task, label='Пункт', order=0) + + def _toggle(self, user): + from django.test import Client + c = Client() + c.force_login(user) + return c.post(f'/tasks/{self.task.pk}/', + {'action': 'checklist_toggle', 'item_id': self.item.pk}, + HTTP_HX_REQUEST='true') + + def test_participant_can_toggle(self): + self._toggle(self.executor) + self.item.refresh_from_db() + self.assertTrue(self.item.is_done) + self.assertEqual(self.item.done_by, self.executor) + + def test_outsider_cannot_toggle(self): + resp = self._toggle(self.outsider) + self.assertEqual(resp.status_code, 404) + self.item.refresh_from_db() + self.assertFalse(self.item.is_done) diff --git a/apps/tasks/views.py b/apps/tasks/views.py index c93deff..e4a9c72 100644 --- a/apps/tasks/views.py +++ b/apps/tasks/views.py @@ -194,10 +194,15 @@ def task_detail_view(request, pk): text = request.POST.get('text', '').strip() if text: TaskComment.objects.create(task=task, author=request.user, text=text) + elif action == 'checklist_toggle' and is_participant: + item = task.checklist.filter(pk=request.POST.get('item_id')).first() + if item: + item.set_done(not item.is_done, user=request.user) context = { 'task': task, 'comments': task.comments.select_related('author'), + 'checklist': task.checklist.select_related('done_by'), 'is_participant': is_participant, 'can_edit': can_edit_task(request.user, task), 'can_delete': can_delete_task(request.user, task), diff --git a/apps/visit_manager/services.py b/apps/visit_manager/services.py index 343b4c0..7215386 100644 --- a/apps/visit_manager/services.py +++ b/apps/visit_manager/services.py @@ -1,12 +1,54 @@ """ Сервисный слой: приём фото, генерация превью, сохранение AttendanceLog, обновление WorkShift. """ +import datetime import io from django.core.files.base import ContentFile from django.utils import timezone from PIL import Image -from .models import AttendanceLog, WorkShift +from .models import AttendanceLog, Holiday, WorkShift + +# Рабочее окно для расчёта дедлайнов по «рабочему времени» (SLA задач). +WORK_DAY_START = datetime.time(9, 0) +WORK_DAY_END = datetime.time(18, 0) # 9 рабочих часов в день + + +def _is_working_day(d: datetime.date) -> bool: + """Будни, не выходной и не нерабочий праздник (Holiday).""" + if d.weekday() >= 5: # сб/вс + return False + holiday = Holiday.objects.filter(date=d).first() + if holiday and not holiday.is_working_day: + return False + return True + + +def add_working_hours(start: datetime.datetime, hours: float) -> datetime.datetime: + """Дата-время через `hours` РАБОЧИХ часов от start, с учётом рабочего окна + (09:00–18:00), выходных и праздников. Возвращает aware-datetime в локальной TZ.""" + current = timezone.localtime(start) if timezone.is_aware(start) else start + remaining = datetime.timedelta(hours=hours) + tz = current.tzinfo + + for _ in range(100000): # страховка от зацикливания на кривых данных + d = current.date() + if not _is_working_day(d): + current = datetime.datetime.combine(d + datetime.timedelta(days=1), WORK_DAY_START, tzinfo=tz) + continue + day_start = datetime.datetime.combine(d, WORK_DAY_START, tzinfo=tz) + day_end = datetime.datetime.combine(d, WORK_DAY_END, tzinfo=tz) + if current < day_start: + current = day_start + if current >= day_end: + current = datetime.datetime.combine(d + datetime.timedelta(days=1), WORK_DAY_START, tzinfo=tz) + continue + available = day_end - current + if remaining <= available: + return current + remaining + remaining -= available + current = datetime.datetime.combine(d + datetime.timedelta(days=1), WORK_DAY_START, tzinfo=tz) + return current THUMB_SIZE = (64, 64) diff --git a/apps/visit_manager/tests.py b/apps/visit_manager/tests.py index 02ff47e..83fc04f 100644 --- a/apps/visit_manager/tests.py +++ b/apps/visit_manager/tests.py @@ -121,3 +121,48 @@ class WorkShiftTests(TestCase): 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) diff --git a/templates/pages/task-detail.html b/templates/pages/task-detail.html index 7016ffb..6cc3138 100644 --- a/templates/pages/task-detail.html +++ b/templates/pages/task-detail.html @@ -66,6 +66,31 @@ {% endif %} + + {% if checklist %} +
+
+

Чек-лист

+ {{ task.checklist_done }}/{{ task.checklist_total }} +
+
+ {% for item in checklist %} + + {% endfor %} +
+
+ {% endif %} +