87cb9de005
- Task.STATUS_CHOICES/PRIORITY_CHOICES переведены на русские msgid
(консистентно с ru-базой); миграция choices
- task_detail_view: метки ролей через gettext
- Шаблоны tasks/task-detail/task-edit размечены {% trans %}; статусы,
приоритеты, чек-лист, комментарии, участники, кнопки действий — uk/en
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
6.4 KiB
Python
168 lines
6.4 KiB
Python
from django.conf import settings
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
from django.utils.translation import gettext_lazy as _
|
||
|
||
from core.models.base import BaseModel
|
||
|
||
|
||
class TaskQuerySet(models.QuerySet):
|
||
def for_user(self, user):
|
||
"""Задачи, видимые сотруднику: створив сам, він відповідальний,
|
||
співвиконавець або спостерігач."""
|
||
q = (
|
||
models.Q(creator=user)
|
||
| models.Q(responsible=user)
|
||
| models.Q(co_executors=user)
|
||
| models.Q(observers=user)
|
||
)
|
||
return self.filter(q).distinct()
|
||
|
||
|
||
class Task(BaseModel):
|
||
STATUS_CHOICES = [
|
||
('pending', _('Ожидает')),
|
||
('in_progress', _('В работе')),
|
||
('done', _('Выполнено')),
|
||
('cancelled', _('Отменено')),
|
||
]
|
||
|
||
PRIORITY_CHOICES = [
|
||
('low', _('Низкий')),
|
||
('medium', _('Средний')),
|
||
('high', _('Высокий')),
|
||
]
|
||
|
||
PRIORITY_BADGES = {'low': 'badge-muted', 'medium': 'badge-warning', 'high': 'badge-danger'}
|
||
|
||
title = models.CharField(_('Назва'), max_length=250)
|
||
description = models.TextField(_('Опис'), blank=True)
|
||
status = models.CharField(_('Статус'), max_length=20,
|
||
choices=STATUS_CHOICES, default='pending', db_index=True)
|
||
priority = models.CharField(_('Пріоритет'), max_length=10,
|
||
choices=PRIORITY_CHOICES, default='medium', db_index=True)
|
||
due_date = models.DateField(_('Термін'), null=True, blank=True)
|
||
|
||
creator = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||
null=True, related_name='created_tasks', verbose_name=_('Постановник'),
|
||
)
|
||
# Ролі учасників — за зразком Бітрікс: відповідальний і співвиконавці
|
||
# виконують задачу (можуть міняти статус), спостерігачі лише бачать
|
||
# і коментують.
|
||
responsible = models.ManyToManyField(
|
||
settings.AUTH_USER_MODEL, blank=True,
|
||
related_name='responsible_tasks', verbose_name=_('Відповідальний'),
|
||
)
|
||
co_executors = models.ManyToManyField(
|
||
settings.AUTH_USER_MODEL, blank=True,
|
||
related_name='co_executor_tasks', verbose_name=_('Співвиконавці'),
|
||
)
|
||
observers = models.ManyToManyField(
|
||
settings.AUTH_USER_MODEL, blank=True,
|
||
related_name='observed_tasks', verbose_name=_('Спостерігачі'),
|
||
)
|
||
|
||
completed_at = models.DateTimeField(_('Виконано в'), null=True, blank=True)
|
||
|
||
objects = TaskQuerySet.as_manager()
|
||
|
||
class Meta:
|
||
verbose_name = _('Задача')
|
||
verbose_name_plural = _('Задачі')
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['status', '-created_at']),
|
||
models.Index(fields=['due_date']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
@property
|
||
def priority_badge(self):
|
||
return self.PRIORITY_BADGES.get(self.priority, 'badge-muted')
|
||
|
||
@property
|
||
def is_overdue(self):
|
||
return bool(
|
||
self.due_date
|
||
and self.status in ('pending', 'in_progress')
|
||
and self.due_date < timezone.localdate()
|
||
)
|
||
|
||
def is_participant(self, user) -> bool:
|
||
"""Может менять статус: автор, відповідальний або співвиконавець.
|
||
Спостерігачі — лише бачать і коментують, статус міняти не можуть."""
|
||
if self.creator_id == user.pk:
|
||
return True
|
||
if self.responsible.filter(pk=user.pk).exists():
|
||
return True
|
||
if self.co_executors.filter(pk=user.pk).exists():
|
||
return True
|
||
return False
|
||
|
||
def set_status(self, status: str):
|
||
self.status = status
|
||
self.completed_at = timezone.now() if status == 'done' else None
|
||
self.save(update_fields=['status', 'completed_at', 'updated_at'])
|
||
|
||
@property
|
||
def checklist_total(self) -> int:
|
||
return self.checklist.count()
|
||
|
||
@property
|
||
def checklist_done(self) -> int:
|
||
return self.checklist.filter(is_done=True).count()
|
||
|
||
|
||
class TaskComment(BaseModel):
|
||
task = models.ForeignKey(
|
||
Task, on_delete=models.CASCADE,
|
||
related_name='comments', verbose_name=_('Задача'),
|
||
)
|
||
author = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||
null=True, related_name='task_comments', verbose_name=_('Автор'),
|
||
)
|
||
text = models.TextField(_('Коментар'))
|
||
|
||
class Meta:
|
||
verbose_name = _('Коментар до задачі')
|
||
verbose_name_plural = _('Коментарі до задач')
|
||
ordering = ['created_at']
|
||
|
||
def __str__(self):
|
||
return f'{self.author}: {self.text[:50]}'
|
||
|
||
|
||
class TaskChecklistItem(BaseModel):
|
||
"""Пункт чек-листа задачі — виконавець просто відмічає зроблене
|
||
(напр. «Додати в 1С Бухгалтерія» при видачі доступів новому співробітнику)."""
|
||
task = models.ForeignKey(
|
||
Task, on_delete=models.CASCADE,
|
||
related_name='checklist', verbose_name=_('Задача'),
|
||
)
|
||
label = models.CharField(_('Пункт'), max_length=300)
|
||
is_done = models.BooleanField(_('Виконано'), default=False)
|
||
done_at = models.DateTimeField(_('Виконано в'), null=True, blank=True)
|
||
done_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
|
||
null=True, blank=True, related_name='+', verbose_name=_('Відмітив'),
|
||
)
|
||
order = models.PositiveSmallIntegerField(_('Порядок'), default=0)
|
||
|
||
class Meta:
|
||
verbose_name = _('Пункт чек-листа')
|
||
verbose_name_plural = _('Чек-лист задачі')
|
||
ordering = ['order', 'id']
|
||
|
||
def __str__(self):
|
||
return self.label
|
||
|
||
def set_done(self, done: bool, user=None):
|
||
self.is_done = done
|
||
self.done_at = timezone.now() if done else None
|
||
self.done_by = user if done else None
|
||
self.save(update_fields=['is_done', 'done_at', 'done_by', 'updated_at'])
|