Спецификация правок «По душе»: 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. Симптом и что доказано
- В публичном каталоге cloud (
GET /api/v1/public/events) 69 событий с площадки All-Events, у всех обложка видаhttps://all-events.ru/upload/iblock/<dir>/<hash>.jpg. Ручная проверка HEAD: из 40 случайных 30 отвечают 404. - Для каждой проверенной мёртвой ссылки страница самого события (
og:image) отдаёт тот же файл с расширением.png. HEAD на.png→ 200. Из 15 мёртвых в выборке 13 оживают заменой расширения, 2 мертвы и как.png. - Список площадки (
https://all-events.ru/events/) отдаёт карточку сbackground-image: url(/upload/webp/resize_cache/iblock/<dir>/402_245_0/<hash>.webp). Это превью, оно живое. - Парсер страницы события в текущем коде отдаёт верный
.png(проверено запускомparse_detail_htmlна скачанной странице). Значит на стенде либо страница события не была прочитана, либо новая ссылка не смогла вытеснить старую (см. причину Б).
Вывод: карточка Вадима верно описывает симптом, но причина не «площадка потеряла файлы», а наша догадка про расширение плюс невозможность заменить уже сохранённую ссылку.
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. Не угадывать расширение (причина А).
parse_list.py:upgrade_image_urlпереименовать вabsolute_image_url; функция только делает адрес абсолютным (urljoin) и убирает?-хвост. Никакой заменыresize_cache/webpна.jpg.- Список (
parse_list.py:334-343):image_urlкарточки = webp-превью как есть (/upload/webp/resize_cache/...webp). Это заведомо живой адрес. - Страница события (
parse_detail.py:194-207): порядок кандидатов оставить(og:image, itemprop image);og:imageуже абсолютный и с верным расширением.ev.image_url = og:image, если он есть; иначе itemprop-картинка без переписывания. merge_detail(parse_detail.py:331-344) без изменений:image_urlсо страницы события побеждает список.normalize.py:255-272без изменений:candidates = [image_url, *images]даёт og:image позицию 0, превью списка следующую.
1.3.2. Устаревшие медиа не должны оставаться обложкой (причина Б).
В _upsert_media (persist.py:651) после обхода входящего списка:
stale = записи EventMedia этого события с kind=image,
source_url не во входящем списке,
download_status == url_only (файл никогда не скачивался)
staleудалять. Записи со статусомdone/pendingне трогать (у них есть файл).- Входящий список приходит целиком на каждый прогон, поэтому удаление безопасно: живая ссылка вернётся следующим же прогоном.
- Инвариант: после
_upsert_mediaпозиция 0 у события ровно одна и это первая входящая картинка.
1.3.3. Проверка ссылок (причина В), общая для всех сборщиков.
- В
DownloadStatusдобавитьdead. Если колонкаevent_media.download_statusв Postgres является enum-типом, нужна Alembic-миграцияALTER TYPE ... ADD VALUE 'dead'; если это строка, миграция не нужна. - Новый модуль
ingestion/orchestrator/media_check.py: check_url(url) -> int: HEAD с таймаутом 5 с,User-Agentкак у сборщика; на 405/403 повтор GET сRange: bytes=0-0; сетевые ошибки = код 0. Одна повторная попытка через 2 с.verify_new_media(session, run_id): для медиа, созданных в этом прогоне (поcreated_at/run_id), не более 8 одновременных запросов на хост, результат вextras["check"] = {"status": код, "at": ISO}; при 404/410 →download_status = dead. Живые остаютсяurl_only.recheck_dead(session, older_than_days=7): недельная перепроверка мёртвых; ожившие возвращать вurl_only.- Вызов
verify_new_mediaв конце прогона сборщика (там же, где сейчас пишется статус прогона,ingestion/orchestrator/),recheck_deadиз ночнойmaintenance. cover_source_url(ui/serializers.py:103) исключает медиа со статусомdead. Тогда выгрузка отдаёт следующую живую картинку или пустую обложку. Пустая обложка честнее битой: приложение рисует цветную плашку с эмодзи темы (DUSHA-30).- Стоимость: один HEAD на новую картинку; на прогоне all-events это сотни запросов, не тысячи. Таймаут и лимит на хост обязательны, иначе прогон площадки растянется.
1.3.4. Разовая уборка накопленного.
CLI-команда python -m ingestion media-repair --source all_events (добавить в ingestion/cli.py рядом с sources seed list run status coverage ui):
- Для каждой
EventMediaс хостомall-events.ruи путём/upload/iblock/.../*.jpg: HEAD. 200 → оставить. 404 → HEAD на.png; 200 → заменитьsource_url(дубликат по уникальностиevent_id + source_urlудалить), иначеdownload_status = dead. - Печать сводки: проверено / оживлено / мертво.
- После уборки
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. Приёмка
- На дев-стенде после прогона all-events:
SELECT count(*) FROM event_media WHERE source_url LIKE 'https://all-events.ru/upload/iblock/%.jpg' AND download_status='url_only'не растёт; новые обложки all-events =og:image. - На cloud после выката и импорта: 40 случайных all-events обложек из
/public/events→ не меньше 38 отвечают 200; в приложении у событий All-Events фотографии, а не плашки (кроме честно мёртвых). - Ни одной ссылки вида
.../upload/iblock/.../*.jpg, построенной из webp, в новых данных.
Требует выката сервиса сборщиков на cloud (Вадим). Приложение не меняется.
2. DUSHA-58. Город в колоде свайпов
2.1. Симптом и что доказано
GET /api/v1/events/deck?city=Санкт-Петербургс демо-аккаунтом на cloud отдаёт те же 10 карточек четырёх городов (8 Мск, 8 СПб, 2 Екб, 2 НН по двум упоминаниямcity_nameна карточку) и тот жеrest: 952, что и запрос без города.GET /api/v1/public/events?city=Санкт-Петербургфильтрует правильно (совпадение по имени города).- Приложение город в колоду не отправляет вовсе: у метода
deckвPodusheApiпараметраcityнет.
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).
DECK_FILTER_KEYS = ('city', 'interest', 'district', 'from', 'to', 'price').- В
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и в приложении. - Обновить docstring
deck()и комментарий надDECK_FILTER_KEYS(сейчас там сказано, что колода принимает только пять ключей). _filter_by_user_cityне трогать: для ленты «лучше чужой город, чем пустая лента» остаётся.
Приложение (android-events).
PodusheApi.kt, методdeck: добавить@Query("city") city: String? = null.EventsRepository.kt,deck(filter): передаватьcity = filter?.city.- Колода должна грузиться с городом всегда, а не только после шторки:
-
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)не обнуляется. - Сброс фильтров (
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. Приёмка
- Сервер (после выката):
deck?city=Санкт-Петербург→ у всех карточекvenue.city_name == "Санкт-Петербург"; без параметра у аккаунта с городом Москва → только Москва. - Приложение: открыть свайпы, не открывая фильтры → только Москва; в фильтрах выбрать Петербург → «Показать» → только Петербург; «Сбросить» → снова Москва. Проверять на сборке против cloud после выката сервера, до него приложение изменений не покажет.
Правка сервера без правки приложения ничего не даёт, и наоборот. Делать обе, выкат сервера через Вадима.
3. DUSHA-43. Прокрутка «Политики» и «Соглашения»
3.1. Симптом и что доказано
Свежая debug-сборка 21.09 на эмуляторе podushe390, вход --es route policy --es kind terms|privacy:
- «Соглашение»: четыре абзаца, ниже пустота. Одно движение вверх, и текст уезжает целиком, лист белый; ещё четыре движения ничего не меняют (снимки совпадают байт в байт). Снимки:
https://salam0nn.ru/artifacts/podushe-dusha-43-shots/terms_1_top.png,.../terms_2_after1swipe.png. - «Политика»: долистывается до последней строки «Дата последнего обновления: 19 августа 2026 г.», лишней пустоты почти нет. Снимки
.../privacy_1_top.png,.../privacy_2_end.png.
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. Приёмка
- «Соглашение»: при коротком тексте прокрутки нет вообще, текст остаётся на месте после любых движений.
- «Политика»: пролистывается до последней строки, за ней пустого экрана нет.
- Кадр
a3Policyв Paparazzi без diff. - Сервер не нужен.
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))}) это единственное, что показано о человеке, ссылки нет, скопировать нельзя.
Правки:
- Сервер,
adminpanel/views/analytics.py:118-150(обе ветки, где собираетсяpage_items): выборку делать сselect_related('user')(ProductEvent.userэто FK наExternalUser,analytics/models.py:63-70) и добавить в строкуuser_name(имя) иuser_phone. Пусто, если пользователя нет. 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).- В
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/ оперирует только ключами).
Правки:
- Новый файл
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 и далее по списку). Формулировки подписей согласовать с владельцем до слияния. users.js:583→badges.map(b => label(BADGE_LABELS, b)).join(', ').core.js:844→ имена событий черезlabel(EVENT_LABELS, name), «(+N total)» → «и ещё N».overview.js:596подписи столбцов диаграммы тоже черезEVENT_LABELSс полнымtitle.- Неизвестный ключ показывать как есть моноширинным, не прятать: так видно, что словарь отстал.
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)
- Снимки «до/после» тех же четырёх экранов, что на скриншотах Нихромова: «Чаты пользователей», «Обзор платформы», диаграмма событий, «Trust-профили», реестр лексики.
- Ни одной надписи с именами таблиц, методов,
Redis,drawer,JSON,PATCHв интерфейсе (grep по правилам 4.1 пустой). - Журнал событий: имя и телефон, клик открывает карточку пользователя; обрезанные идентификаторы копируются по клику.
- Бейджи и события словами; неизвестный ключ виден моноширинным.
- Требует выката образа консоли на cloud (Вадим). Серверная часть только в 4.2 п.1.
5. DUSHA-60. Корень: площадка, координаты, район, организатор
Три пункта карточки это два корня и одна несделанная функция. Ниже правки, после которых беда не повторяется, и разовая уборка.
5.1. Доказательства (cloud, 21.09)
- 43 события Афиши из Петербурга, Москвы, Екатеринбурга висят на одной площадке
dd18546d-…без названия с адресом «423451, респ Татарстан, Альметьевский р-н, г Альметьевск, ул Герцена, д 3в»,city_name = "Нижний Новгород", без координат. Ещё 13 событий All-Events на площадке «не определено» (Москва). - По фильтру города в
/public/events: Москва 19 событий с площадкой «Нижний Новгород», Петербург 13 НН + 5 Мск + 2 Екб, Казань 10 НН + 4 СПб + 4 Мск. - Афиша: 61 из 751 событий без названия площадки, 456 из 751 без координат.
- Организатор пуст у 954 из 954 публичных событий.
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) город площадки не передаёт.
Правки:
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, событие остаётся без площадки, с городом и координатами события._upsert_venue: передmergeпроверятьvenue.city_code == (v.city_code or item.city_code); при несовпадении не мержить, а завести новую запись (ключ с городом это уже гарантирует; проверка это защита от старых записей).merge:city_codeдописывать, если пуст; при непустом и другом — не трогать и писать предупреждение в лог прогона.ui/export.py, блокvenue: добавитьcity_codeиcity_nameплощадки.catalog_import.py:277:city_nameплощадки брать изcard['venue']['city_name'], а не из события.- Ночная проверка в
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), у остальных нет.
Правки:
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) то же. Нет города → района нет.ingestion/collectors/cities.py: в реестр городов добавитьbbox(широта/долгота, рамка с запасом 30 км) для каждого кода._CITY_BBOXизafisha/normalize.pyперенести туда и удалить локальную копию.persist.py: единая проверка в_upsert_venue/create/mergeи при записиevent.lat/lon: точка внеbboxсвоего города → координаты не пишутся,extras["geo_rejected"] = {"lat","lon","reason":"outside_city"}, статус событияneeds_geo(как сейчас делает Афиша).geo/backfill.py:74: убрать| (Venue.city_code.is_(None)); площадки без города не геокодировать.- После выката:
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). Афиша организатора не парсит и почти никогда не даёт.
Правки:
- Сервер,
events/models.py:Event.organizer_name(CharField 512, blank),Event.organizer_url(URLField, blank),Event.organizer_external_id(CharField 128, blank, индекс). Миграция. ui/export.py: в карточкуorganizer: {"external_id": "org:<id>", "name": ..., "url": ...} | null.catalog_import.py: писать три поля.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 про расхождения документа и сервера).- Приложение:
Dtos.kt:118-126OrganizerDto+kind,url;EventPageState.kt:102-105: дляexternalстрока с именем без перехода в профиль, аватар = заглушка организации из макета (нода согласовать с владельцем); если организатора нет, раздел не рисовать. - Сборщик Афиши: парсить
organizerиз JSON-LD, если поле есть; отсутствие организатора у Афиши это норма, не ошибка.
5.5. Разовая уборка
- Сборщик: команда
python -m ingestion venues-rekey [--source afisha]: для всех площадок сsource_place_id LIKE 'auto:%'пересчитать ключ по новому правилу через нормализацию исходной карточки (данные площадки есть вvenuesиevent_source_links), перевесить события, удалить записи без событий. Сводка: перевешено / удалено / без площадки. - Сервер: после следующего импорта у событий появятся новые
venue(по новомуexternal_id), старые общие записи станут сиротами. Командаpython manage.py prune_orphan_venues(новая): удалитьVenueбез событий и без ссылок. - Районы:
backfill_districts --refresh. - Обложки: раздел 1.3.4.
5.6. Тесты
tests/test_dedup.pyили новыйtests/test_venue_identity.py: две безымянные площадки без координат в разных городах дают разные ключи; одноимённые без координат в разных городах не сливаются; без названия и адреса ключNone; координаты вне рамки города отбрасываются и помечаются.tests/test_geo_backfill.py: площадка сcity_code = Noneне геокодируется; площадка Петербурга не ищется в московском индексе.tests_dating_tz/test_events_public_api.py(фикстурыGeoAreaсsquare(), строки 740-751): точка в московском полигоне у события сcity_name='Санкт-Петербург'района не получает;backfillс городом не трогает чужие.tests_dating_tz/test_api_contract.py:organizer.kindв обоих вариантах иnull.- Сборщик Афиши: карточка с
organizerв JSON-LD →OrganizerSpec.
5.7. Решения, которые нужны от владельца до начала
- Событие без названия площадки и без адреса остаётся без площадки, только с городом.
- Что показывать в «Организаторы» у событий с площадок, пока внешнего организатора нет: ничего или название источника (в макете такого состояния нет).
- Грузить ли районы Петербурга сразу после починки (
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. |