Спецификация правок «По душе»: DUSHA-57, DUSHA-58, DUSHA-43, DUSHA-16…19 и корень DUSHA-60

Дата: 21.09.2026. Основание: карточки трекера tracker.salam0nn.ru/p/DUSHA и ручная проверка каждого утверждения 21.09 (код, curl к прод-копии cloud.ru, запуск парсера сборщика на скачанных страницах, живой прогон приложения на эмуляторе). Номера строк даны по main на коммите 2894cd60.

Документ для разработчика: в каждом разделе есть симптом, доказанная причина, точные правки по файлам, тесты, критерий приёмки и что требует выката на cloud.ru.


0. Общие правила

Правило Как применять
Ветка на задачу fix/dusha-57-all-events-covers, fix/dusha-58-deck-city, fix/dusha-43-policy-scroll, fix/dusha-16-19-admin, fix/dusha-60-venue-identity. От origin/main. В feature/figma-parity не пушить.
Сервер и сборщики Всё, что живёт в backend/ и ingestion/, на cloud.ru выкатывает DevOps (Вадим) по цепочке тег → образы → стейдж → prod-gate. Правка «сделана» только когда коммит виден на origin; хеш в комментарии карточки недостаточен (см. DUSHA-21, 18.09).
Приложение android-events/, сборка на макмини: tools/build_on_mac.sh :app:assembleDushaDebug. Перед сборкой добавить в скрипт --exclude 'docs/' (сейчас заливка тащит 5,4 ГБ снимков и не доходит до gradle, проверено 21.09). Сборку для проверки выкладывать по той же ссылке https://salam0nn.ru/artifacts/apk/podushe-cloud.apk.
Визуал Ничего визуального, чего нет в Figma (правило владельца 17.09). Служебные надписи, снекбары, состояния «нажато» без ноды не заводить.
Проверка Руками: экран открыть, действие сделать, результат увидеть. «Экран сменился» не считается. Для приложения: эмулятор podushe390 на макмини, отладочный вход adb shell am start -n com.podushe.app.dusha/ru.podushe.app.MainActivity --es route <имя> … (только debug-сборка).
Трекер Карточку двигать по ходу: «В работе» → «На проверке». В комментарии: причина, что сделано, чем проверено, ссылка на сборку с sha256, что осталось (сервер/выкат).
Тесты Сервер: tests_dating_tz/ (Django TestCase, PostGIS). Сборщики: tests/ в корне репозитория (pytest, Postgres через TEST_DATABASE_URL, схема Alembic, см. tests/conftest.py). Приложение: app/src/test (Paparazzi-кадры). Прогон тяжёлых тестов на макмини, не на сервере.

1. DUSHA-57. Битые обложки all-events.ru

1.1. Симптом и что доказано

Вывод: карточка Вадима верно описывает симптом, но причина не «площадка потеряла файлы», а наша догадка про расширение плюс невозможность заменить уже сохранённую ссылку.

1.2. Причины

# Где Что
А ingestion/collectors/all_events/parse_list.py:151-166, upgrade_image_url Переписывает /upload/webp/resize_cache/iblock/<dir>/<w>_<h>_<n>/<name>.webp и /upload/webp/iblock/<dir>/<name>.webp в /upload/iblock/<dir>/<name>.jpg. Расширение оригинала неизвестно, у площадки много .png. Вызовы: parse_list.py:239, 245, 337, 343 и parse_detail.py:203.
Б ingestion/orchestrator/persist.py:651-686, _upsert_media Входящие медиа добавляются или обновляются по source_url; записи, которых во входящем списке нет, остаются с прежней position. При тексте position совпадающем (0 и 0) cover_source_url (ingestion/ui/serializers.py:103-110, сортировка по position, устойчивая) выбирает старую запись. Ошибочная первая ссылка становится обложкой навсегда.
В Весь ingestion/ Нет ни одной проверки ссылки на картинку (HEAD/GET). DownloadStatus (ingestion/domain.py:32-36: url_only, pending, done, failed) не имеет состояния «ссылка мертва».

Как обложка доходит до приложения: cover_source_url → колонка cover_url в выгрузке (ingestion/ui/export.py:381-385) → карточка с content_hash (export.py:158-166, включает cover_url) → events/catalog_import.py на сервере (values['cover_url'], строка ~460; при неизменном content_hash только отметка синхронизации, catalog_import.py:549-553) → Event.cover_url → API. Перехостинг на сервере (_rehost_cover, catalog_import.py:433-451) включается только для хостов из settings.CATALOG_REHOST_HOSTS (_needs_rehost, 425-431); all-events туда не входит.

1.3. Правки

1.3.1. Не угадывать расширение (причина А).

1.3.2. Устаревшие медиа не должны оставаться обложкой (причина Б).

В _upsert_media (persist.py:651) после обхода входящего списка:

stale = записи EventMedia этого события с kind=image,
        source_url не во входящем списке,
        download_status == url_only  (файл никогда не скачивался)

1.3.3. Проверка ссылок (причина В), общая для всех сборщиков.

1.3.4. Разовая уборка накопленного.

CLI-команда python -m ingestion media-repair --source all_events (добавить в ingestion/cli.py рядом с sources seed list run status coverage ui):

  1. Для каждой EventMedia с хостом all-events.ru и путём /upload/iblock/.../*.jpg: HEAD. 200 → оставить. 404 → HEAD на .png; 200 → заменить source_url (дубликат по уникальности event_id + source_url удалить), иначе download_status = dead.
  2. Печать сводки: проверено / оживлено / мертво.
  3. После уборки cover_url в выгрузке меняется, content_hash меняется, ближайший импорт на сервере обновит Event.cover_url сам (catalog_import.py:555-566). Отдельной серверной команды не нужно.

Ожидание по выборке 21.09: около 85 % мёртвых оживут, остальные уйдут в dead и получат плашку.

1.4. Тесты

tests/test_all_events_normalize.py (уже есть, 8 тестов): - карточка списка с background-image: url(/upload/webp/resize_cache/iblock/8f3/402_245_0/x.webp) → image_url == "https://all-events.ru/upload/webp/resize_cache/iblock/8f3/402_245_0/x.webp", и нигде не появляется .jpg; - страница события с og:image .../8f3/x.png и списочным превью → после merge_detail image_url = .png, превью на позиции 1; - absolute_image_url не меняет расширение ни для .png, ни для .jpeg, ни для .webp.

Новый tests/test_persist_media.py (Postgres-фикстуры из tests/conftest.py): - первый прогон даёт медиа A.jpg (позиция 0); второй прогон даёт B.png (0) и C.webp (1) без A → в базе только B, C, cover_source_url = B; - медиа со статусом done не удаляется при отсутствии во входящем списке; - cover_source_url пропускает dead.

tests/test_media_check.py: check_url на локальном HTTP-сервере (pytest httpserver или http.server в потоке): 200 → живая, 404 → dead, 405 на HEAD + 206 на GET → живая, таймаут → код 0, статус не меняется.

1.5. Приёмка

Требует выката сервиса сборщиков на cloud (Вадим). Приложение не меняется.


2. DUSHA-58. Город в колоде свайпов

2.1. Симптом и что доказано

2.2. Причины

Сторона Файл Что
Сервер events/services.py:669 DECK_FILTER_KEYS = ('interest', 'district', 'from', 'to', 'price') — city отбрасывается на входе (services.py:683-685).
Сервер events/services.py:688, _filter_by_user_city (1295-1340) Отбор по городу профиля применяется всегда; при пустом городе профиля показывается всё. У демо-аккаунтов города нет.
Приложение data/api/PodusheApi.kt:61-69 deck(interest, district, price, from, to) — нет @Query("city").
Приложение data/events/EventsRepository.kt:258-267 api.deck(...) вызывается без города, хотя AfishaFilter.city (EventsRepository.kt:78) есть и search() его передаёт.
Приложение ui/AppViewModels.kt:785-810 DeckViewModel.init грузит колоду с filter = null; фильтр появляется только после «Показать» в шторке (AppNavHost.kt:1176-1180).
Приложение ui/navigation/AppNavHost.kt:1099-1101 Город по умолчанию («Москва», R.string.fig_city_moscow) подставляется в фильтры только при открытии шторки. До этого filters.city пуст.

2.3. Правки

Сервер (events/services.py).

  1. DECK_FILTER_KEYS = ('city', 'interest', 'district', 'from', 'to', 'price').
  2. В deck(): python allowed = {k: params.get(k) for k in self.DECK_FILTER_KEYS if params.get(k)} if params else {} qs = self.published_qs() if not allowed.get('city'): qs = self._filter_by_user_city(qs, user) # профиль — значение по умолчанию if allowed: qs = apply_public_filters(qs, allowed) # явный город побеждает профиль apply_public_filters (events/public_filters.py:219-221) уже отбирает city_name__icontains=city. Формат значения: русское имя города, как в /public/events и в приложении.
  3. Обновить docstring deck() и комментарий над DECK_FILTER_KEYS (сейчас там сказано, что колода принимает только пять ключей).
  4. _filter_by_user_city не трогать: для ленты «лучше чужой город, чем пустая лента» остаётся.

Приложение (android-events).

  1. PodusheApi.kt, метод deck: добавить @Query("city") city: String? = null.
  2. EventsRepository.kt, deck(filter): передавать city = filter?.city.
  3. Колода должна грузиться с городом всегда, а не только после шторки: - DeckViewModel: убрать автозагрузку из init; загрузка только через load(next). - AppNavHost.kt, маршрут Swipes (строка 1081): LaunchedEffect(afisha.filters.city) { deckVm.load(afisha.filters.toFilter()) }; перед этим, если afisha.filters.city.isNullOrBlank(), выставить defaultCity через afishaVm.updateFilters(afisha.filters.copy(city = defaultCity)) (тот же код, что сейчас на открытии шторки, строки 1099-1101). LaunchedEffect(cards.size) на строке 1132 сохранить. - AfishaFilter.isEmpty (EventsRepository.kt:94-97) уже учитывает city, поэтому фильтр только с городом в load(next) не обнуляется.
  4. Сброс фильтров (AppNavHost.kt:1175) уже сохраняет город; проверить, что после «Сбросить» колода перечитывается с городом.

2.4. Тесты

tests_dating_tz/test_event_deck.py (есть хелперы _user, _imported(**kwargs) с city_name, _deck(**params)), класс DeckCityTests: - профиль без города, события в Москве и Петербурге, _deck(city='Санкт-Петербург') → только петербургские, rest = их число; - профиль с городом Москва, _deck() → только московские; _deck(city='Санкт-Петербург') → только петербургские (явный выбор побеждает профиль); - _deck(city='Нет такого города') → пусто, rest == 0; - существующий test_foreign_params_are_ignored (или аналог про «чужие параметры») дополнить: q и bbox по-прежнему игнорируются.

Приложение: Paparazzi-кадров для колоды не менять; проверка руками по 2.5.

2.5. Приёмка

Правка сервера без правки приложения ничего не даёт, и наоборот. Делать обе, выкат сервера через Вадима.


3. DUSHA-43. Прокрутка «Политики» и «Соглашения»

3.1. Симптом и что доказано

Свежая debug-сборка 21.09 на эмуляторе podushe390, вход --es route policy --es kind terms|privacy:

3.2. Причина

ui/screens/legal/PolicyNodeScreen.kt:62: текст рисуется PdNodeText(... blockHeight = 1824.dp ...) — высота ноды 261:26913 из макета — и в живом режиме (scrollable = true, строка 43/54) тоже. Область прокрутки равна 1824 dp плюс отступы независимо от длины текста. «Соглашение» занимает около 350 dp, остальное пустота. PolicyScreen.kt читает res/raw/terms.md и res/raw/policy.md, выкидывает строки с # и пустые, передаёт scrollable = true.

3.3. Правка

PolicyNodeScreen.kt:62:

blockHeight = if (scrollable) Dp.Unspecified else 1824.dp,   // 261:26913: кадр держит высоту ноды, живой экран — высоту текста

Modifier.pdBlockHeight (ui/kit/NodeText.kt:195-196) при Dp.Unspecified высоту не задаёт; ветка абзацев в PdNodeText (NodeText.kt:99-113) оборачивает Column в Box(modifier.pdBlockHeight(blockHeight)), значит высота станет равна тексту. Модификатор wrapContentHeight(unbounded = true) на строке 68 оставить: он нужен режиму кадра.

Кадр A3-policy-261-26902 (app/src/test/.../ScreensScreenshotTest.kt:196-197) вызывает PolicyScreen(kind = "privacy"), то есть живой режим. Видимые 844 dp сверху не меняются, снимок должен совпасть. Если diff появится, разбирать, а не перезаписывать эталон.

3.4. Тексты

Оба документа сейчас рыба из макета (CONTENT-GAP в PolicyScreen.kt): - res/raw/terms.md: 4 абзаца, «сервиса «Название»»; - res/raw/policy.md: 23 абзаца, но это текст пользовательского соглашения про «ООО «Название»», а не политика конфиденциальности.

Финальные тексты даёт владелец. Формат файла: абзацы по одному на строку, пустые строки и строки с # игнорируются. При замене проверить экран заново по 3.5.

3.5. Приёмка


4. DUSHA-16, 17, 18, 19. Консоль оператора (backend/admin-panel)

Консоль это статичный JS без сборки: backend/admin-panel/index.html, assets/core.js, assets/admin.css, разделы в assets/sections/*.js. На cloud деплоится образом admin-spa-image:<тег> (migration/cloud-ru/admin-spa.yaml:34), то есть тоже через тег и Вадима. На cloud выложены те же строки, что в репозитории (проверено 21.09 по https://dushalove.uxbrain.ru/ops/assets/sections/*.js).

4.1. DUSHA-16. Подсказки для разработчиков в интерфейсе

Правило разбора каждой надписи: - описывает устройство системы (таблицы, Redis, адреса разделов, названия моделей, HTTP-методы) → удалить; - объясняет оператору, что сделать на экране → переписать по-русски без жаргона; - сообщение о правах/ошибке → оставить.

Найдено к удалению или переписыванию (файл:строка, текущий текст):

Файл Строка Сейчас Решение
overview.js 61 Клик по плитке — раздел. Адреса разделов: #platform/users, #dating/moderation, #events/catalog. удалить
overview.js 586 подзаголовок окно ~500 последних «последние 500 событий»
overview.js 593-596 подписи столбцов диаграммы режутся l.slice(0, 10) (event_reac, feed_item_) подпись полностью в две строки (CSS .bl{white-space:normal;word-break:break-all} или поворот на 45°); title уже есть, оставить
moderation.js 52 подзаголовок Поиск по телефону/имени · клик «Открыть» — переписка в drawer «Найдите чат по телефону или имени и нажмите «Открыть»»
moderation.js 73 <p class="faint">Телефон → ExternalUser (nikea) → chat DB. Preview: cold messages_batches, иначе Redis last_messages (как mobile). Пустые shell после create-chat — без текста. PII: полный удалить
moderation.js 393 подзаголовок AI/rules · open «Открытые жалобы» (сверить смысл с разделом)
ingest.js 917 подзаголовок PATCH add / remove / full list «Добавьте слово или удалите лишнее»
users.js 556 подзаголовок UserTrustProfile «Уровень доверия пользователей»
ops.js 576 SLA: critical 4h · high 24h · normal 72h · low 7d «Сроки ответа: критичные 4 ч, высокие 24 ч, обычные 72 ч, низкие 7 дней»
ops.js 1046 pending · attempt > 3 «Ожидают, попыток больше трёх»
ops.js 1067 decision=block · unreviewed «Заблокированы автоматически, не просмотрены»
ops.js 1108 unverified / score < 40 «Не верифицированы, балл ниже 40»
ops.js 1132 is_active=false · sample «Выборка неактивных»
ops.js 1333 payload JSON · second pair of eyes «Проверка данных вторым человеком»
settings.js 511 Клиент ходит в публичный … переписать без слова «клиент ходит» либо удалить

Полный обход: grep -n 'class="note">' sections/*.js, grep -n 'class="sub">' sections/*.js | grep -vP '[А-Яа-я]', grep -n 'class="faint"' sections/*.js. Всё, что в этих списках содержит имена таблиц, полей, HTTP-методов, Redis, drawer, payload, JSON, PATCH, идёт под правило выше.

4.2. DUSHA-17. Обрезанные идентификаторы

Причина: shortId (core.js:453-456) режет UUID до 8 знаков; в журнале событий (overview.js:510-521, строка 519 ${esc(shortId(e.user_id))}) это единственное, что показано о человеке, ссылки нет, скопировать нельзя.

Правки:

  1. Сервер, adminpanel/views/analytics.py:118-150 (обе ветки, где собирается page_items): выборку делать с select_related('user') (ProductEvent.user это FK на ExternalUser, analytics/models.py:63-70) и добавить в строку user_name (имя) и user_phone. Пусто, если пользователя нет.
  2. overview.js:519: <td>${esc(e.user_name || '—')} <span class="mono faint" title="${esc(e.user_id || '')}">${esc(e.user_phone || shortId(e.user_id))}</span></td>; при наличии e.user_id клик по ячейке открывает карточку пользователя через openUser360(e.user_id) (core.js:632).
  3. В core.js рядом с shortId добавить idChip(id): <span class="mono faint idchip" title="${id}" data-copy="${id}">${shortId(id)}</span>, обработчик клика копирует полный идентификатор в буфер и показывает «Скопировано». Применить во всех местах, где идентификатор показан без телефона или имени: moderation.js:85, 90, 156, 182, 355, 406, ingest.js:1126, ops.js:626, 1079, users.js:203, 841, 867, 1057. Где рядом есть телефон или имя (users.js:267, 272, 354-357, 837, 1114, 1478), не трогать.

4.3. DUSHA-18. Ключи вместо слов

Причина: бейджи доверия приходят ключами и печатаются как есть (users.js:583, (p.badges || []).join(', ')); имена событий на карточке пользователя тоже сырые (core.js:844, ... .join(' · ') (+N total)). Словаря нет ни в консоли, ни на сервере (scoring/ оперирует только ключами).

Правки:

  1. Новый файл assets/labels.js, подключить из admin.js: js export const BADGE_LABELS = { children_disclosed: 'Дети указаны', criminal_checked: 'Судимость проверена', esia_verified: 'Госуслуги подтверждены', fssp_checked: 'ФССП проверено', }; export const EVENT_LABELS = { /* все имена из KNOWN_EVENT_NAMES */ }; export const label = (map, key) => map[key] || `<span class="mono">${esc(key)}</span>`; Ключи бейджей: scoring/services.py:156-157, scoring/providers/children.py:24-33 и остальные провайдеры scoring/providers/*.py (grep -rn "badge=" scoring/). Имена событий: analytics/models.py:9, KNOWN_EVENT_NAMES (feed_item_shown, feed_opened, event_reaction, geo_ping, company_invite, onboarding_step, like_sent, superlike_sent, pass_sent, undo_pass, app_open, auth_ok, feed_loaded, match_shown, swipe_training_done, age_range_done и далее по списку). Формулировки подписей согласовать с владельцем до слияния.
  2. users.js:583 → badges.map(b => label(BADGE_LABELS, b)).join(', ').
  3. core.js:844 → имена событий через label(EVENT_LABELS, name), «(+N total)» → «и ещё N».
  4. overview.js:596 подписи столбцов диаграммы тоже через EVENT_LABELS с полным title.
  5. Неизвестный ключ показывать как есть моноширинным, не прятать: так видно, что словарь отстал.

4.4. DUSHA-19. Чипы слов в реестре лексики

Заголовок карточки не о том; в теле речь о чипах слов на странице «Реестр запрещённой лексики». Причина: ingest.js:930-941 рисует <span class="chip" style="...padding:6px 10px"> с кнопкой <button class="iconbtn">✕</button>, а .iconbtn (admin.css:87) это 36×36 px с рамкой. Для чипов SEO уже есть уменьшенное правило admin.css:962-966 (.seo-kw-chips .chip .iconbtn, .rowtags .chip .iconbtn, ... → 16×16 без рамки), к лексике оно не применено.

Правки: 1. Контейнеру чипов (ingest.js:930) добавить класс rowtags (уже используется для рядов чипов в applogs.js:524, ingest-telegram.js:289, seo-keywords.js:184, seo-panel.js:631). 2. Убрать инлайновый padding:6px 10px и style у кнопки в ingest.js:935-939; высота чипа должна выйти около 22 px, текст занимает большую часть. 3. Переименовать карточку в трекере: «Реестр лексики: чипы слов втрое крупнее текста».

4.5. Приёмка (весь раздел 4)


5. DUSHA-60. Корень: площадка, координаты, район, организатор

Три пункта карточки это два корня и одна несделанная функция. Ниже правки, после которых беда не повторяется, и разовая уборка.

5.1. Доказательства (cloud, 21.09)

5.2. Корень 1: ключ площадки

Причина: ingestion/orchestrator/persist.py:180-186, _stable_place_id: без source_place_id ключ = auto:<normalize_title(name) или "unknown">:<geo_cell или "nogeo">. Города в ключе нет. Все безымянные площадки без координат одного источника получают ключ auto:unknown:nogeo и сливаются в одну запись; одноимённые без координат сливаются между городами. merge (persist.py:380-385) берёт первый непустой адрес и первые координаты, city_code записи никогда не обновляется. Сервер (events/catalog_import.py:272-278) при каждом импорте перезаписывает venue.city_name из карточки события, потому что выгрузка (ingestion/ui/export.py:466-481) город площадки не передаёт.

Правки:

  1. persist.py, _stable_place_id(v, city_code): - есть source_place_id → как сейчас; - иначе auto:<city>:<name_norm>:<addr_norm>:<cell|nogeo>, где addr_norm = адрес без пунктуации и регистра (как place_id_for в collectors/telegram/normalize.py:54-56), city = v.city_code or item.city_code; - нет ни названия, ни адреса → вернуть None; _upsert_venue в этом случае возвращает None, событие остаётся без площадки, с городом и координатами события.
  2. _upsert_venue: перед merge проверять venue.city_code == (v.city_code or item.city_code); при несовпадении не мержить, а завести новую запись (ключ с городом это уже гарантирует; проверка это защита от старых записей).
  3. merge: city_code дописывать, если пуст; при непустом и другом — не трогать и писать предупреждение в лог прогона.
  4. ui/export.py, блок venue: добавить city_code и city_name площадки. catalog_import.py:277: city_name площадки брать из card['venue']['city_name'], а не из события.
  5. Ночная проверка в ingestion/alerts.py: число событий, у которых venue.city_code != event.city_code; больше нуля → тревога.

5.3. Корень 2: координаты и район не знают города

Причины: - events/geo_areas.py:34-44, area_for_point: поиск полигона по всем GeoArea без фильтра по городу, хотя поле GeoArea.city_name есть (events/models.py:1626) и import_geo_areas его заполняет (import_geo_areas.py:245). - ingestion/geo/backfill.py:70-74: геокодируются площадки city_code == <город прогона> OR city_code IS NULL по индексу одного города (load_index, 34-47). Площадка без города получает точку чужого города. - Проверка «точка внутри города» только у Афиши (collectors/afisha/normalize.py:54-76, _coords и _CITY_BBOX), у остальных нет.

Правки:

  1. geo_areas.py: area_for_point(point, city_name) фильтрует GeoArea.objects.filter(kind=DISTRICT, city_name=city_name, boundary__contains=point); district_for_event передаёт event.city_name, для площадки venue.city_name; backfill (69-94) то же. Нет города → района нет.
  2. ingestion/collectors/cities.py: в реестр городов добавить bbox (широта/долгота, рамка с запасом 30 км) для каждого кода. _CITY_BBOX из afisha/normalize.py перенести туда и удалить локальную копию.
  3. persist.py: единая проверка в _upsert_venue/create/merge и при записи event.lat/lon: точка вне bbox своего города → координаты не пишутся, extras["geo_rejected"] = {"lat","lon","reason":"outside_city"}, статус события needs_geo (как сейчас делает Афиша).
  4. geo/backfill.py:74: убрать | (Venue.city_code.is_(None)); площадки без города не геокодировать.
  5. После выката: python manage.py backfill_districts --refresh (флаг есть, backfill_districts.py:26) на дев-стенде и cloud после загрузки районов (DUSHA-55).

5.4. Корень 3: организатор

Причина: цепочка обрывается на выгрузке. Сборщики извлекают организатора (OrganizerSpec, collectors/protocol.py:32-35; Timepad timepad/normalize.py:318, 474; всего упоминаний в 25 сборщиках), база сборщика хранит Organizer (persist.py:396-420). ui/export.py организатора не отдаёт (в карточке для сервера поля нет; organizer_brief используется только в UI сборщика). На сервере Event.organizer это FK на ExternalUser (events/models.py:373-380), _organizer_dict (events/services.py:152-170) отдаёт null для событий с площадок. Приложение (ui/screens/afisha/EventPageState.kt:102-105) строит раздел «Организаторы» только из event.organizer и рисует заголовок с пустым списком (EventPageNodeScreen.kt:821-835). Афиша организатора не парсит и почти никогда не даёт.

Правки:

  1. Сервер, events/models.py: Event.organizer_name (CharField 512, blank), Event.organizer_url (URLField, blank), Event.organizer_external_id (CharField 128, blank, индекс). Миграция.
  2. ui/export.py: в карточку organizer: {"external_id": "org:<id>", "name": ..., "url": ...} | null. catalog_import.py: писать три поля.
  3. events/api_schema.py:20-30 и services.py:152-170: organizer становится объектом с полем kind: {"kind":"user", user_id, name, initials, color, ...} или {"kind":"external", name, url}; null только когда нет ни того, ни другого. Обновить документ контракта (DUSHA-61 про расхождения документа и сервера).
  4. Приложение: Dtos.kt:118-126 OrganizerDto + kind, url; EventPageState.kt:102-105: для external строка с именем без перехода в профиль, аватар = заглушка организации из макета (нода согласовать с владельцем); если организатора нет, раздел не рисовать.
  5. Сборщик Афиши: парсить organizer из JSON-LD, если поле есть; отсутствие организатора у Афиши это норма, не ошибка.

5.5. Разовая уборка

  1. Сборщик: команда python -m ingestion venues-rekey [--source afisha]: для всех площадок с source_place_id LIKE 'auto:%' пересчитать ключ по новому правилу через нормализацию исходной карточки (данные площадки есть в venues и event_source_links), перевесить события, удалить записи без событий. Сводка: перевешено / удалено / без площадки.
  2. Сервер: после следующего импорта у событий появятся новые venue (по новому external_id), старые общие записи станут сиротами. Команда python manage.py prune_orphan_venues (новая): удалить Venue без событий и без ссылок.
  3. Районы: backfill_districts --refresh.
  4. Обложки: раздел 1.3.4.

5.6. Тесты

5.7. Решения, которые нужны от владельца до начала

  1. Событие без названия площадки и без адреса остаётся без площадки, только с городом.
  2. Что показывать в «Организаторы» у событий с площадок, пока внешнего организатора нет: ничего или название источника (в макете такого состояния нет).
  3. Грузить ли районы Петербурга сразу после починки (import_geo_areas --city Санкт-Петербург --bbox …).

Требует выката сборщиков, сервера и консоли на cloud, плюс приложения. Без разовой уборки старые склейки не исчезнут.


6. Порядок работ

Шаг Что Кто Выкат
1 DUSHA-58 сервер + приложение разработчик сервер: Вадим; APK: salam0nn
2 DUSHA-57 п. 1.3.1–1.3.2 + тесты, затем 1.3.3, затем уборка 1.3.4 разработчик сборщики: Вадим
3 DUSHA-43 правка одной строки; тексты после получения от владельца разработчик APK
4 DUSHA-16…19 разработчик (можно Нихромов) консоль: Вадим
5 DUSHA-60 корни 1 и 2, уборка, затем корень 3 разработчик сборщики + сервер + APK: Вадим

Каждый шаг: своя ветка, PR в main, комментарий в карточке с тем, чем проверено, карточка «На проверке». Владелец проверяет руками.


7. Как сделано 21.09.2026 (отличия от плана)

Ветки от локального main 2894cd60 (в кузне и на GitHub его 18 коммитов нет, от них зависят и приложение, и сервер): fix/dusha-58-deck-city, fix/dusha-57-all-events-covers, fix/dusha-43-policy-scroll, fix/dusha-16-19-admin, fix/dusha-60-venue-identity; все вместе — integration/dusha-fixes-2026-09-21.

Карточка Что пошло не по плану и почему
DUSHA-58 Кроме city в колоде нашлись ещё три причины в приложении: обе шторки открывали поиск города с меткой afisha, которую экран поиска не знает (выбор уходил в модель регистрации и пропадал — город не менялся ни на карте, ни на свайпах); у свайпов была своя модель фильтров; кнопка «Показать» по макету есть только при районах/датах/цене/интересах, поэтому смену одного города применить было нечем. Сделано: метка filters, свайпы на модели карты, город применяется сразу при выборе в поиске, шторки переживают переход в поиск, колода синхронизируется с фильтрами при входе, камера карты переезжает в центр выбранного города. Город по умолчанию — тот, что показывает шторка («Москва»): город профиля сервер приложению не отдаёт.
DUSHA-57 Не «угадываем расширение», а берём полноразмерный путь площадки /upload/webp/iblock/<dir>/<name>.webp: он отдаёт оригинал в настоящем формате и честно отвечает 404. Понижение/удаление устаревших картинок (1.3.2) отменено: одно событие собирает картинки всех склеенных площадок, и обложка гуляла бы между ними. Устаревшую битую ссылку снимает проверка: статус dead, выгрузка его не берёт. Проверка — шаг обслуживания maintenance media-check в волне, а не конец прогона сборщика. Разовая команда уборки (1.3.4) не понадобилась: следующая волна даёт новые адреса, проверка гасит старые.
DUSHA-43 Как в плане. Эталон a3Policy расходится с кодом ещё до правки (снят до отступа под строку состояния и до исправления зазора абзацев) — не перезаписан.
DUSHA-16…19 Как в плане; дополнительно переведены плитки карточки человека, заголовок скоринга, карточка доверия. Значок id перехватывает клик на погружении, чтобы не открывать заодно карточку строки. Значения уровней (basic, elevated) и тексты проверок с сервера — отдельная работа.
DUSHA-60 Отбраковка точки — «попала в рамку ДРУГОГО известного города», а не «вне своей рамки»: иначе пропали бы честные события пригородов. Команда пересчёта ключей не понадобилась: главная площадка события на следующей волне переназначает событие на площадку с новым ключом (а не назвавшая площадку — снимает старую ссылку). Решения 5.7 приняты по умолчанию: без названия и адреса площадки нет; без организатора блок «Организаторы» не рисуется; районы Петербурга — на усмотрение DevOps.