"""Проекты с шагами по отделам; пункты шагов связываются с tasks.Task.""" from __future__ import annotations from django.conf import settings from django.db import models from django.db.models import Count, Q from django.utils.translation import gettext_lazy as _ from core.models.base import BaseModel class ProjectTemplate(BaseModel): name = models.CharField(_('Название'), max_length=200) description = models.TextField(_('Описание'), blank=True) default_duration_days = models.PositiveIntegerField( _('Длительность по умолчанию (дни)'), default=90, ) is_active = models.BooleanField(_('Активен'), default=True) class Meta: verbose_name = _('Шаблон проекта') verbose_name_plural = _('Шаблоны проектов') ordering = ['name'] def __str__(self): return self.name class ProjectTemplateStep(BaseModel): template = models.ForeignKey( ProjectTemplate, on_delete=models.CASCADE, related_name='steps', verbose_name=_('Шаблон'), ) title = models.CharField(_('Название шага'), max_length=250) order = models.PositiveSmallIntegerField(_('Порядок'), default=0) department = models.ForeignKey( 'core.Directory', on_delete=models.SET_NULL, null=True, blank=True, limit_choices_to={'directory_type__code': 'department'}, related_name='project_template_steps', verbose_name=_('Отдел'), ) offset_days = models.PositiveIntegerField( _('Смещение от старта (дни)'), default=0, help_text=_('Плановый старт шага = старт проекта + offset.'), ) duration_days = models.PositiveIntegerField( _('Длительность шага (дни)'), default=14, ) class Meta: verbose_name = _('Шаг шаблона') verbose_name_plural = _('Шаги шаблона') ordering = ['order', 'id'] def __str__(self): return f'{self.order}. {self.title}' class ProjectTemplateStepItem(BaseModel): step = models.ForeignKey( ProjectTemplateStep, on_delete=models.CASCADE, related_name='items', verbose_name=_('Шаг'), ) title = models.CharField(_('Название задачи'), max_length=250) description = models.TextField(_('Описание'), blank=True) priority = models.CharField( _('Приоритет'), max_length=10, default='medium', choices=[ ('low', _('Низкий')), ('medium', _('Средний')), ('high', _('Высокий')), ], ) order = models.PositiveSmallIntegerField(_('Порядок'), default=0) class Meta: verbose_name = _('Пункт шага шаблона') verbose_name_plural = _('Пункты шага шаблона') ordering = ['order', 'id'] def __str__(self): return self.title class ProjectQuerySet(models.QuerySet): def for_user(self, user): """Владелец, участник связанных Task, сотрудник отдела шага, или staff.""" if not user or not getattr(user, 'is_authenticated', False): return self.none() if getattr(user, 'is_staff', False) or getattr(user, 'is_superuser', False): return self.all() dept_id = getattr(user, 'department_id', None) q = Q(owner=user) | Q(created_by=user) q |= Q(steps__items__task__creator=user) q |= Q(steps__items__task__responsible=user) q |= Q(steps__items__task__co_executors=user) q |= Q(steps__items__task__observers=user) if dept_id: q |= Q(steps__department_id=dept_id) return self.filter(q).distinct() class Project(BaseModel): STATUS_DRAFT = 'draft' STATUS_ACTIVE = 'active' STATUS_DONE = 'done' STATUS_CANCELLED = 'cancelled' STATUS_CHOICES = [ (STATUS_DRAFT, _('Черновик')), (STATUS_ACTIVE, _('Активен')), (STATUS_DONE, _('Завершён')), (STATUS_CANCELLED, _('Отменён')), ] title = models.CharField(_('Название'), max_length=250) description = models.TextField(_('Описание'), blank=True) status = models.CharField( _('Статус'), max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT, db_index=True, ) start_date = models.DateField(_('Дата старта'), null=True, blank=True) due_date = models.DateField(_('Срок'), null=True, blank=True) pharmacy = models.ForeignKey( 'locations.Pharmacy', on_delete=models.SET_NULL, null=True, blank=True, related_name='projects', verbose_name=_('Аптека'), ) template = models.ForeignKey( ProjectTemplate, on_delete=models.SET_NULL, null=True, blank=True, related_name='projects', verbose_name=_('Шаблон'), ) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='owned_projects', verbose_name=_('Владелец'), ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='created_projects', verbose_name=_('Создал'), ) objects = ProjectQuerySet.as_manager() class Meta: verbose_name = _('Проект') verbose_name_plural = _('Проекты') ordering = ['-created_at'] indexes = [ models.Index(fields=['status', '-created_at']), ] def __str__(self): return self.title def progress_counts(self) -> tuple[int, int]: """(done_tasks, total_tasks).""" agg = ( self.steps.filter(items__task__isnull=False) .aggregate( total=Count('items__task', distinct=True), done=Count( 'items__task', filter=Q(items__task__status='done'), distinct=True, ), ) ) return int(agg['done'] or 0), int(agg['total'] or 0) @property def progress_percent(self) -> int: done, total = self.progress_counts() if total <= 0: return 0 return int(round(100 * done / total)) class ProjectStep(BaseModel): STATUS_PENDING = 'pending' STATUS_IN_PROGRESS = 'in_progress' STATUS_DONE = 'done' STATUS_CHOICES = [ (STATUS_PENDING, _('Ожидает')), (STATUS_IN_PROGRESS, _('В работе')), (STATUS_DONE, _('Выполнен')), ] project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name='steps', verbose_name=_('Проект'), ) title = models.CharField(_('Название'), max_length=250) order = models.PositiveSmallIntegerField(_('Порядок'), default=0) department = models.ForeignKey( 'core.Directory', on_delete=models.SET_NULL, null=True, blank=True, limit_choices_to={'directory_type__code': 'department'}, related_name='project_steps', verbose_name=_('Отдел'), ) status = models.CharField( _('Статус'), max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING, db_index=True, ) planned_start = models.DateField(_('План. старт'), null=True, blank=True) planned_due = models.DateField(_('План. срок'), null=True, blank=True) class Meta: verbose_name = _('Шаг проекта') verbose_name_plural = _('Шаги проекта') ordering = ['order', 'id'] def __str__(self): return f'{self.order}. {self.title}' def refresh_status(self, *, save: bool = True) -> str: items = list(self.items.select_related('task')) if not items: new = self.STATUS_PENDING else: tasks = [i.task for i in items if i.task_id] if not tasks: new = self.STATUS_PENDING elif all(t.status == 'done' for t in tasks): new = self.STATUS_DONE elif any(t.status in ('in_progress', 'done') for t in tasks): new = self.STATUS_IN_PROGRESS else: new = self.STATUS_PENDING if new != self.status: self.status = new if save: self.save(update_fields=['status', 'updated_at']) return self.status class ProjectStepItem(BaseModel): step = models.ForeignKey( ProjectStep, on_delete=models.CASCADE, related_name='items', verbose_name=_('Шаг'), ) task = models.OneToOneField( 'tasks.Task', on_delete=models.SET_NULL, null=True, blank=True, related_name='project_step_item', verbose_name=_('Задача'), ) title = models.CharField(_('Название'), max_length=250) description = models.TextField(_('Описание'), blank=True) priority = models.CharField(_('Приоритет'), max_length=10, default='medium') order = models.PositiveSmallIntegerField(_('Порядок'), default=0) class Meta: verbose_name = _('Пункт шага') verbose_name_plural = _('Пункты шага') ordering = ['order', 'id'] def __str__(self): return self.title