projects: модуль проектов с шагами по отделам и Task.

Шаблоны/ручное создание, access_projects, сид «Открытие аптеки». Плюс SILO/S3 для Django media.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 16:26:45 +03:00
parent 69ae14f65e
commit 030bbc98fd
28 changed files with 2129 additions and 3 deletions
+257
View File
@@ -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