Corp/backend/tasks/models.py

1208 lines
42 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.

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):
PROJECT = "project", "Проект"
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):
PROJECT = "project", "Проект"
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(
"Комментарий обложки должен принадлежать этой задаче."
)
class TaskBoardTemplate(models.Model):
"""Reusable board structure, independent of the source board and its tasks."""
name = models.CharField("Название", max_length=255)
description = models.TextField("Описание", blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="task_board_templates",
verbose_name="Автор",
)
is_shared = models.BooleanField("Общий шаблон", default=False)
layout = models.JSONField("Структура доски", default=dict)
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 TaskChecklistTemplate(models.Model):
"""Personal, board-owned or company-owned reusable checklist snapshot."""
class Scope(models.TextChoices):
PERSONAL = "personal", "Личный"
BOARD = "board", "Доска"
COMPANY = "company", "Компания"
name = models.CharField("Название", max_length=255)
description = models.TextField("Описание", blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="task_checklist_templates",
verbose_name="Автор",
)
scope = models.CharField(
"Область публикации",
max_length=16,
choices=Scope.choices,
default=Scope.PERSONAL,
)
board = models.ForeignKey(
TaskBoard,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="checklist_templates",
verbose_name="Доска",
)
# Kept for compatibility with the already deployed API and clients.
is_shared = models.BooleanField("Общий", default=False)
is_archived = models.BooleanField("В архиве", default=False)
version = models.PositiveIntegerField("Версия", default=1)
items = models.JSONField("Пункты", default=list)
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"]
permissions = [
(
"manage_company_checklist_templates",
"Управление корпоративными шаблонами чек-листов",
),
]
def __str__(self):
return self.name
class TaskChecklistTemplateBoardEditor(models.Model):
"""Explicitly appointed board template editor; not a board-wide task permission."""
board = models.ForeignKey(
TaskBoard,
on_delete=models.CASCADE,
related_name="checklist_template_editors",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="editable_task_checklist_template_boards",
)
appointed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="appointed_checklist_template_editors",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["board", "user"],
name="uniq_checklist_editor_board_user",
)
]
class TaskChecklistTemplateProposal(models.Model):
"""Submission snapshot; publication does not alter the author's template."""
class Status(models.TextChoices):
PENDING = "pending", "Ожидает"
APPROVED = "approved", "Одобрено"
REJECTED = "rejected", "Отклонено"
source_template = models.ForeignKey(
TaskChecklistTemplate,
on_delete=models.SET_NULL,
related_name="proposals",
null=True,
blank=True,
)
board = models.ForeignKey(
TaskBoard,
on_delete=models.CASCADE,
related_name="checklist_template_proposals",
)
proposed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="checklist_template_proposals",
)
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
items = models.JSONField(default=list)
status = models.CharField(
max_length=16,
choices=Status.choices,
default=Status.PENDING,
)
reviewed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="reviewed_checklist_template_proposals",
)
reviewed_at = models.DateTimeField(null=True, blank=True)
published_template = models.ForeignKey(
TaskChecklistTemplate,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="approved_proposals",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at", "-id"]
class TaskChecklistTemplateRevision(models.Model):
"""Immutable published template content and its editor at each version."""
template = models.ForeignKey(
TaskChecklistTemplate,
on_delete=models.CASCADE,
related_name="revisions",
)
version = models.PositiveIntegerField()
changed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="checklist_template_revisions",
)
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
items = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-version"]
constraints = [
models.UniqueConstraint(
fields=["template", "version"], name="uniq_checklist_revision_version"
)
]