1016 lines
36 KiB
Python
1016 lines
36 KiB
Python
from django.conf import settings
|
||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import models
|
||
from django.db.models.functions import Lower
|
||
from django.utils import timezone
|
||
from employees.models import Department
|
||
|
||
|
||
class TaskPriority(models.TextChoices):
|
||
LOW = "low", "Низкий"
|
||
MEDIUM = "medium", "Средний"
|
||
HIGH = "high", "Высокий"
|
||
CRITICAL = "critical", "Критический"
|
||
|
||
|
||
class TaskCoverType(models.TextChoices):
|
||
ATTACHMENT = "attachment", "Файл"
|
||
CHECKLIST = "checklist", "Чек-лист"
|
||
COMMENT = "comment", "Комментарий"
|
||
EXTERNAL_LINK = "external_link", "Внешняя ссылка"
|
||
LINKED_OBJECT = "linked_object", "Связанный объект"
|
||
|
||
|
||
class TaskAutomationKind(models.TextChoices):
|
||
EVENT = "event", "Событие"
|
||
SCHEDULE = "schedule", "Срок"
|
||
BUTTON = "button", "Кнопка"
|
||
|
||
|
||
class TaskAutomationTrigger(models.TextChoices):
|
||
MANUAL = "manual", "Ручной запуск"
|
||
TASK_CREATED = "task_created", "Задача создана"
|
||
TASK_UPDATED = "task_updated", "Задача изменена"
|
||
TASK_MOVED = "task_moved", "Задача перемещена"
|
||
LABEL_ADDED = "label_added", "Метка добавлена"
|
||
LABEL_REMOVED = "label_removed", "Метка удалена"
|
||
ASSIGNEE_CHANGED = "assignee_changed", "Исполнитель изменён"
|
||
DUE_DATE_CHANGED = "due_date_changed", "Срок изменён"
|
||
PRIORITY_CHANGED = "priority_changed", "Срочность изменена"
|
||
ATTACHMENT_ADDED = "attachment_added", "Файл добавлен"
|
||
COMMENT_ADDED = "comment_added", "Комментарий добавлен"
|
||
CHECKLIST_COMPLETED = "checklist_completed", "Пункт чек-листа выполнен"
|
||
LINKED_OBJECT_ADDED = "linked_object_added", "Связанный объект добавлен"
|
||
DATE_REACHED = "date_reached", "Наступила дата"
|
||
|
||
|
||
class TaskAutomationRunStatus(models.TextChoices):
|
||
RUNNING = "running", "Выполняется"
|
||
SUCCESS = "success", "Выполнено"
|
||
PARTIAL = "partial", "Выполнено частично"
|
||
FAILED = "failed", "Ошибка"
|
||
SKIPPED = "skipped", "Пропущено"
|
||
|
||
|
||
class TaskBoardAccessScope(models.TextChoices):
|
||
ALL = "all", "Для всех"
|
||
PRIVATE = "private", "Для себя"
|
||
RESTRICTED = "restricted", "Выборочно"
|
||
|
||
|
||
class TaskBoard(models.Model):
|
||
name = models.CharField("Название", max_length=255)
|
||
description = models.TextField("Описание", blank=True)
|
||
avatar = models.ImageField(
|
||
"Аватар доски",
|
||
upload_to="task_board_avatars/%Y/%m/",
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.PROTECT,
|
||
related_name="created_task_boards",
|
||
verbose_name="Создал",
|
||
)
|
||
members = models.ManyToManyField(
|
||
settings.AUTH_USER_MODEL,
|
||
blank=True,
|
||
related_name="task_boards",
|
||
verbose_name="Участники",
|
||
)
|
||
departments = models.ManyToManyField(
|
||
Department,
|
||
blank=True,
|
||
related_name="task_boards",
|
||
verbose_name="Отделы",
|
||
)
|
||
access_scope = models.CharField(
|
||
"Доступ",
|
||
max_length=16,
|
||
choices=TaskBoardAccessScope.choices,
|
||
default=TaskBoardAccessScope.ALL,
|
||
)
|
||
is_archived = models.BooleanField("Архивная", default=False)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Доска задач"
|
||
verbose_name_plural = "Доски задач"
|
||
ordering = ["name", "id"]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class TaskBoardGroup(models.Model):
|
||
name = models.CharField("Название", max_length=120)
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name="task_board_groups",
|
||
verbose_name="Пользователь",
|
||
)
|
||
boards = models.ManyToManyField(
|
||
TaskBoard,
|
||
through="TaskBoardGroupMembership",
|
||
blank=True,
|
||
related_name="board_groups",
|
||
verbose_name="Доски",
|
||
)
|
||
is_shared = models.BooleanField("Общая", default=False)
|
||
hidden_by = models.ManyToManyField(
|
||
settings.AUTH_USER_MODEL,
|
||
blank=True,
|
||
related_name="hidden_task_board_groups",
|
||
verbose_name="Скрыта пользователями",
|
||
)
|
||
position = models.PositiveIntegerField("Позиция", default=0)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Группа досок задач"
|
||
verbose_name_plural = "Группы досок задач"
|
||
ordering = ["position", "name", "id"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["user", "name"],
|
||
name="uniq_task_board_group_user_name",
|
||
)
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.user}: {self.name}"
|
||
|
||
|
||
class TaskBoardGroupMembership(models.Model):
|
||
group = models.ForeignKey(
|
||
TaskBoardGroup,
|
||
on_delete=models.CASCADE,
|
||
related_name="board_memberships",
|
||
verbose_name="Группа",
|
||
)
|
||
board = models.ForeignKey(
|
||
TaskBoard,
|
||
on_delete=models.CASCADE,
|
||
related_name="group_memberships",
|
||
verbose_name="Доска",
|
||
)
|
||
rank_key = models.CharField("Ключ порядка", max_length=32)
|
||
added_at = models.DateTimeField("Добавлено", auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Доска в группе"
|
||
verbose_name_plural = "Доски в группах"
|
||
ordering = ["group_id", "rank_key", "id"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["group", "board"],
|
||
name="uniq_task_board_group_membership",
|
||
),
|
||
models.UniqueConstraint(
|
||
fields=["group", "rank_key"],
|
||
name="uniq_task_board_group_rank_key",
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.group}: {self.rank_key}. {self.board}"
|
||
|
||
|
||
class TaskBoardPlacement(models.Model):
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name="task_board_placements",
|
||
verbose_name="Пользователь",
|
||
)
|
||
board = models.ForeignKey(
|
||
TaskBoard,
|
||
on_delete=models.CASCADE,
|
||
related_name="user_placements",
|
||
verbose_name="Доска",
|
||
)
|
||
is_pinned = models.BooleanField("Закреплена", default=False)
|
||
pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True)
|
||
notifications_enabled = models.BooleanField("Уведомления", default=True)
|
||
view_state = models.JSONField(
|
||
"Состояние представления",
|
||
default=dict,
|
||
blank=True,
|
||
)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Размещение доски задач"
|
||
verbose_name_plural = "Размещения досок задач"
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["user", "board"],
|
||
name="uniq_task_board_placement_user_board",
|
||
)
|
||
]
|
||
indexes = [
|
||
models.Index(fields=["user", "is_pinned"]),
|
||
models.Index(fields=["board", "user"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.user}: {self.board}"
|
||
|
||
|
||
class TaskUserSettings(models.Model):
|
||
user = models.OneToOneField(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
related_name="task_settings",
|
||
verbose_name="Пользователь",
|
||
)
|
||
default_board = models.ForeignKey(
|
||
TaskBoard,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="default_for_user_settings",
|
||
verbose_name="Доска по умолчанию",
|
||
)
|
||
default_group = models.ForeignKey(
|
||
TaskBoardGroup,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="default_for_user_settings",
|
||
verbose_name="Группа досок по умолчанию",
|
||
)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Настройки задач пользователя"
|
||
verbose_name_plural = "Настройки задач пользователей"
|
||
|
||
def __str__(self):
|
||
return f"{self.user} - задачи"
|
||
|
||
|
||
class TaskColumn(models.Model):
|
||
board = models.ForeignKey(
|
||
TaskBoard,
|
||
on_delete=models.CASCADE,
|
||
related_name="columns",
|
||
verbose_name="Доска",
|
||
)
|
||
parent = models.ForeignKey(
|
||
"self",
|
||
on_delete=models.CASCADE,
|
||
related_name="subcolumns",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Родительская колонка",
|
||
)
|
||
name = models.CharField("Название", max_length=120)
|
||
position = models.PositiveIntegerField("Позиция", default=0)
|
||
color = models.CharField("Цвет", max_length=32, blank=True)
|
||
is_done = models.BooleanField("Финальная колонка", default=False)
|
||
is_archived = models.BooleanField("Архивная", default=False)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Колонка задач"
|
||
verbose_name_plural = "Колонки задач"
|
||
ordering = ["position", "id"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["board", "name"],
|
||
condition=models.Q(parent__isnull=True),
|
||
name="uniq_task_top_column_board_name",
|
||
),
|
||
models.UniqueConstraint(
|
||
fields=["parent", "name"],
|
||
condition=models.Q(parent__isnull=False),
|
||
name="uniq_task_subcolumn_parent_name",
|
||
),
|
||
models.UniqueConstraint(
|
||
fields=["board"],
|
||
condition=models.Q(parent__isnull=True, is_done=True),
|
||
name="uniq_task_done_column_per_board",
|
||
),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.board}: {self.name}"
|
||
|
||
@property
|
||
def is_subcolumn(self):
|
||
return self.parent_id is not None
|
||
|
||
|
||
class TaskColumnRow(models.Model):
|
||
column = models.ForeignKey(
|
||
TaskColumn,
|
||
on_delete=models.CASCADE,
|
||
related_name="rows",
|
||
verbose_name="Основная колонка",
|
||
)
|
||
name = models.CharField("Название", max_length=120)
|
||
position = models.PositiveIntegerField("Позиция", default=0)
|
||
color = models.CharField("Цвет", max_length=32, blank=True)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Дорожка колонки"
|
||
verbose_name_plural = "Дорожки колонок"
|
||
ordering = ["position", "id"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["column", "name"],
|
||
name="uniq_task_column_row_name",
|
||
)
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.column}: {self.name}"
|
||
|
||
|
||
class TaskLabel(models.Model):
|
||
name = models.CharField("Название", max_length=80)
|
||
color = models.CharField("Цвет", max_length=32, default="#38bdf8")
|
||
|
||
class Meta:
|
||
verbose_name = "Метка задачи"
|
||
verbose_name_plural = "Метки задач"
|
||
ordering = ["name", "id"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
Lower("name"),
|
||
name="uniq_task_label_name_ci",
|
||
)
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class Task(models.Model):
|
||
board = models.ForeignKey(
|
||
TaskBoard,
|
||
on_delete=models.CASCADE,
|
||
related_name="tasks",
|
||
verbose_name="Доска",
|
||
)
|
||
column = models.ForeignKey(
|
||
TaskColumn,
|
||
on_delete=models.PROTECT,
|
||
related_name="tasks",
|
||
verbose_name="Колонка",
|
||
)
|
||
row = models.ForeignKey(
|
||
TaskColumnRow,
|
||
on_delete=models.SET_NULL,
|
||
related_name="tasks",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Дорожка",
|
||
)
|
||
title = models.CharField("Название", max_length=255)
|
||
description = models.TextField("Описание", blank=True)
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.PROTECT,
|
||
related_name="created_tasks",
|
||
verbose_name="Создал",
|
||
)
|
||
assignee = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="assigned_tasks",
|
||
verbose_name="Исполнитель",
|
||
)
|
||
participants = models.ManyToManyField(
|
||
settings.AUTH_USER_MODEL,
|
||
blank=True,
|
||
related_name="participating_tasks",
|
||
verbose_name="Участники",
|
||
)
|
||
labels = models.ManyToManyField(
|
||
TaskLabel,
|
||
blank=True,
|
||
related_name="tasks",
|
||
verbose_name="Метки",
|
||
)
|
||
skills = models.ManyToManyField(
|
||
"employees.Skill",
|
||
blank=True,
|
||
related_name="tasks",
|
||
verbose_name="Применяемые навыки",
|
||
)
|
||
priority = models.CharField(
|
||
"Приоритет",
|
||
max_length=20,
|
||
choices=TaskPriority.choices,
|
||
default=TaskPriority.MEDIUM,
|
||
)
|
||
due_date = models.DateField("Срок", null=True, blank=True)
|
||
position = models.PositiveIntegerField("Позиция", default=0)
|
||
completed_at = models.DateTimeField("Завершено", null=True, blank=True)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Задача"
|
||
verbose_name_plural = "Задачи"
|
||
ordering = ["position", "-created_at", "id"]
|
||
indexes = [
|
||
models.Index(fields=["board", "column", "row", "position"]),
|
||
models.Index(fields=["assignee", "due_date"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
def save(self, *args, **kwargs):
|
||
if self.column_id and self.board_id != self.column.board_id:
|
||
self.board_id = self.column.board_id
|
||
|
||
if self.column_id and self.column.is_done and not self.completed_at:
|
||
self.completed_at = timezone.now()
|
||
elif self.column_id and not self.column.is_done:
|
||
self.completed_at = None
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
class TaskExecutionCycle(models.Model):
|
||
"""Immutable role/deadline snapshot for one task completion cycle.
|
||
|
||
The task may be reopened, reassigned or deleted later. Secret skill
|
||
progress must still belong to the employee who was the assignee when the
|
||
task entered the final column, so the mutable task itself is not a safe
|
||
source of historical truth.
|
||
"""
|
||
|
||
class Status(models.TextChoices):
|
||
PENDING = "pending", "Ожидает подтверждения"
|
||
CONFIRMED = "confirmed", "Подтверждено"
|
||
LATE = "late", "Завершено после срока"
|
||
REOPENED = "reopened", "Возвращено в работу"
|
||
EXCLUDED = "excluded", "Не участвует в расчёте"
|
||
|
||
task = models.ForeignKey(
|
||
Task,
|
||
on_delete=models.SET_NULL,
|
||
related_name="execution_cycles",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Задача",
|
||
)
|
||
task_id_snapshot = models.PositiveBigIntegerField("ID задачи")
|
||
employee = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
related_name="task_execution_cycles",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Исполнитель на момент завершения",
|
||
)
|
||
cycle_key = models.CharField("Ключ цикла", max_length=180, unique=True)
|
||
task_title = models.CharField("Название задачи", max_length=255)
|
||
due_date_snapshot = models.DateField("Срок на момент завершения", null=True)
|
||
completed_at_snapshot = models.DateTimeField("Время завершения")
|
||
protection_until = models.DateTimeField("Подтвердить после")
|
||
status = models.CharField(
|
||
"Состояние",
|
||
max_length=16,
|
||
choices=Status.choices,
|
||
default=Status.PENDING,
|
||
)
|
||
is_self_assigned = models.BooleanField("Самоназначение", default=False)
|
||
exclusion_reason = models.CharField("Причина исключения", max_length=80, blank=True)
|
||
reopened_at = models.DateTimeField("Возвращено в работу", null=True, blank=True)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Цикл исполнения задачи"
|
||
verbose_name_plural = "Циклы исполнения задач"
|
||
ordering = ["completed_at_snapshot", "id"]
|
||
indexes = [
|
||
models.Index(fields=["employee", "status", "completed_at_snapshot"]),
|
||
models.Index(fields=["task", "status"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.task_title}: {self.get_status_display()}"
|
||
|
||
|
||
class TaskAutomation(models.Model):
|
||
name = models.CharField("Название", max_length=160)
|
||
description = models.TextField("Описание", blank=True)
|
||
kind = models.CharField(
|
||
"Тип",
|
||
max_length=16,
|
||
choices=TaskAutomationKind.choices,
|
||
default=TaskAutomationKind.EVENT,
|
||
)
|
||
trigger = models.CharField(
|
||
"Событие",
|
||
max_length=32,
|
||
choices=TaskAutomationTrigger.choices,
|
||
default=TaskAutomationTrigger.TASK_UPDATED,
|
||
)
|
||
conditions = models.JSONField(
|
||
"Условия",
|
||
default=dict,
|
||
blank=True,
|
||
)
|
||
actions = models.JSONField(
|
||
"Действия",
|
||
default=list,
|
||
blank=True,
|
||
)
|
||
schedule_config = models.JSONField(
|
||
"Настройки срока",
|
||
default=dict,
|
||
blank=True,
|
||
)
|
||
applies_to_all_boards = models.BooleanField(
|
||
"Для всех досок",
|
||
default=True,
|
||
)
|
||
boards = models.ManyToManyField(
|
||
TaskBoard,
|
||
blank=True,
|
||
related_name="automations",
|
||
verbose_name="Доски",
|
||
)
|
||
is_active = models.BooleanField("Активна", default=True)
|
||
stop_on_error = models.BooleanField("Остановить при ошибке", default=True)
|
||
position = models.PositiveIntegerField("Позиция", default=0)
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.PROTECT,
|
||
related_name="created_task_automations",
|
||
verbose_name="Создал",
|
||
)
|
||
last_run_at = models.DateTimeField("Последний запуск", null=True, blank=True)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Автоматизация задач"
|
||
verbose_name_plural = "Автоматизации задач"
|
||
ordering = ["kind", "position", "name", "id"]
|
||
indexes = [
|
||
models.Index(fields=["kind", "is_active", "position"]),
|
||
models.Index(fields=["trigger", "is_active"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
def applies_to_board(self, board_id):
|
||
return self.applies_to_all_boards or self.boards.filter(id=board_id).exists()
|
||
|
||
|
||
class TaskAutomationRun(models.Model):
|
||
automation = models.ForeignKey(
|
||
TaskAutomation,
|
||
on_delete=models.CASCADE,
|
||
related_name="runs",
|
||
verbose_name="Автоматизация",
|
||
)
|
||
task = models.ForeignKey(
|
||
Task,
|
||
on_delete=models.CASCADE,
|
||
related_name="automation_runs",
|
||
verbose_name="Задача",
|
||
)
|
||
trigger_activity = models.ForeignKey(
|
||
"TaskActivity",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="automation_runs",
|
||
verbose_name="Событие",
|
||
)
|
||
actor = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="task_automation_runs",
|
||
verbose_name="Запустил",
|
||
)
|
||
source = models.CharField("Источник", max_length=16)
|
||
status = models.CharField(
|
||
"Статус",
|
||
max_length=16,
|
||
choices=TaskAutomationRunStatus.choices,
|
||
default=TaskAutomationRunStatus.RUNNING,
|
||
)
|
||
idempotency_key = models.CharField(
|
||
"Ключ идемпотентности",
|
||
max_length=255,
|
||
unique=True,
|
||
)
|
||
context = models.JSONField("Контекст", default=dict, blank=True)
|
||
actions_log = models.JSONField("Журнал действий", default=list, blank=True)
|
||
error = models.TextField("Ошибка", blank=True)
|
||
started_at = models.DateTimeField("Начато", auto_now_add=True)
|
||
finished_at = models.DateTimeField("Завершено", null=True, blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Запуск автоматизации задач"
|
||
verbose_name_plural = "Запуски автоматизаций задач"
|
||
ordering = ["-started_at", "-id"]
|
||
indexes = [
|
||
models.Index(fields=["automation", "-started_at"]),
|
||
models.Index(fields=["task", "-started_at"]),
|
||
models.Index(fields=["status", "-started_at"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.automation}: {self.task} ({self.status})"
|
||
|
||
|
||
class TaskAttachment(models.Model):
|
||
task = models.ForeignKey(
|
||
Task,
|
||
on_delete=models.CASCADE,
|
||
related_name="attachments",
|
||
verbose_name="Задача",
|
||
)
|
||
comment = models.ForeignKey(
|
||
"communications.Message",
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="task_attachments",
|
||
verbose_name="Комментарий",
|
||
)
|
||
file = models.FileField(
|
||
"Файл",
|
||
upload_to="task_attachments/%Y/%m/%d/",
|
||
)
|
||
thumbnail = models.ImageField(
|
||
"Миниатюра",
|
||
upload_to="task_attachment_thumbnails/%Y/%m/%d/",
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
file_name = models.CharField("Название файла", max_length=255)
|
||
file_size = models.PositiveBigIntegerField("Размер файла (байты)")
|
||
mime_type = models.CharField("MIME-тип", max_length=255, blank=True)
|
||
uploaded_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.PROTECT,
|
||
related_name="uploaded_task_attachments",
|
||
verbose_name="Загрузил",
|
||
)
|
||
created_at = models.DateTimeField("Загружено", auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Вложение задачи"
|
||
verbose_name_plural = "Вложения задач"
|
||
ordering = ["created_at", "id"]
|
||
indexes = [models.Index(fields=["task", "created_at"])]
|
||
|
||
def __str__(self):
|
||
return f"{self.task}: {self.file_name}"
|
||
|
||
|
||
class TaskChecklistItem(models.Model):
|
||
task = models.ForeignKey(
|
||
Task,
|
||
on_delete=models.CASCADE,
|
||
related_name="checklist_items",
|
||
verbose_name="Задача",
|
||
)
|
||
title = models.CharField("Пункт", max_length=500)
|
||
position = models.PositiveIntegerField("Позиция", default=0)
|
||
is_completed = models.BooleanField("Выполнен", default=False)
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.PROTECT,
|
||
related_name="created_task_checklist_items",
|
||
verbose_name="Создал",
|
||
)
|
||
completed_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="completed_task_checklist_items",
|
||
verbose_name="Отметил выполненным",
|
||
)
|
||
completed_at = models.DateTimeField("Выполнен", null=True, blank=True)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Пункт чек-листа задачи"
|
||
verbose_name_plural = "Пункты чек-листа задач"
|
||
ordering = ["position", "id"]
|
||
indexes = [
|
||
models.Index(fields=["task", "position"]),
|
||
models.Index(fields=["task", "is_completed"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.task}: {self.title}"
|
||
|
||
|
||
class TaskLinkedObjectKind(models.TextChoices):
|
||
POST = "post", "Новость"
|
||
MESSAGE = "message", "Сообщение"
|
||
CALENDAR_EVENT = "calendar_event", "Календарное событие"
|
||
DOCUMENT = "document", "Документ"
|
||
REQUEST = "request", "Заявление"
|
||
PROCUREMENT_REQUEST = "procurement_request", "Заявка на закупку"
|
||
EMPLOYEE = "employee", "Сотрудник"
|
||
GUEST = "guest", "Гость"
|
||
GUEST_VISIT = "guest_visit", "Заявка на гостевой визит"
|
||
ATTENDANCE_RECORD = "attendance_record", "Запись посещаемости"
|
||
|
||
|
||
class TaskActivityObjectKind(models.TextChoices):
|
||
POST = "post", "Новость"
|
||
MESSAGE = "message", "Сообщение"
|
||
CALENDAR_EVENT = "calendar_event", "Календарное событие"
|
||
DOCUMENT = "document", "Документ"
|
||
REQUEST = "request", "Заявление"
|
||
PROCUREMENT_REQUEST = "procurement_request", "Заявка на закупку"
|
||
EMPLOYEE = "employee", "Сотрудник"
|
||
GUEST = "guest", "Гость"
|
||
GUEST_VISIT = "guest_visit", "Заявка на гостевой визит"
|
||
ATTENDANCE_RECORD = "attendance_record", "Запись посещаемости"
|
||
EXTERNAL_LINK = "external_link", "Внешняя ссылка"
|
||
CHECKLIST_ITEM = "checklist_item", "Пункт чек-листа"
|
||
COMMENT = "comment", "Комментарий"
|
||
|
||
|
||
class TaskActivityAction(models.TextChoices):
|
||
CREATED = "created", "Создал задачу"
|
||
UPDATED = "updated", "Обновил задачу"
|
||
CLAIMED = "claimed", "Взял задачу в работу"
|
||
MOVED = "moved", "Переместил задачу"
|
||
LINKED = "linked", "Связал объект"
|
||
UNLINKED = "unlinked", "Убрал связь"
|
||
ATTACHMENT_ADDED = "attachment_added", "Добавил файл"
|
||
ATTACHMENT_REMOVED = "attachment_removed", "Удалил файл"
|
||
CHECKLIST_ITEM_ADDED = "checklist_item_added", "Добавил пункт чек-листа"
|
||
CHECKLIST_ITEM_UPDATED = "checklist_item_updated", "Изменил пункт чек-листа"
|
||
CHECKLIST_ITEM_COMPLETED = (
|
||
"checklist_item_completed",
|
||
"Выполнил пункт чек-листа",
|
||
)
|
||
CHECKLIST_ITEM_REOPENED = (
|
||
"checklist_item_reopened",
|
||
"Вернул пункт чек-листа в работу",
|
||
)
|
||
CHECKLIST_ITEM_REMOVED = "checklist_item_removed", "Удалил пункт чек-листа"
|
||
COMMENT_ADDED = "comment_added", "Добавил комментарий"
|
||
COMMENT_EDITED = "comment_edited", "Изменил комментарий"
|
||
COMMENT_REMOVED = "comment_removed", "Удалил комментарий"
|
||
|
||
|
||
class TaskActivity(models.Model):
|
||
task = models.ForeignKey(
|
||
Task,
|
||
on_delete=models.CASCADE,
|
||
related_name="activities",
|
||
verbose_name="Задача",
|
||
)
|
||
actor = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name="task_activities",
|
||
verbose_name="Исполнитель",
|
||
)
|
||
action = models.CharField(
|
||
"Действие",
|
||
max_length=32,
|
||
choices=TaskActivityAction.choices,
|
||
)
|
||
object_kind = models.CharField(
|
||
"Тип объекта",
|
||
max_length=32,
|
||
choices=TaskActivityObjectKind.choices,
|
||
blank=True,
|
||
)
|
||
object_id = models.PositiveBigIntegerField("ID объекта", null=True, blank=True)
|
||
metadata = models.JSONField("Метаданные", default=dict, blank=True)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "История задачи"
|
||
verbose_name_plural = "История задач"
|
||
ordering = ["-created_at", "-id"]
|
||
indexes = [
|
||
models.Index(fields=["task", "-created_at"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.task} - {self.action}"
|
||
|
||
|
||
class TaskLinkedObject(models.Model):
|
||
task = models.ForeignKey(
|
||
Task,
|
||
on_delete=models.CASCADE,
|
||
related_name="linked_objects",
|
||
verbose_name="Задача",
|
||
)
|
||
kind = models.CharField(
|
||
"Тип связи",
|
||
max_length=32,
|
||
choices=TaskLinkedObjectKind.choices,
|
||
)
|
||
content_type = models.ForeignKey(
|
||
ContentType,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Тип объекта",
|
||
)
|
||
object_id = models.PositiveBigIntegerField("ID объекта")
|
||
content_object = GenericForeignKey("content_type", "object_id")
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.PROTECT,
|
||
related_name="created_task_links",
|
||
verbose_name="Связал",
|
||
)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
metadata = models.JSONField("Метаданные", default=dict, blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Связанный объект задачи"
|
||
verbose_name_plural = "Связанные объекты задач"
|
||
ordering = ["-created_at", "-id"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["task", "content_type", "object_id"],
|
||
name="uniq_task_linked_object",
|
||
)
|
||
]
|
||
indexes = [
|
||
models.Index(fields=["task", "kind"]),
|
||
models.Index(fields=["content_type", "object_id"]),
|
||
]
|
||
|
||
def __str__(self):
|
||
return f"{self.task} -> {self.kind}:{self.object_id}"
|
||
|
||
|
||
class TaskExternalLink(models.Model):
|
||
task = models.ForeignKey(
|
||
Task,
|
||
on_delete=models.CASCADE,
|
||
related_name="external_links",
|
||
verbose_name="Задача",
|
||
)
|
||
url = models.URLField("Ссылка", max_length=2048)
|
||
title = models.CharField("Название", max_length=255, blank=True)
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.PROTECT,
|
||
related_name="created_task_external_links",
|
||
verbose_name="Добавил",
|
||
)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Внешняя ссылка задачи"
|
||
verbose_name_plural = "Внешние ссылки задач"
|
||
ordering = ["-created_at", "-id"]
|
||
constraints = [
|
||
models.UniqueConstraint(
|
||
fields=["task", "url"],
|
||
name="uniq_task_external_link",
|
||
)
|
||
]
|
||
indexes = [models.Index(fields=["task", "created_at"])]
|
||
|
||
def __str__(self):
|
||
return f"{self.task} -> {self.title or self.url}"
|
||
|
||
|
||
class TaskCover(models.Model):
|
||
task = models.OneToOneField(
|
||
Task,
|
||
on_delete=models.CASCADE,
|
||
related_name="cover",
|
||
verbose_name="Задача",
|
||
)
|
||
kind = models.CharField(
|
||
"Тип",
|
||
max_length=32,
|
||
choices=TaskCoverType.choices,
|
||
)
|
||
attachment = models.ForeignKey(
|
||
TaskAttachment,
|
||
on_delete=models.CASCADE,
|
||
related_name="task_covers",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Файл",
|
||
)
|
||
comment = models.ForeignKey(
|
||
"communications.Message",
|
||
on_delete=models.CASCADE,
|
||
related_name="task_covers",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Комментарий",
|
||
)
|
||
external_link = models.ForeignKey(
|
||
TaskExternalLink,
|
||
on_delete=models.CASCADE,
|
||
related_name="task_covers",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Внешняя ссылка",
|
||
)
|
||
linked_object = models.ForeignKey(
|
||
TaskLinkedObject,
|
||
on_delete=models.CASCADE,
|
||
related_name="task_covers",
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Связанный объект",
|
||
)
|
||
created_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.PROTECT,
|
||
related_name="created_task_covers",
|
||
verbose_name="Установил",
|
||
)
|
||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Обложка задачи"
|
||
verbose_name_plural = "Обложки задач"
|
||
|
||
def __str__(self):
|
||
return f"{self.task}: {self.get_kind_display()}"
|
||
|
||
def clean(self):
|
||
super().clean()
|
||
sources = {
|
||
TaskCoverType.ATTACHMENT: self.attachment_id,
|
||
TaskCoverType.CHECKLIST: True,
|
||
TaskCoverType.COMMENT: self.comment_id,
|
||
TaskCoverType.EXTERNAL_LINK: self.external_link_id,
|
||
TaskCoverType.LINKED_OBJECT: self.linked_object_id,
|
||
}
|
||
selected_source = sources.get(self.kind)
|
||
if not selected_source:
|
||
raise ValidationError("Для выбранного типа укажите источник обложки.")
|
||
|
||
populated = sum(
|
||
value is not None
|
||
for value in (
|
||
self.attachment_id,
|
||
self.comment_id,
|
||
self.external_link_id,
|
||
self.linked_object_id,
|
||
)
|
||
)
|
||
expected = 0 if self.kind == TaskCoverType.CHECKLIST else 1
|
||
if populated != expected:
|
||
raise ValidationError("Обложка должна содержать только один источник.")
|
||
|
||
task_bound_sources = (
|
||
self.attachment,
|
||
self.external_link,
|
||
self.linked_object,
|
||
)
|
||
if any(
|
||
source is not None and source.task_id != self.task_id
|
||
for source in task_bound_sources
|
||
):
|
||
raise ValidationError("Источник обложки должен принадлежать этой задаче.")
|
||
|
||
if self.comment_id:
|
||
task_content_type = ContentType.objects.get_for_model(Task)
|
||
chat = self.comment.chat
|
||
if not (
|
||
chat.type == "comments"
|
||
and chat.context_content_type_id == task_content_type.id
|
||
and chat.context_object_id == self.task_id
|
||
):
|
||
raise ValidationError(
|
||
"Комментарий обложки должен принадлежать этой задаче."
|
||
)
|