Експорт таблиць у Google Sheets
Спільні словники підписів, обхід фіду, Celery-задача і межі Drive.
Що бачить користувач — у Вигрузка креативів і задач. Тут про те, звідки беруться підписи колонок і чому експорт живе у воркері.
Спільний словник
Підписи enum-ів і специфікація колонок лежать у dictionaries/ у корені репозиторію —
одне джерело для фронтенду й бекенду:
| файл | що містить |
|---|---|
enum-labels.json |
27 мап значення → підпис (in_test → In Test) |
creative-columns.json |
95 колонок креатива: key, label, section, path, kind |
task-columns.json |
50 колонок задачі |
path — це шлях у документі (vlm.hook.marketing_angles), kind — як значення стає
клітинкою (rate, usd, enumList, durationMs, richtext, …).
Обидва контексти Docker-збірки (./frontend, ./backend) не бачать кореня репозиторію,
тому кожне дерево тримає закомічену копію:
frontend/src/core/dictionaries/— читаєrowValue/rowFromColumns, з яких будуються рядки таблиць (creative-mapping.ts,task-mapping.ts);backend/packages/kumquat-vocabulary/src/kumquat_vocabulary/data/— читаєrender.cell, з якого будуються клітинки експорту.
Копії пише make dictionaries; make check-dictionaries порівнює їх побайтово і саме це
робить CI (.github/workflows/dictionaries.yaml). Редагувати треба оригінал у
dictionaries/.
Що це дає: колонка, яку показує таблиця, і колонка, яку віддає експорт, — та сама колонка з тим самим заголовком. Раніше підписи існували лише у фронтенді (~2500 рядків мап у чотирьох файлах), і бекенд просто не мав чим їх віддати.
Два тести тримають словник від дрейфу:
tests/api/test_field_path_contracts.py— коженpathрезолвиться на реальній Pydantic-моделі (Creative,Task);frontend/src/core/dictionaries/index.test.ts—labelіsectionкожної колонки збігаються зFieldDefтого ж поля.
Плюс satisfies Record<TaskStatus, string> у task-mapping.ts: статус, доданий до енама
або зникнувши зі словника, ламає збірку фронтенду.
Чого у словнику свідомо немає
Поле, на яке експорт не може відповісти, до словника не входить, і resolve_columns
відхиляє такий ключ замість того, щоб віддати колонку прочерків. Це, зокрема, роллапи, які
сервер обчислює на кожне читання (variants, points картки), і tags/description
задачі — вони живуть на TaskVariant, а не на Task.
Обхід фіду
ExportRows (kumquat_api/exports/rows.py) ходить курсором по тому самому запиту, що й
список: CreativesRepository.find_page для креативів, TasksRepository.find_full_page
для задач — окремий метод, бо фід дошки трімить документи до TaskCard, а експорт пише
цілі документи.
- Сторінка — 500 документів (
PAGE_SIZE); на кожній батчем резолвляться люди (user-колонки). - Документи валідуються моделлю, а не читаються сирими: інакше поля з ненульовим дефолтом
(
test_status,resolution,source) виходили б порожніми там, де API показує дефолт. - Імʼя задачі підмінюється на
display_name— те саме, що робить респонс-шар (_for_displayуroutes/tasks.py), інакше експорт віддавав би технічну назву картки. - CSV пишеться на диск потоково, зі
flushпісля кожної сторінки.
query, sort, search і task_id передаються з клієнта дослівно — це параметри самого
фіду, тому експорт не має другого визначення фільтра.
Черга і нотифікація
POST /api/v1/exports/{creatives,tasks} рахує рядки, віддає 202 з {entity, rows, columns}
і ставить tasks.io.export_sheet у чергу io. Ліміти часу підняті до 1800/1740 с — як у
execute_graph, бо загальні 600 с на повну вигрузку не розраховані.
Воркер (build_export у kumquat_api/worker/tasks.py) пише CSV у тимчасовий файл,
віддає його в Drive, пише рядок аудиту export.created і надсилає Slack-DM з посиланням.
Помилка теж іде в DM — інакше людина чекала б вічно. Роль creator+ забезпечує глобальний
enforce_write_role: це POST, окремого гейта не потрібно.
Ендпоінт віддає 503, якщо брокер недоступний: нічого не записано, але й обіцяти вигрузку,
яку ніхто не підбере, не можна.
Drive
Файл створює сервісний акаунт, тому він і власник. Складається в
Exports/{імʼя користувача}/ під DRIVE__ROOT_FOLDER_ID і успадковує доступи цієї гілки —
жодного permissions.create на конкретного користувача, бо посилання, яке не можна
переслати, це не експорт.
Підпапка на людину — не про приватність (доступи однакові), а про те, що пласка папка за
місяць стає сотнею майже однакових назв. Імʼя папки — name, інакше email, інакше id;
слеші й контрольні символи вирізаються, довжина ріжеться на 64 (folder_name).
Назва файлу — {Entity|title} {YYYY-MM-DD HH:MM:SS} ({8 символів export_id}). export_id
мінтиться в ендпоінті, а не у воркері: task_acks_late + task_reject_on_worker_lost
означають, що вбитий под поставить задачу в чергу знову, і id із запиту дає ретраю ту саму
назву замість другого файлу-двійника. Дедуплікації в Drive немає свідомо — це зайвий виклик
на кожен експорт заради випадку, коли под убили посеред аплоаду.
Sheets API не задіяний: files.create з mimeType: application/vnd.google-apps.spreadsheet
і CSV-медіа — Drive конвертує сам. Один виклик і той самий скоуп, який уже є.
- Аплоад резюмабельний, чанками по 8 МБ: дефолт читає файл у памʼять цілком, а на одному
io-поді може йти вісім експортів одночасно. - Понад 95 МБ CSV Drive не конвертує — перевіряється до аплоаду (
CsvTooLarge), щоб людина отримала зрозумілу підказку, а не помилку Drive. - Нових секретів не треба:
DRIVE__*іSLACK__*уже є уkumquat-celery-worker.
Межі
ROW_LIMIT— 50 000 рядків на запит (kumquat_api/schemas/exports.py). Це не межа Sheets (там 10 млн клітинок), а страховка від вигрузки всієї колекції без фільтра.- Порожній результат відхиляється
422. - Форматування збігається з таблицею, крім двох випадків: порожнє поле — порожня клітинка
(а не
—, який сортувався б серед значень), іdatetimeбезT, щоб Sheets прочитав його як дату. Гроші й ставки лишаються рядками ($1,235,1.2%) — Sheets парсить їх назад у числа. - Кожна вигрузка — новий файл; прибирання старих не автоматизоване.
export_idпишеться і в рядок аудиту, і в назву файлу — по ньому вигрузку можна звести з тим, хто і за яким фільтром її замовив.