Skip to content
Kumquat
Esc
↑↓navigate↵open⌘Jpreview
On this page

Воркфлоу задач

Статуси, переходи, автоматичне створення креативів, неймінг, сповіщення в 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) теж дозволене — це не вказівка на папку. Решта воркстрімів автоматизацію не запускає, тому для них поле лишається звичайним редагованим.

Обробка ділиться на дві частини.

Синхронна (в межах запиту, мілісекунди):

  1. Перевірка переходу через assert_transition_allowed. Недозволений перехід → 409.
  2. Запис нового статусу. Для in_progress проставляється started_at (лише якщо ще порожній). Для статусів із побічними ефектами automation.state ставиться в pending.
  3. Запис рядка в колекцію task_transitions (аудит: звідки, куди, хто, коли).
  4. Для статусів 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):

  1. роль нижче creator → нічого;
  2. deleted_at заповнений → нічого (вихід один — restore);
  3. Task.automation_running → нічого (ширше за будь-який рядок: хендлер будує зі знімка);
  4. work_stream != Performance → все (автоматизація не запускається, розсинхронювати нічого);
  5. інакше — рядок матриці.

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. Кроки:

  1. Якщо work_stream != Performance — хендлер ставить automation.state = ok і виходить, статусу не торкаючись. Автоматика обробляє лише Performance.
  2. Для VAR/VARDES/VARAI — успадкування порожніх полей таксономії з задачі оригінального креативу (_inherit_from_origin): якщо в задачі поле порожнє, а в оригінала є — копіюється.
  3. Валідація обов’язкових полів — набір бере 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.
  4. Захист від дублів — дві незалежні перевірки: якщо для задачі вже є креативи — помилка (щоб не створити дублікати при повторному запуску); якщо серед похідних карток оригінала вже є така сама — теж помилка (розділ «Дублікати варіантів»).
  5. Побудова Task Name, створення креативів (розділ «Неймінг»).
  6. Створення папки в Google Drive.
  7. Оновлення задачі: generated_name, drive_url, drive_folder_id, automation.state = ok. Статус не пишеться — задача вже в ready_for_production.
  8. Для 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)

  1. Якщо completed_at порожній — проставляється (один раз, не перезаписується).
  2. Якщо 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 мовчки її ігнорує, без помилки.

Was this page helpful?