Воркфлоу задач
Статуси, переходи, автоматичне створення креативів, неймінг, сповіщення в Slack і Google Drive.
Документ описує, як працює виробничий воркфлоу задач на бекенді kumquat: статуси, переходи, автоматичне створення креативів, неймінг, сповіщення в Slack і роботу з Google Drive.
Основні файли:
kumquat_tasks/enums.py— статуси, дозволені переходи (ALLOWED_TRANSITIONS), групи типів задач, стан автоматизації.kumquat_tasks/field_policy.py— які поля редагуються/обов’язкові/сховані для стану задачі.kumquat_tasks/workflow/transitions.py— перевірка переходів.kumquat_tasks/workflow/handlers.py— обробники статусів.kumquat_tasks/workflow/creative_factory.py— генерація креативів і неймінг.kumquat_tasks/workflow/legacy_remap.py— розбір Legacy Remap List.kumquat_tasks/notifications.py— сповіщення в Slack;kumquat_tasks/drive.py— папки Google Drive.kumquat_api/routes/tasks.py— ендпоінт зміни статусу.kumquat_api/worker/tasks.py— Celery-задача.
Задача і креатив
- Задача (Task) — одиниця виробництва. Вона групує креативи, які з неї виходять, і несе таксономію (продукт, тип задачі, медіатип, мову тощо), кількість, дедлайн, людей і статус.
- Креатив (Creative) — конкретна відео/статик-картка, що йде в тестування в рекламному
кабінеті. Кожен креатив належить одній задачі (
task_id). - Зв’язки не дублюються масивами: «креативи задачі» — це запит
find({task_id}), «похідні креативи» — запит поorigin_ref. Джерело правди — сам документ, а не збережені списки id. - Люди:
Task Owner— постановник задачі;Assignee— виконавець.
Два поля-посилання на вихідні креативи:
origin_creative_id— один вихідний креатив, обов’язковий дляVAR/VARDES/VARAI.source_creative_ids— кілька вихідних креативів, дляLOC/WHT/ADPT/RSZ.
Види задач (Task Type)
Task Type визначає, як автоматизація створює креативи і звідки бере дані.
| Task Type | Що це | Картки |
|---|---|---|
NEW |
Нові базові креативи з нуля | count карток, з полів задачі |
VAR |
Варіації наявного креативу | count карток; джерело — Origin Creative |
NEWDES |
Те саме, що NEW, дизайнерська задача |
count карток, з полів задачі |
VARDES |
Варіації наявного креативу, дизайнерська задача | count карток; джерело — Origin Creative |
DES |
Legacy-тип дизайну | Поводиться як VARDES, якщо є Origin; інакше як NEWDES |
LOC |
Локалізація (переклад під іншу мову) | Одна картка на кожен Source Creative; мова — цільова, решта метаданих з оригінала |
WHT |
Вайтенінг (версія під іншу платформу) | Одна картка на кожен Source Creative; Source — цільовий, ставиться is_whitened |
CLN |
Ручні картки | Автоматика не запускається |
VARAI |
AI-варіації | count карток, як VAR/VARDES (зазвичай проставляється креативницею — 4); Origin Creative обов’язковий, частина полів успадковується з нього |
ADPT |
Адаптація під інший сорс | Одна картка на кожен Source Creative, як LOC/WHT; Source — цільовий (з задачі), решта полів — з оригінального креативу |
RSZ |
Ресайз (зміна розміру, більше нічого) | Одна картка на кожен Source Creative, як ADPT; Resolution — цільовий (з задачі), решта полів — з оригінального креативу |
LOC/WHT/ADPT/RSZ — похідні задачі: беруть готові креативи (Source Creatives) і роблять
їхню локалізовану / відбілену / адаптовану / ресайзнуту версію. VAR/VARDES/VARAI — варіації
від одного оригіналу (Origin Creative).
Робочі напрями (Work Stream)
Performance, Product, Web, Brand, SMM, HR. Автоматизація обробляє лише
Performance; задачі інших напрямів ігноруються (задача повертається в backlog зі станом
warning).
Довідники
- Продукти (
product_id):PLT(PlantIn),CNI(CoinIn),RKI(RockIn),SPD(SportDex). - Media Type:
VID(відео),STA(статик),ANI(аніма/gif),WEB,LND(лендинг). - Resolution (розмір готового файлу):
1080x1920,1920x1080,640x768,800x800,320x50,1080x1350,250x250,300x250,336x280,200x200,300x600,1242x2688,1080x1080,600x800,800x600. - Source (рекламна платформа):
META,TIK(TikTok),APL(AppLovin),GGL(Google),SNA(Snapchat),SKN,TAB,UNT(Unity). - Language:
EN,FR,DE,ES,PT,UAтощо — локаль креативу; дляLOCкодує цільову мову (гео в назві не бере участі). - Design Complexity / Marketing Complexity — 1–5 кожна: складність задачі окремо для дизайну й для маркетингу. У Points іде лише Design Complexity. Priority — 1–3 (🔴/🟡/⚪).
- Modifier (на креативі, зазвичай проставляється вручну):
HK1/HK2/HK3(варіант хука),UC,AI,RSIZ(ресайз),CTA1/CTA2,PX. - Varied Element (що варіюється в задачі):
hook,cta,body_concept,background,hook_script,voice,music,persona.
Два незалежні статуси
У задачі є два окремі статус-поля, які не пов’язані між собою:
status(TaskStatus) — виробничий процес (від постановки задачі до здачі файлів).test_status(TestStatus) — стан у рекламному кабінеті (не тестувався / тестується / протестовано / на паузі).
Задача може бути done по виробництву і водночас tested у рекламі — це нормально. Автоматика
описана нижче реагує тільки на status. test_status змінюється звичайним оновленням задачі.
Статуси і потік
backlog → ready_for_production → in_progress → tech_check / creative_check → done → archived
| Статус | Група | Що відбувається |
|---|---|---|
backlog |
To-do | Задача створена, автоматика не запускається. |
ready_for_production |
To-do | Тригер автоматизації: валідація, створення креативів, папки Drive. Поки прогон іде — automation.state = pending і задача заморожена. Успіх лишає її тут чекати на виконавця, помилка повертає в backlog. |
in_progress |
In progress | Виконавець узяв задачу. Автоматично проставляється started_at. |
tech_check |
In progress | Технічна перевірка (NEW/VAR). Проставляється Assignee на креативи, submitted_at, Slack відповідальному за Tech Check. |
creative_check |
In progress | Креативна перевірка (NEWDES/VARDES). Те саме, плюс фолбек Assignee на Task Owner. |
done |
Complete | Файли здані. Проставляється completed_at, папка Drive переміщується. |
archived |
Complete | Задача закрита. Кінцевий стан. |
Дозволені переходи описані в ALLOWED_TRANSITIONS (kumquat_tasks/enums.py). З backlog є рівно
один вихід — ready_for_production: це той перехід, що запускає автоматизацію, і без нього немає ні
назви, ні папки Drive. Заборонено перестрибувати етапи (наприклад backlog → done); частина
зворотних переходів дозволена для правок (наприклад done → in_progress); archived — кінцевий,
виходів немає.
Як змінюється статус
Статус змінюється тільки через окремий ендпоінт:
PATCH /api/v1/tasks/{task_id}/status
{ "target_status": "ready_for_production" }
Поле status навмисно прибране зі звичайного PATCH /tasks/{id} — так зміна статусу завжди
проходить через перевірки й запускає побічні ефекти.
З того самого міркування — і на крок далі — started_at / submitted_at / completed_at немає
ні в TaskUpdate, ні в TaskCreate. Їх ставить сам воркфлоу: перше — перехід у in_progress,
решту — хендлери за перевірками, і пишуть вони через репозиторій, який цими схемами не проходить.
Тобто по HTTP їх не виставить ніхто — ні правкою, ні створенням задачі; клієнт, який їх усе одно
надішле, отримає те саме, що й зі status — поля просто проігнорують (extra="ignore"), а не 422.
У формі вони read-only.
TaskCreate тримає їх поза схемою окремо від TaskUpdate: новостворена задача сидить у backlog
і за визначенням ще нікуди не рухалася, тож дати старту, здачі й завершення в неї бути не може.
Виняток — легасі-імпорт (backend/scripts/backfill_creatives.py), який проставляє completed_at
з вивантаженої таблиці; він збирає Task напряму й через цю схему не йде.
Поля, які схема пропускає, все одно проходять через політику полів (розділ нижче): те, що
поле є в TaskUpdate, не означає, що його можна записати в будь-якому статусі.
Передумови входу в статус (REQUIRED_TO_ENTER)
Окрім графа переходів, статус може вимагати, щоб задача вже щось несла до моменту переїзду. Сьогодні їх два:
| Статус | Вимагає | Чому |
|---|---|---|
ready_for_production |
execution_complexity, creation_complexity |
Обидві навмисно не питаються при створенні (field_policy.DEFERRED_AT_CREATE) — бриф пишуть раніше, ніж оцінено обсяг. Тут відповідь уперше потрібна: кожна рахує свої власні Points, і _validate без обох прогон не запустить. |
in_progress |
assignee_id |
In Progress означає, що хтось робить роботу, тож задача мусить казати хто — інакше вона висить у колонці без відповідального, а on_tech_check пізніше розносить Assignee по Creative-картках і розносити нема чого. |
Перший пункт свідомо дублює те, що перевірить _validate, і робить це раніше: інакше задача
переїжджає, прогон падає й _revert вертає її в Backlog з банером — довгий шлях, щоб сказати
«заповніть два поля». Решту входів прогону так не піднято: вони питаються ще при створенні, тож
задача без них — вже крайній випадок, і його ловить прогон.
in_progress навмисно не повторює обидві Complexity, хоч і стоїть за Ready for Production:
кожен вхід у нього йде через двері RFP — прямий перехід або повернення з Tech / Creative Check, куди
інакше як через RFP не потрапити. Друга копія того самого правила лише дала б їм нагоду розійтися.
Ціна цього рішення відома: обидві Complexity лишаються редагованими до Creative Check
(PLANNING_FIELDS), а required_fields віддається формі, а не перевіряється на записі — тож
PATCH з явним null їх таки очистить, і задача доїде в In Progress без них. Наслідок видимий, але
не фатальний: points_for_level без відповідної складності віддає None лише для тієї сторони —
Execution Points чи Creation Points у картки просто немає, а форма показує рядок як обов’язковий
незаповнений.
Це не політика полів: field_policy.py відповідає, що можна записати в статусі, який задача
вже тримає, а це — умова прибуття. assignee_id лишається редагованим скрізь. Перевірка стоїть у
transition_status поряд з assert_transition_allowed, до будь-якого запису, тож відмова не лишає
ні рядка історії, ні напівзастосованого стану. Область та сама, що й у графа — тільки Performance.
Відмова — 409 MissingForStatusError з полем у тексті. Але доходити до неї не мало б: матриця
віддається в status_schema.required_to_enter (GET /tasks/schema і meta.status_schema), тож
селект статусу гасить пункт, а колонка дошки не приймає картку — та сама причина, з якої віддається
граф переходів, і те саме правило, не друга його копія. Клієнт, який отримав відповідь без цього
поля, не блокує нічого: сервер відмовить у будь-якому разі.
drive_url у Performance-задачі теж пише тільки автоматизація — разом із drive_folder_id,
який жодна форма подати не може. Спроба виставити його руками (у POST /tasks чи в
PATCH /tasks/{id}) відхиляється — DriveLinkNotEditableError, 422. Перевірка стоїть після
дифу зі збереженою задачею, тож клієнт, який просто відлунює вже записане посилання, помилки не
дістає; очищення (null) теж дозволене — це не вказівка на папку. Решта воркстрімів
автоматизацію не запускає, тому для них поле лишається звичайним редагованим.
Обробка ділиться на дві частини.
Синхронна (в межах запиту, мілісекунди):
- Перевірка переходу через
assert_transition_allowed. Недозволений перехід →409. - Запис нового статусу. Для
in_progressпроставляєтьсяstarted_at(лише якщо ще порожній). Для статусів із побічними ефектамиautomation.stateставиться вpending. - Запис рядка в колекцію
task_transitions(аудит: звідки, куди, хто, коли). - Для статусів
ready_for_production,tech_check,creative_check,done— постановка Celery-задачіtasks.transitionу чергу (Redis).
Асинхронна (Celery-воркер, секунди):
Воркер (worker/tasks.py::run_task_transition) створює клієнтів (Mongo, Slack, Drive) через
services/integrations/factory.py, дістає задачу і викликає відповідний обробник у
TaskWorkflowHandlers. Обробники ніколи не падають через помилку Slack/Drive — такі збої
логуються, а не зупиняють процес.
Ланцюжок kumquat_core не імпортує kumquat_api: постановку задачі в чергу робить залежність
get_transition_enqueuer на рівні роута (той самий підхід, що й у графах — get_run_enqueuer).
Політика полів (field_policy.py)
Одна декларація відповідає на три питання про поле — редаговане / обов’язкове / сховане — для
пари «стан задачі × хто питає». Її читають три місця: TasksService.update_task (enforcement),
TaskWorkflowHandlers._validate (обов’язковість перед прогоном) і API, який віддає її фронтенду.
До цього правило «naming-поля Performance-задачі редагуються лише в Backlog» існувало тільки на
фронтенді. PATCH /api/v1/tasks/{id} з {"product_id": "CNI"} на задачі в done приймався, і з
цього моменту generated_name, назви карток креативів і папка {Product}/{TaskName} описували щось
інше — без жодної помилки. Будь-яка інтеграція чи curl обходили замок повністю.
Групи полів
Кожне записуване поле TaskUpdate належить рівно одній групі. Це перевіряється тестом
(set(TaskUpdate.model_fields) == ALL_WRITABLE_FIELDS у tests/tasks/test_field_policy.py), тому
нове поле без групи — падіння тесту, а не тихо відкрите поле в кожному статусі.
| Група | Поля | Чому |
|---|---|---|
NAMING_FIELDS |
name, work_stream, task_type, product_id, media_type, resolution, source, geo, language, count |
входять у build_task_name, назви карток і папку Drive |
LINEAGE_FIELDS |
origin_creative_id, source_creative_ids, legacy_remap_list |
з яких оригіналів будуються картки |
TAXONOMY_FIELDS |
subject, topic, subtopics, primary_anchor, audience, awareness_stage, messaging_angle, hook_trigger, hook_type, ad_format, notes, traffic_flow, landing, funnel, cpp, varied_elements, manual_launch |
копіюються на картки в момент прогону, у назву не входять |
PLANNING_FIELDS |
priority, task_owner_id, assignee_id, deadline_at, execution_complexity, creation_complexity |
автоматизація їх для назви не читає (кожна рахує лише свої власні Points) |
ALWAYS_EDITABLE_FIELDS |
test_status, var_description |
відповідь із кабінету і нотатки — не входи прогону |
AUTOMATION_OWNED_FIELDS |
drive_url |
Performance: пише тільки автоматизація, у жодному статусі |
Матриця
EDITABLE_BY_STATUS, для work_stream = Performance:
| Статус | NAMING | LINEAGE | TAXONOMY | PLANNING | ALWAYS |
|---|---|---|---|---|---|
backlog |
✓ | ✓ | ✓ | ✓ | ✓ |
ready_for_production |
— | — | ✓ | ✓ | ✓ |
in_progress |
— | — | ✓ | ✓ | ✓ |
tech_check |
— | — | ✓ | ✓ | ✓ |
creative_check |
— | — | ✓ | ✓ | ✓ |
done |
— | — | — | — | ✓ |
archived |
— | — | — | — | ✓ |
Поверх матриці — оверлеї, перший збіг виграє (editable_fields):
- роль нижче
creator→ нічого; deleted_atзаповнений → нічого (вихід один — restore);Task.automation_running→ нічого (ширше за будь-який рядок: хендлер будує зі знімка);work_stream != Performance→ все (автоматизація не запускається, розсинхронювати нічого);- інакше — рядок матриці.
locked_fields() — доповнення до editable_fields(), де кожне поле несе причину (LockReason:
status / role / deleted / automation_running / automation_owned). Причина потрібна UI:
«автоматизація володіє цим полем» і «поверни задачу в Backlog» — різні тексти, і друге для
drive_url було б тупиком.
Enforcement
TasksService._assert_fields_editable викликається в update_task після дифу зі збереженою
задачею, поруч з _assert_drive_link_editable: клієнт, що відлунює незмінене значення, нічого не
редагує, і taskUpdateDelta на фронті теж посилає лише те, що зрушило. Судиться проти збереженої
задачі, не проти payload — status через цю схему не ходить, а work_stream сам лежить у
NAMING_FIELDS, тож поза Backlog його не зрушити.
Порушення → TaskFieldLockedError → 409 з переліком полів і статусом, у який треба перемістити
задачу. Помилка гучна навмисно: тихо відкинутий ключ лишає інтеграцію в переконанні, що запис
відбувся.
Замок «прогон іде» тут не питається — він стоїть усередині самого запису
(TasksRepository.update_unless_automation_running), де читання і перевірка не можуть розійтися.
Обов’язковість і видимість
required_fields(work_stream, task_type, has_legacy_remap=...) — те, що _validate перевіряє перед
прогоном, і те, що форма малює зірочкою. Одне джерело замість двох: форма раніше не позначала
execution_complexity / creation_complexity, і задача без них їхала в ready_for_production, щоб за
секунду вернутись у backlog з Missing required fields.
number у наборі навмисно немає, хоч _validate його й перевіряє: його призначає
next_task_number на створенні, тож зірочка стояла б на полі, яке ніхто не може заповнити.
count і поля походження лишають собі власні повідомлення ("Count is empty or 0",
"No origin creative") — «missing» про них сказало б менше.
hidden_fields(work_stream, task_type) — TAXONOMY_FIELDS без varied_elements для
LOC/WHT/ADPT/RSZ у Performance: ці типи будують картку з таксономії оригінального креативу,
тож редагувати їх на задачі нічого не дає. Тест стежить, щоб схований набір не перетинався з
обов’язковим — обов’язкове поле, якого форма не малює, це блокер без виходу.
Що віддається клієнту
| Ендпоінт | Що | Для чого |
|---|---|---|
GET /tasks/{id} → meta.field_policy |
TaskFieldPolicy: locked (поле → причина), required, hidden, unlock_status |
розв’язано для цієї задачі й цього викликача (роль читається з того ж клейма, що й enforce_write_role) |
GET /tasks/schema → field_policy |
TASK_FIELD_POLICY_SCHEMA — уся матриця |
форма перерозв’язує локально, коли чернетка сидить на статусі, якого відповідь вище ще не бачила |
Друге не дублює перше: обидва генеруються з тієї самої декларації, як TASK_STATUS_SCHEMA віддає
ALLOWED_TRANSITIONS. Матриця потрібна саме тому, що статус у чернетці змінюється раніше, ніж
приходить перечитування, — без неї користувач має кадр, у який можна друкувати в поле, що вже
закрите. Усі осі перелічені (по типу задачі, з "" для «ще не обрано»), щоб клієнт робив пошук по
таблиці, а не виводив правило заново — виведене правило і було б другою копією.
Фронтенд-половина — frontend/src/modules/tasks/model/task-field-policy.ts: resolveFieldPolicy()
(пошук по матриці) плюс мапа camelCase ⇄ snake_case, бо форма ключує рядки по-своєму. Своїх правил
там немає; canEditPerformanceFields, lockedSideKeys, HIDDEN_FOR_MULTI_ORIGIN і дзеркало
requiredEntries прибрані.
Автоматизація (run_automation)
Запускається при переході в ready_for_production. Кроки:
- Якщо
work_stream != Performance— хендлер ставитьautomation.state = okі виходить, статусу не торкаючись. Автоматика обробляє лише Performance. - Для
VAR/VARDES/VARAI— успадкування порожніх полей таксономії з задачі оригінального креативу (_inherit_from_origin): якщо в задачі поле порожнє, а в оригінала є — копіюється. - Валідація обов’язкових полів — набір бере
field_policy.required_fields()(розділ «Політика полів»), плюсnumber, який перевіряється тут:product_id,task_type,media_type,execution_complexity,creation_complexity,resolution,source.languageіgeoобов’язкові теж, але не дляWHT/ADPT/RSZ(GEO_LANGUAGE_EXEMPT_TASK_TYPES): ці типи варіюють лише одне поле (SourceдляWHT/ADPT,ResolutionдляRSZ— той самийresolution, що обов’язковий уже для всіх типів вище), а мову й гео беруть з оригінала.sourceнатомість обов’язковий завжди —Task.sourceмає дефолт (Source.META), тож порожнім не буває. Для типів із лічильником карток (NEW,VAR,NEWDES,VARDES,DES,VARAI,CLN) —count > 0. ДляLOC/WHT/ADPT/RSZ— має бути хоч одинsource_creative. ДляVAR/VARDES/VARAI— має бути заданийOrigin Creative(або legacy remap, що на нього резолвиться) — інакше помилкаNo origin creative. - Захист від дублів — дві незалежні перевірки: якщо для задачі вже є креативи — помилка (щоб не створити дублікати при повторному запуску); якщо серед похідних карток оригінала вже є така сама — теж помилка (розділ «Дублікати варіантів»).
- Побудова
Task Name, створення креативів (розділ «Неймінг»). - Створення папки в Google Drive.
- Оновлення задачі:
generated_name,drive_url,drive_folder_id,automation.state = ok. Статус не пишеться — задача вже вready_for_production. - Для
VAR/LOC/WHT/ADPT/RSZ/VARAI— Slack Task Owner про успіх.
Якщо на будь-якому кроці помилка — задача повертається в backlog (compare-and-set проти
ready_for_production, щоб не затерти ручне переміщення), automation.state = error з описом, і
Task Owner отримує Slack.
Дублікати варіантів (LOC/WHT/ADPT/RSZ)
Похідна задача не має вдруге робити те, що для цього оригінала вже зроблено: другу локалізацію на ту
саму пару Language+Geo, другий вайтенінг чи адаптацію на той самий Source, або другий ресайз
у той самий Resolution. Вирішує це find_duplicate_variant (creative_factory.py) — по вже
наявних похідних картках оригінала (CreativesRepository.list_variants, запит по origin_ref._id).
Вісь порівняння повторює те, що картка насправді варіює:
| Task Type | Дублікат, якщо в оригінала вже є картка з тим самим |
|---|---|
LOC |
Language і Geo |
WHT |
Source |
ADPT |
Source |
RSZ |
Resolution |
RSZ звіряється по Resolution, не по Aspect Ratio: піксельний розмір — це те, що ресайз
насправді змінює, а дві RSZ-картки можуть ділити один Aspect Ratio і при цьому бути різного
розміру (800x800 і 250x250 — обидва 1x1). resolution = None ніколи не збігається сам із
собою — на відміну від Source, він не має дефолту на рівні CreativeVariant (лише на Task),
тож старі RSZ-сусіди, зняті до появи цього поля, можуть нести None; без цього застереження вони
позначались би дублікатами цілі, якої насправді ще нема.
RSZ додатково: якщо цільовий Resolution збігається з тим, який оригінал уже має сам —
запит відхиляється так само, як дублікат, тільки звірка йде не з сусідньою карткою, а з самим
джерелом (TasksService._assert_no_duplicate_variants / TaskWorkflowHandlers._reject_duplicate_variants).
Інакше ресайз у той самий розмір минав би цю перевірку, бо в оригінала ще нема жодної RSZ-картки,
з якою звіряти.
Порівнюються лише картки того самого Task Type — WHT-картка ніколи не вважається дублікатом для
LOC. Власні попередні картки задачі виключаються (exclude_task_id), тож повторне збереження
задачі з тими самими значеннями дублікатом не є.
Перевірка стоїть у двох місцях, і це не дублювання:
- Рання, у
TasksService— на створенні й апдейті задачі. Бачить лише явніsource_creative_ids, зате відповідає синхронно:DuplicateVariantError→409наPOST /api/v1/tasksіPATCH /api/v1/tasks/{task_id}. - Авторитетна, у
run_automation(_reject_duplicate_variantsуhandlers.py) — після_source_origins, тобто коли набір оригіналів уже повний, включно з тими, що прийшли через legacy remap і синхронно не резолвляться. Помилка тут — звичайнийWorkflowError: задача вертається вbacklogзіautomation.state = error.
Фронтенд читає той самий набір карток через GET /api/v1/creatives/variants?origin_ids=... (до 50
оригіналів за запит), щоб показати конфлікт до сабміту, а не після.
Неймінг
Реалізовано в creative_factory.py. Дефолти не приховуються — сегмент зі значенням показується
завжди. Локаль кодується парою Geo+Language в кінці назви; ці два сегменти ходять разом і
є єдиними необов’язковими: для WHT/ADPT/RSZ задача їх не має (успадковуються з оригінала), і
тоді вони просто відсутні, а не підставляються літералом None.
Source не входить у жодну назву, Resolution — тільки в назву креативу. Задача називається
тим, що вона замовляє; розмір готового файлу — властивість того, що з неї виходить, тож він
належить картці. Саме тому назва креативу більше не є назвою задачі з префіксом: два шаблони
збігаються головою і різняться одним сегментом, і пишуть їх дві функції — build_task_name та
build_creative_name.
Цим сегментом раніше був Aspect Ratio, і він називав форму, а не файл: саме лише 1x1 покриває
800x800, 250x250 і 200x200, тож три картки різного розміру планували три однакові назви, і
дві з них відхиляв _reject_planned_collisions. Resolution — це сам розмір, тож назва розрізняє
те, чого співвідношення сторін розрізнити не могло.
Уже видані назви не переписуються. Назва креативу — це те, як його називає рекламний кабінет
і на що зав’язаний кожен рядок аналітики нижче за течією, тож сегмент _9x16_ на старих картках
лишається як є; новий сегмент діє для назв, що будуються від цього моменту. Перепланувати назви
задач, які ще не пішли в роботу (Backlog, Ready for Production), уміє
backend/scripts/rename_to_new_naming_scheme.py.
Task Name:
{Task#}_{Product}_{TaskType}_{MediaType}[_{Geo}_{Language}]
52_CNI_NEW_VID_US_EN
Креатив, звичайний (NEW, VAR, NEWDES, VARDES, DES, VARAI):
{Variation#}-{Task#}_{Product}_{TaskType}_{MediaType}_{Resolution}[_{Geo}_{Language}]
1-52_CNI_NEW_VID_1080x1920_US_EN
Кількість карток = count задачі. Метадані копіюються з задачі, Assignee при створенні не
проставляється (його ставлять пізніше на Tech/Creative Check). VARAI — те саме, плюс
успадкування кількох полів з оригінала (розділ «Автоматизація», крок 2): один Origin Creative,
count карток проставляє креативниця (зазвичай 4).
Креатив LOC/WHT/ADPT/RSZ (Case A — оригінал є карткою):
{Variation#, від оригіналу}-{Task#, від оригіналу}_{Product}_{LOC|WHT|ADPT|RSZ}_{MediaType}_{Resolution}_{Geo}_{Language}
1-45_CNI_LOC_VID_1080x1920_FR_FR ← LOC: Geo+Language → цільова локаль; Resolution — оригіналу
1-45_CNI_WHT_VID_1080x1920_US_EN ← WHT: Geo+Language і Resolution з оригіналу; платформа в назву не входить
1-45_CNI_ADPT_VID_1080x1920_FR_FR ← ADPT: решта — з оригіналу; платформа в назву не входить
1-45_CNI_RSZ_VID_800x800_US_EN ← RSZ: Resolution → цільовий розмір, решта — з оригіналу
Одна картка на кожен source_creative. Variation#/Task# у назві беруться від оригінального
креативу (для трасування), Task Type — власний (LOC/WHT/ADPT/RSZ, а не тип оригінала).
Product, Media Type — з задачі. Assignee/Task Owner — з оригінала. Заповнюється origin_ref
на оригінал.
Resolution у Case A — оригіналу, а не задачі (_delivered_resolution у
creative_factory.py). LOC/WHT/ADPT пікселів не міняють: локалізація, вайтенінг і адаптація —
той самий файл того самого розміру, тож розмір подорожує з оригіналом так само, як Geo+Language
для WHT/ADPT. Одна задача може нести кілька оригіналів різного розміру, тому це читається на
кожну картку, а не з єдиного поля задачі. Task.resolution лишається фолбеком для оригінала, який
свого розміру не має (легасі-картка або знята до появи поля) — без нього сегмент просто зник би з
назви. RSZ — єдиний тип, який бере розмір із задачі, бо зміна розміру і є його роботою; тому
Resolution питається (і показується у формі) лише в нього — RESOLUTION_EXEMPT_TASK_TYPES у
kumquat_tasks/enums.py, field_policy.required_fields/hidden_fields.
Якщо оригінал сам є scratch-карткою (NEW/VAR/…), Variation#/Task# беруться з її власних
полів (variation_num, номер її задачі). Якщо ж оригінал сам є нелегасі похідною карткою
(LOC/WHT/ADPT/RSZ, побудованою за тим самим правилом раніше) — своїх variation_num вона не має, і
пара Variation#-Task# парситься назад із префіксу її власної назви (_borrow_prefix у
creative_factory.py). Оскільки цей префікс зберігається незмінним через увесь ланцюжок, це працює
на будь-якій глибині (NEW → LOC → WHT → ADPT → RSZ → …), поки кожна ланка нелегасі.
LOC/WHT беруть з оригінала Resolution і, залежно від типу, Source або пару
Geo+Language (сам Source у назві не з’являється, але лишається полем картки) — решта
таксономії (funnel, hypothesis, subject, …) йде з полів задачі. ADPT і RSZ ідуть далі: з задачі
береться тільки одне поле — Source для ADPT (ціль адаптації, теж не в назві), Resolution
для RSZ (ціль ресайзу — і цей сегмент, на відміну від Source, у назві лишається) — а весь
інший контент, включно з Language/Geo та (для ADPT) розміром, копіюється з оригінального
креативу. WHT додатково
ставить is_whitened = true; ADPT/RSZ цей прапорець не ставлять.
Креатив LOC/WHT/ADPT/RSZ (Case B — legacy-оригінал, або нема що позичити):
{OriginName}_{Geo}_{Language} (LOC) | {OriginName}_{Source} (WHT/ADPT) | {OriginName}_{Resolution} (RSZ)
CoinIn_3150_9256_Var_Vid_9x16_Sell_FR_FR ← LOC: локаль допискою в кінець
CoinIn_legacy_wht_APL ← WHT/ADPT: цільова платформа допискою в кінець
CoinIn_legacy_rsz_800x800 ← RSZ: розмір допискою в кінець
Якщо оригінал — легасі (origin.is_legacy, задача з is_legacy=true) або з його назви взагалі
неможливо розпізнати {Variation#}-{Task#} — нова назва не перебудовується, а просто дописує в
кінець існуючої назви оригіналу ту вісь, яку задача змінює: Geo+Language для LOC,
Source для WHT/ADPT, Resolution для RSZ. Source у структурованій назві (Case A) не
з’являється — але Case B структурованою назвою і не є: у ній немає й сегмента TaskType, тож без
дописки вона була б назвою оригіналу без змін, і кожен вайтенінг чи адаптація легасі-оригінала
стикався б із карткою, з якої робиться. Resolution, на відміну від Source, лишається сегментом
і в структурованій назві (Case A) — тож RSZ дописує його в Case B з тієї самої причини, з якої він
там лишається: без нього два RSZ від одного легасі-оригінала до різних розмірів спланували б
однакову назву. is_legacy завжди переважає Case A, навіть якщо в легасі-оригінала є заповнені
variation_num/номер задачі — вони проставлені бекфілом для фільтрації, а не для запозичення в
нову назву.
Міграція вже згенерованих назв. Назви, які автоматизація опублікувала до зміни конвенції,
переписує backend/scripts/rename_to_new_naming_scheme.py (за замовчуванням dry-run, пише з
--apply). Він бере тільки задачі в Backlog і Ready for Production — саме вони ще нічого не
опублікували назовні: картки з’являються на Done, а рекламні кабінети й уся аналітика нижче
пишуться проти назв карток. Для кожної задачі він перебудовує generated_name і
planned_creative_names, переносить галочки planned_creatives_done, перейменовує папку на Drive
і файли в ній (gdrive-sync зв’язує файл із карткою за точним stem). Задачу, чий новий план уже
зайнятий, скрипт пропускає цілком і називає в звіті — назву їй треба дати руками. Скрипт
ідемпотентний: другий запуск нічого не пише.
Дві
WHT- (чи двіADPT-) задачі від одного нелегасі-оригінала на різні платформи планують один рядок. Структурований шаблон Case A платформи не називає, аTaskTypeу них однаковий — різниці в назві не лишається. Такий план відхиляється_reject_planned_collisionsна Ready for Production (задача повертається в Backlog з поясненням), а не мінтить дублікат; другій потрібна назва руками. Легасі-оригіналів це не стосується — Case B дописуєSource.
CLN автоматично картки не генерує.
Task #. Назва завжди починається з номера задачі. Номер призначається автоматично при створенні
задачі (TasksRepository.next_task_number) — глобальний атомарний лічильник у колекції counters
(документ _id: "task_number"), який при першому використанні стартує від поточного максимуму.
Якщо number вже задано (наприклад, із синку) — не перезаписується.
Призначення виконавця (tech_check / creative_check)
tech_check(NEW/VAR):Assigneeзадачі проставляється на всі креативи задачі,submitted_at, Slack відповідальному за Tech Check.creative_check(NEWDES/VARDES): те саме; якщоAssigneeзадачі порожній — беретьсяTask Ownerі записується вAssigneeзадачі. Slack іде Task Owner тільки якщо він не збігається з Assignee.LOC/WHT/ADPT/RSZ:Assigneeне чіпається — атрибуція збережена з оригінала ще при створенні.
Done (on_done)
- Якщо
completed_atпорожній — проставляється (один раз, не перезаписується). - Якщо
manual_launch = true— Slack у UAM-канал. Папка Drive нікуди не переміщується: вона лишається там, де її створила автоматизація.
Slack
Транспорт — kumquat_tasks/notifications.py. WorkflowNotifier містить таблицю
маршрутизації.
| Подія | Кому |
|---|---|
| Помилка валідації / скрипту | Task Owner (DM) |
| Автоматизація успішна (VAR/LOC/WHT/ADPT/RSZ/VARAI) | Task Owner (DM) |
| Tech Check | Відповідальний за Tech Check (DM) |
| Creative Check | Task Owner (DM), якщо він не Assignee |
| Призначення на задачу зі статусом Ready for Production | Новий Assignee (DM) |
| Done + Manual Launch | UAM-канал |
| Немає отримувача | Фолбек у загальний CREATIVE-канал |
Task Owner і Assignee резолвляться по email: Clerk-id → email (колекція users) →
users.lookupByEmail у Slack → DM. Відповідальний за Tech Check задається напряму як Slack
user id (SLACK__TECH_CHECK_USER_ID), без резолву по email.
Єдина подія в цій таблиці, що не прив’язана до переходу статусу — призначення виконавця. Її
пильнує TasksService.update_task (kumquat_tasks/service.py): якщо PATCH /tasks/{id} міняє
assignee_id на нове значення, поки задача вже стоїть у ready_for_production, сервіс ставить у
Celery-чергу tasks.io.task_assigned (kumquat_api/worker/tasks.py). Воркер перечитує задачу
заново перед відправкою — якщо її вже перепризначили ще раз або вона встигла зрушити зі статусу,
сповіщення тихо не йде.
Формат повідомлення. Кожне сповіщення — один Block Kit блок card (task_message у
kumquat_tasks/notifications.py):
| Поле картки | Вміст | Ліміт |
|---|---|---|
title |
назва задачі (display_name), обгорнута в посилання |
150 |
subtitle |
суть події — перший рядок тексту | 150 |
body |
факти по два в рядок: статус, виконавець, дедлайн, продукт, медіа, тип | 200 |
subtext |
опційна деталь — решта тексту після першого рядка (цитата коментаря) | 200 |
52_CNI_NEW_VID_US_EN ← клікабельна назва
🔍 Ready for Tech Check
*Status* 🔧 Tech Check · *Assignee* Denys Koval
*Product* CNI · *Media* VID
*Type* NEW
Заголовок веде на {APP_BASE_URL}/tasks/{id} і є єдиним переходом. Окремої кнопки картка не
має: посилання в заголовку читається і там, де кнопки не видно — у прев’ю пуш-нотифікації, у
вузькому мобільному клієнті, у згорнутій картці в треді.
Статус — тег, як на дошці. На фронтенді статус це підфарбована іконка плюс лейбл
(optionIcons поля status). Кольорового фону всередині тексту Slack не має, тож тег
переїжджає як емодзі, найближче до тієї іконки, зі збереженим кольором там, де він є:
| Статус | Тег | Статус | Тег |
|---|---|---|---|
| Backlog | 🟠 Backlog | Creative Check | 👀 Creative Check |
| Ready for Production | 🔵 Ready for Production | Done | ✅ Done |
| In Progress | 🔄 In Progress | Archived | 📦 Archived |
| Tech Check | 📋 Tech Check |
Кружечок беруть лише ті два статуси, які й на дошці намальовані кружечками (Backlog і Ready for Production). Решта — емодзі: у Slack немає кружечка на кожен відтінок, яким фарбує дошка, і рожевий Creative Check, округлений до найближчого, злився б з іншим статусом в один тег. Статус без запису в таблиці друкується як є, а не ламає повідомлення.
Назву задачі вводять руками, тож перед вставкою в <url|назва> вона екранується (&, <, >)
і обрізається під бюджет 150 - len(url) - 3 — незакритий > обірвав би посилання й вивалив
сирий URL у текст.
body наповнюється, поки вкладається в 200 символів: рядки додаються попарно й обриваються
цілими, тому довге ім’я виконавця прибирає Type, а не ріже значення посередині.
Порожній APP_BASE_URL означає «без переходу»: заголовок лишається звичайним текстом. Це
свідомо тихо — краще сповіщення без посилання, ніж зламане.
Кожна подія має свій колір — смужка ліворуч від картки:
| Подія | Колір |
|---|---|
| Помилка автоматизації | 🔴 червоний |
| Автоматизація успішна | 🟢 зелений |
| Legacy remap / Manual Launch | 🟡 жовтий |
| Tech / Creative Check / призначення на Ready for Production | 🔵 синій |
| Згадка в коментарі | 🟠 помаранчевий |
Кольорова смужка існує лише в attachments, тому кольорове повідомлення кладе блоки всередину
attachment, а прев’ю — у його fallback. Верхньорівневий text при цьому не надсилається: поруч
з attachment Slack його рендерить, і повідомлення дублювалося б.
Колір задається на рівні транспорту, а не блоків: task_message його не приймає, смужку навколо
блоків малює _payload у kumquat_core/integrations/slack.py.
Імʼя виконавця резолвиться з дзеркала users за Clerk-id (одне читання на сповіщення); якщо
виконавця немає — рядок фактів його просто не містить.
Поруч із блоками завжди йде плоский текст ("{назва}\n{подія}") — Slack показує його в
превʼю сповіщення і там, де блоки не рендеряться.
APP_BASE_URL — публічний URL фронтенду, різний для кожного середовища (локально
http://localhost:3000, у кластері задається через config.appBaseUrl у чарті
kumquat-celery-worker — саме він виконує обидва Celery-таски, що шлють у Slack).
Якщо змінна порожня — повідомлення йде без переходу, а не з битим посиланням.
Сповіщення про згадку в коментарі має той самий вигляд і додатково цитує сам коментар (обрізаний до 280 символів).
Google Drive
Клієнт — kumquat_tasks/drive.py. Структура спирається на одну батьківську папку в Shared Drive
(DRIVE__ROOT_FOLDER_ID), усередині — {Product}/{TaskName}.
- Автоматизація:
{Product}/{TaskName}(find-or-create, тому повторний прогон перевикористає ту саму папку). - Done: папка лишається на місці — переміщень немає.
Воркфлоу тільки створює папки, ніколи не переміщує й не видаляє. Сервіс-акаунту достатньо прав content-manager у Shared Drive.
Конфігурація
Змінні середовища (nested delimiter __):
APP_BASE_URL
SLACK__ENABLED
SLACK__BOT_TOKEN
SLACK__CREATIVE_CHANNEL
SLACK__UAM_CHANNEL
SLACK__TECH_CHECK_USER_ID
SLACK__BOT_DISPLAY_NAME
SLACK__BOT_ICON_URL
DRIVE__CREDENTIALS_JSON
DRIVE__ROOT_FOLDER_ID
Якщо SLACK__BOT_TOKEN не задано — використовується LoggingNotifier (повідомлення лише в лог).
Якщо DRIVE__CREDENTIALS_JSON не задано — NoopDriveClient (операції з папками лише в лог).
Це дозволяє ганяти весь воркфлоу локально без реальних інтеграцій.
SLACK__ENABLED — вимикач для випадку, коли блок заповнений повністю. false лишає
LoggingNotifier попри наявний токен; за замовчуванням true, тож прод нічого додавати не
мусить. Потрібен там, де секрет спільний з продом: dev тягне ті самі SLACK__* з
ml/dev/kumquat-secrets, і без прапорця вибір був би між «вирізати чотири значення із
секрету» та «слати DM живим людям». Вимкнений notifier усе одно бачить creative_channel,
uam_channel і tech_check_user_id, тому в лог іде й маршрут, який повідомлення отримало б —
на відміну від повністю знятого блоку, де ці id порожні.
Прапорець сам по собі блоку не створює: SLACK__ENABLED без решти SLACK__* — це та сама
помилка «Field required» на старті, що й будь-яке інше часткове заповнення.
Слати в Slack можуть лише воркери (kumquat_api/worker/tasks.py) — API тільки ставить задачу
в чергу, тому змінна живе в configmap чартів kumquat-worker і kumquat-celery-worker
(config.slackEnabled), а не в kumquat-api. У values-dev.yaml обох чартів вона вже
виставлена в "false".
SLACK__BOT_DISPLAY_NAME / SLACK__BOT_ICON_URL — опційні, за замовчуванням порожні. Задані —
підмінюють ім’я й аватар відправника на кожному повідомленні (username/icon_url у
chat.postMessage, SlackNotifier у kumquat_core/integrations/slack.py); порожні — лишають
брендування самого Slack-застосунку. Підміна працює лише за наявності скоупу
chat:write.customize на SLACK__BOT_TOKEN — без нього Slack мовчки її ігнорує, без помилки.