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

Експорт таблиць у 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 пишеться і в рядок аудиту, і в назву файлу — по ньому вигрузку можна звести з тим, хто і за яким фільтром її замовив.

Was this page helpful?