Files
ACORE/apps/projects/models.py
T
lokses 030bbc98fd projects: модуль проектов с шагами по отделам и Task.
Шаблоны/ручное создание, access_projects, сид «Открытие аптеки». Плюс SILO/S3 для Django media.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-23 16:26:45 +03:00

258 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Проекты с шагами по отделам; пункты шагов связываются с 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