030bbc98fd
Шаблоны/ручное создание, access_projects, сид «Открытие аптеки». Плюс SILO/S3 для Django media. Co-authored-by: Cursor <cursoragent@cursor.com>
258 lines
9.3 KiB
Python
258 lines
9.3 KiB
Python
"""Проекты с шагами по отделам; пункты шагов связываются с 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
|