projects: модуль проектов с шагами по отделам и Task.
Шаблоны/ручное создание, access_projects, сид «Открытие аптеки». Плюс SILO/S3 для Django media. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"""Проекты с шагами по отделам; пункты шагов связываются с 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
|
||||
Reference in New Issue
Block a user