Отчёты, шаблоны, вложения и доставки
Модели Report, ReportSurveyTemplate, FileAttachment, ReportDelivery — структура отчётности и история доставок.
Отчёты, шаблоны, вложения и доставки
ReportSurveyTemplate
Префикс: rprts_
Таблица: report_survey_template
Файл: garden/modules/report/models/report.py
Шаблон отчёта в формате SurveyJS.
| Поле | Тип | Описание |
|---|---|---|
id | KSUID, rprts_* | |
name | CharField(255) | Название шаблона |
version | PositiveIntegerField | Версия (например, 1, 2, 3) |
description | TextField | Произвольное описание |
template_json | JSONField | SurveyJS-схема — целиком |
is_default | BooleanField | Флаг «шаблон по умолчанию» |
Constraints:
uq_template_name_version— одно сочетание(name, version)уникально.uq_only_one_default_template— ровно один шаблон может иметьis_default=True(partial unique constraint).
Если при сохранении шаблону ставится is_default=True, метод save() автоматически снимает флаг с остальных.
Дефолтный шаблон создаётся командой:
python manage.py seed_report_templatesReport
Префикс: rprt_
Таблица: report
Файл: garden/modules/report/models/report.py
Отчёт по наряду. Связь с нарядом — 1:1 через OneToOneField.
| Поле | Тип | Описание |
|---|---|---|
id | KSUID, rprt_* | |
service_visit | OneToOne → ServiceVisit | На какой наряд (PROTECT) |
author | FK → User | Бригадир — автор (limit_choices role=brigadier) |
reviewer | FK → User, nullable | Сотрудник — рецензент (limit_choices role IN (admin, staff)) |
template | FK → ReportSurveyTemplate | Шаблон, по которому собран отчёт |
template_name_snapshot | CharField(255) | Снимок имени шаблона |
template_version_snapshot | PositiveIntegerField | Снимок версии |
template_json_snapshot | JSONField | Полный снимок схемы на момент создания |
status | ReportStatus | См. ниже |
rejection_comment | TextField | Комментарий рецензента при отклонении |
submitted_at, approved_at, rejected_at | DateTimeField | Таймстемпы переходов |
submission_attempts | PositiveSmallIntegerField | Сколько раз подавался |
sent_to_client | BooleanField | Отправлен клиенту хоть раз? |
survey_data | JSONField(nullable) | Заполненные ответы по шаблону |
History подключена с excluded_fields=["survey_data", "template_json_snapshot"] — JSON-поля не пишутся в historical_report, чтобы не раздувать историю.
Снимок шаблона
В момент создания отчёта (когда Report._state.adding истинный, а template_json_snapshot пустой) метод save() копирует весь шаблон в snapshot-поля:
self.template_name_snapshot = tpl.name
self.template_version_snapshot = tpl.version
self.template_json_snapshot = tpl.template_jsonЗачем это нужно. Если потом изменить шаблон в ReportSurveyTemplate.template_json — уже созданные отчёты продолжат использовать ту версию схемы, по которой их собирали. Это:
- Гарантирует валидность данных —
survey_dataвсегда соответствует своему шаблону. - Поддерживает обратную совместимость — старые отчёты со старыми полями (
*-person-count/*-hours-count) корректно отображаются и считаются. - Делает шаблоны иммутабельными для исторических данных.
Статусная машина
| Статус | Триггер | Эффекты |
|---|---|---|
draft | Создание | Стартовая позиция, обычно создаётся автоматически вместе с нарядом |
submitted | survey_complete | submitted_at = now. submission_attempts += 1. Отправляется REPORT_SUBMITTED сотрудникам. |
approved | approve | approved_at = now. Запускает sync_visit_members_from_survey. Отправляется REPORT_APPROVED бригадиру. |
rejected | reject (с комментарием) | rejected_at = now. Требуется rejection_comment (валидация в clean()). Отправляется REPORT_REJECTED бригадиру. |
Поле sent_to_client поднимается отдельно — через POST /reports/{id}/survey/send-to-client/. Это действие отправляет REPORT_READY контактным лицам.
Эндпоинты отчёта
| Метод | Путь | Назначение |
|---|---|---|
GET | /api/reports/ | Список (поддерживает фильтры) |
GET | /api/reports/{id}/ | Детали |
POST | /api/reports/{id}/survey/ | Сохранить черновик survey_data |
POST | /api/reports/{id}/survey/complete/ | `draft |
POST | /api/reports/{id}/survey/approve/ | submitted → approved |
POST | /api/reports/{id}/survey/reject/ | submitted → rejected (требует rejection_comment) |
POST | /api/reports/{id}/survey/send-to-client/ | Поднимает sent_to_client, отправляет REPORT_READY |
GET | /api/reports/{id}/deliveries/ | История попыток доставки |
GET | /api/reports/{id}/report-summary/ | Аггрегированный отчёт для публичной страницы (расчёт чел.-час) |
FileAttachment
Префикс: file_
Таблица: file_attachment
Файл, прикреплённый к отчёту.
| Поле | Тип | Описание |
|---|---|---|
id | KSUID, file_* | |
report | FK → Report | К какому отчёту |
bucket | CharField(255) | Имя бакета в Supabase Storage |
object_key | CharField(1024) | Путь к файлу внутри бакета |
original_filename | CharField(255) | Исходное имя |
content_type | CharField(128) | MIME-тип |
size | BigIntegerField(nullable) | Размер в байтах |
checksum_sha256 | CharField(64) | SHA-256 хеш |
width, height | IntegerField(nullable) | Для изображений |
uploaded_at | DateTimeField(auto_now_add) | Когда загружен |
URL не хранится. Метод build_presigned_url(expires_seconds=3600) генерирует подписанную ссылку на лету. Это даёт короткоживущие ссылки и не требует публичности bucket.
ReportDelivery
Префикс: rprtdel_
Таблица: report_delivery
Файл: garden/modules/report/models/delivery.py
Запись о попытке доставки уведомления — фиксирует канал, статус, время, ошибки и payload.
| Поле | Тип | Описание |
|---|---|---|
id | KSUID, rprtdel_* | |
report | FK → Report, nullable | Для топиков report_* |
service_visit | FK → ServiceVisit, nullable | Для топиков work_* |
topic | NotificationTopic, nullable | Какой топик |
channel | DeliveryChannel | telegram / email |
telegram_chat_id | CharField(128, nullable) | Для канала telegram |
recipient_email | EmailField(nullable) | Для канала email |
status | DeliveryStatus | См. ниже |
error_code, error_message | CharField / TextField | При ошибках |
payload_snapshot | JSONField | Полезная нагрузка на момент отправки (для отладки) |
attempts | PositiveSmallIntegerField | Сколько раз пытались |
sent_at, delivered_at, opened_at, failed_at | DateTimeField | Таймстемпы переходов |
Constraint delivery_has_report_or_service_visit:
CheckConstraint(
check=Q(report__isnull=False) | Q(service_visit__isnull=False),
name="delivery_has_report_or_service_visit",
)Хотя бы одно из двух полей должно быть заполнено. Для большинства топиков заполнены оба (через report.service_visit); для work_started / work_completed — только service_visit (отчёта ещё / уже нет в фокусе).
Жизненный цикл доставки
opened достижим напрямую из sent, потому что Resend tracking pixel может сработать до (или вместо) события delivered.
Эндпоинт report-summary
GET /api/reports/{id}/report-summary/ — публичный (для авторизованных и для держателей прямой ссылки) — возвращает агрегированный отчёт:
{
"service_visit": {...},
"object": {...},
"client": {...},
"blocks": {
"standard_care": {
"is_active": true,
"people": 3,
"hours": 10.0,
"comment": "..."
},
"topiary_cut": {...},
"materials": [...],
"services": [...]
},
"files": [...]
}Расчёт людей и часов — через утилиту calculate_man_hours(survey_data, work_type):
def calculate_man_hours(survey_data, work_type):
workers_key = f"{work_type}-workers"
person_count_key = f"{work_type}-person-count"
hours_count_key = f"{work_type}-hours-count"
if workers_key in survey_data:
# Новый формат — массив {worker_id, hours}
workers = survey_data[workers_key]
return len(workers), sum(w.get("hours", 0) for w in workers)
elif person_count_key in survey_data:
# Старый формат — people × hours
p = survey_data.get(person_count_key, 0)
h = survey_data.get(hours_count_key, 0)
return p, p * h
return 0, 0.0Это поддерживает обе версии формы (со слайдерами и с matrixdynamic) одновременно, без миграции данных.