feat(finder): кнопка «Отправить в прогрев» рядом с «Отдать менеджеру»
Task 9 плана 2026-07-19-reklamnaya-auditoriya-v2: начальник в «Поиске
клиентов» отмечает фирмы галочками и отправляет их в прогрев рекламой
Яндекса. Старая кнопка «Отдать менеджеру» остаётся рядом — решение
владельца: быстрая дорожка в обход прогрева для горячих фирм.
- salesfinder/adaudience.py — pick_ad_phones: из размеченных номеров
(ДаДата+HLR) отбирает живой/молчащий мобильный, отбрасывает городской,
мусор и «не существует».
- salesfinder/portal.py — send_ad_audience: тот же транспорт и заголовок
X-Sales-Token, что у send_prospects, POST на /api/sales/integration/ad-audience.
- web/app.py — POST /view/{rid}/warmup: отмеченные фирмы без единого
годного мобильного пропускаются с честным сообщением, остальные летят
в портал полным снимком (payload=asdict(f)) вместе с отобранными номерами.
- web/ui.py — кнопка рядом с «Отдать менеджеру», деактивируется вместе
с ней, когда портал недоступен.
- web/static/app.js — подтверждение перед отправкой ВО ВНЕШНЕМ файле
(не onclick/inline-<script>): CSP боевого портала lk.liderra.ru такое
молча вырезает (инцидент 15.07–18.07 — «Поиск клиентов» был мёртв 3 дня).
12 новых тестов (TDD, падали до реализации), полный прогон службы
tests/ — 321 passed, регрессий нет.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""Отбор номеров, годных для рекламы в Яндексе.
|
||||
|
||||
Годится живой или молчащий мобильный. Не годится городской (реклама по нему
|
||||
не найдёт человека) и мусор. «Не существует» (dead) отбрасываем — деньги на ветер.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def pick_ad_phones(firm: dict[str, Any]) -> list[str]:
|
||||
checked = firm.get("contact_phones_checked") or []
|
||||
picked: list[str] = []
|
||||
|
||||
for row in checked:
|
||||
if row.get("kind") != "mobile":
|
||||
continue
|
||||
if row.get("alive") == "dead":
|
||||
continue
|
||||
phone = str(row.get("phone") or "")
|
||||
if len(phone) == 11 and phone.startswith("7") and phone not in picked:
|
||||
picked.append(phone)
|
||||
|
||||
return picked
|
||||
@@ -60,3 +60,25 @@ def send_prospects(base, token, manager_id, firms, post=None):
|
||||
if not (200 <= r.status_code < 300):
|
||||
raise PortalError(f"портал вернул {r.status_code}")
|
||||
return r.json() or {}
|
||||
|
||||
|
||||
def send_ad_audience(base, token, firms, post=None):
|
||||
"""Отправить фирмы в прогрев рекламой — портал заводит их в аудиторию Яндекса.
|
||||
|
||||
`firms` — уже собранные словари по контракту POST /api/sales/integration/ad-audience
|
||||
(firm_name/firm_inn/.../phones), номера уже отобраны и нормализованы вызывающим
|
||||
кодом (salesfinder.adaudience.pick_ad_phones) — тут только транспорт и заголовок.
|
||||
Возвращает {added, renewed, phones}.
|
||||
"""
|
||||
if post is None:
|
||||
import requests
|
||||
post = requests.post
|
||||
url = base.rstrip("/") + "/api/sales/integration/ad-audience"
|
||||
body = {"firms": firms}
|
||||
try:
|
||||
r = post(url, json=body, headers=_headers(token), timeout=_TIMEOUT)
|
||||
except Exception as e:
|
||||
raise PortalError(f"портал недоступен: {e}") from e
|
||||
if not (200 <= r.status_code < 300):
|
||||
raise PortalError(f"портал вернул {r.status_code}")
|
||||
return r.json() or {}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""TDD «Отправить в прогрев» (Task 9): отбор номеров, отправка в портал, кнопка в UI.
|
||||
|
||||
Старая кнопка «Отдать менеджеру» рядом ОСТАЁТСЯ (решение владельца) — быстрая дорожка
|
||||
в обход прогрева для горячих фирм. Портал/сеть инъектируются — тесты бьют по стабам,
|
||||
секреты сюда не попадают. ПДн: только фиктивные номера 7999000000X.
|
||||
"""
|
||||
import os
|
||||
from dataclasses import asdict
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from salesfinder.adaudience import pick_ad_phones
|
||||
from salesfinder.models import Firm
|
||||
from salesfinder.portal import PortalError
|
||||
|
||||
|
||||
# ── Шаг 1: отбор номеров, годных для рекламы ────────────────────────────────
|
||||
|
||||
def test_берём_только_живые_мобильные():
|
||||
firm = {
|
||||
"contact_phones_checked": [
|
||||
{"phone": "79990000001", "kind": "mobile", "alive": "alive"},
|
||||
{"phone": "79990000002", "kind": "mobile", "alive": "dead"},
|
||||
{"phone": "74950000003", "kind": "landline", "alive": "alive"},
|
||||
{"phone": "79990000004", "kind": "junk", "alive": "alive"},
|
||||
]
|
||||
}
|
||||
|
||||
assert pick_ad_phones(firm) == ["79990000001"]
|
||||
|
||||
|
||||
def test_номер_гасшего_абонента_всё_равно_годится():
|
||||
# «выключен» — не «не существует»: рекламу такому показать можно.
|
||||
firm = {"contact_phones_checked": [{"phone": "79990000005", "kind": "mobile", "alive": "gone"}]}
|
||||
|
||||
assert pick_ad_phones(firm) == ["79990000005"]
|
||||
|
||||
|
||||
def test_фирма_без_размеченных_номеров_даёт_пустой_список():
|
||||
assert pick_ad_phones({}) == []
|
||||
|
||||
|
||||
# ── Шаг 5: отправка в портал (транспорт) ────────────────────────────────────
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status, data):
|
||||
self.status_code = status
|
||||
self._data = data
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def test_send_ad_audience_posts_firms_with_token_header():
|
||||
from salesfinder import portal
|
||||
|
||||
sent = {}
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
sent["url"] = url
|
||||
sent["json"] = json
|
||||
sent["headers"] = headers
|
||||
return _Resp(200, {"added": 1, "renewed": 0, "phones": 1})
|
||||
|
||||
firms = [{"firm_name": "ООО Ромашка", "phones": ["79990000001"]}]
|
||||
res = portal.send_ad_audience("https://p", "TOK", firms, post=fake_post)
|
||||
|
||||
assert res == {"added": 1, "renewed": 0, "phones": 1}
|
||||
assert sent["url"].endswith("/api/sales/integration/ad-audience")
|
||||
assert sent["headers"]["X-Sales-Token"] == "TOK"
|
||||
assert sent["json"] == {"firms": firms}
|
||||
|
||||
|
||||
def test_send_ad_audience_non_2xx_raises():
|
||||
from salesfinder import portal
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
return _Resp(422, {})
|
||||
|
||||
with pytest.raises(portal.PortalError):
|
||||
portal.send_ad_audience("https://p", "TOK", [{"firm_name": "X"}], post=fake_post)
|
||||
|
||||
|
||||
# ── Шаг 6: обработчик /view/{rid}/warmup + кнопка в UI ──────────────────────
|
||||
|
||||
def make_client(tmp_path):
|
||||
os.environ["SF_DB"] = str(tmp_path / "runs.sqlite")
|
||||
from web import app as appmod
|
||||
appmod._portal_auth = lambda: None
|
||||
return TestClient(appmod.app)
|
||||
|
||||
|
||||
def _seed_run(appmod, firms, rubric="stom"):
|
||||
rid = appmod.STORE.create(rubric, "moscow")
|
||||
appmod.STORE.save_result(rid, firms)
|
||||
appmod.STORE.set_status(rid, "done")
|
||||
return rid
|
||||
|
||||
|
||||
def _firm_with_mobile(**over):
|
||||
base = dict(
|
||||
name="ООО Ромашка", inn="7709918259", city="Москва", domain="romashka.ru",
|
||||
contact_phones_checked=[{"phone": "79990000001", "kind": "mobile", "alive": "alive"}],
|
||||
)
|
||||
base.update(over)
|
||||
return Firm(**base)
|
||||
|
||||
|
||||
def test_warmup_sends_picked_firms_with_their_phones(tmp_path):
|
||||
c = make_client(tmp_path)
|
||||
from web import app as appmod
|
||||
appmod._portal_cfg = lambda: ("https://p", "TOK")
|
||||
sent = {}
|
||||
|
||||
def stub_send(base, token, firms):
|
||||
sent["base"] = base
|
||||
sent["token"] = token
|
||||
sent["firms"] = firms
|
||||
return {"added": len(firms), "renewed": 0, "phones": sum(len(f["phones"]) for f in firms)}
|
||||
|
||||
appmod.PORTAL_SEND_AD_AUDIENCE = stub_send
|
||||
rid = _seed_run(appmod, [
|
||||
_firm_with_mobile(name="A"),
|
||||
_firm_with_mobile(name="B"),
|
||||
])
|
||||
|
||||
r = c.post(f"/view/{rid}/warmup", data={"sel": ["0", "1"]})
|
||||
assert r.status_code == 200
|
||||
assert len(sent["firms"]) == 2
|
||||
assert sent["firms"][0]["firm_name"] == "A"
|
||||
assert sent["firms"][0]["phones"] == ["79990000001"]
|
||||
assert sent["firms"][0]["firm_inn"] == "7709918259"
|
||||
|
||||
|
||||
def test_warmup_skips_firm_without_mobile_and_reports_it(tmp_path):
|
||||
c = make_client(tmp_path)
|
||||
from web import app as appmod
|
||||
appmod._portal_cfg = lambda: ("https://p", "TOK")
|
||||
sent = {}
|
||||
|
||||
def stub_send(base, token, firms):
|
||||
sent["firms"] = firms
|
||||
return {"added": len(firms), "renewed": 0, "phones": 1}
|
||||
|
||||
appmod.PORTAL_SEND_AD_AUDIENCE = stub_send
|
||||
rid = _seed_run(appmod, [
|
||||
Firm(name="Без телефона", contact_phones_checked=[]),
|
||||
_firm_with_mobile(name="С телефоном"),
|
||||
])
|
||||
|
||||
r = c.post(f"/view/{rid}/warmup", data={"sel": ["0", "1"]})
|
||||
assert r.status_code == 200
|
||||
assert "Без телефона" in r.text # честно назвали, кого пропустили
|
||||
assert len(sent["firms"]) == 1
|
||||
assert sent["firms"][0]["firm_name"] == "С телефоном"
|
||||
|
||||
|
||||
def test_warmup_all_without_mobile_sends_nothing(tmp_path):
|
||||
c = make_client(tmp_path)
|
||||
from web import app as appmod
|
||||
appmod._portal_cfg = lambda: ("https://p", "TOK")
|
||||
called = {"n": 0}
|
||||
|
||||
def stub_send(base, token, firms):
|
||||
called["n"] += 1
|
||||
return {"added": 0, "renewed": 0, "phones": 0}
|
||||
|
||||
appmod.PORTAL_SEND_AD_AUDIENCE = stub_send
|
||||
rid = _seed_run(appmod, [Firm(name="Без телефона", contact_phones_checked=[])])
|
||||
|
||||
r = c.post(f"/view/{rid}/warmup", data={"sel": ["0"]})
|
||||
assert r.status_code == 200
|
||||
assert called["n"] == 0 # портал вообще не дёргаем — нечего слать
|
||||
assert "Без телефона" in r.text
|
||||
|
||||
|
||||
def test_warmup_portal_error_sends_nothing_marked(tmp_path):
|
||||
c = make_client(tmp_path)
|
||||
from web import app as appmod
|
||||
appmod._portal_cfg = lambda: ("https://p", "TOK")
|
||||
|
||||
def boom(base, token, firms):
|
||||
raise PortalError("портал недоступен")
|
||||
|
||||
appmod.PORTAL_SEND_AD_AUDIENCE = boom
|
||||
rid = _seed_run(appmod, [_firm_with_mobile(name="A")])
|
||||
|
||||
r = c.post(f"/view/{rid}/warmup", data={"sel": ["0"]})
|
||||
assert r.status_code == 200
|
||||
assert "портал" in r.text.lower()
|
||||
|
||||
|
||||
def test_warmup_no_portal_config_sends_nothing(tmp_path):
|
||||
c = make_client(tmp_path)
|
||||
from web import app as appmod
|
||||
appmod._portal_cfg = lambda: ("", "")
|
||||
called = {"n": 0}
|
||||
appmod.PORTAL_SEND_AD_AUDIENCE = lambda base, token, firms: called.__setitem__("n", called["n"] + 1) or {}
|
||||
rid = _seed_run(appmod, [_firm_with_mobile(name="A")])
|
||||
|
||||
r = c.post(f"/view/{rid}/warmup", data={"sel": ["0"]})
|
||||
assert r.status_code == 200
|
||||
assert called["n"] == 0
|
||||
|
||||
|
||||
def test_results_html_has_warmup_button_next_to_assign():
|
||||
from web import ui
|
||||
run = {"id": 5, "rubric": "stom", "city": "moscow", "status": "done",
|
||||
"firms": [asdict(_firm_with_mobile(name="A", director_inn="1"))]}
|
||||
html = ui.results_html(run, managers=[{"id": 7, "name": "Иванов"}])
|
||||
assert "Отправить в прогрев" in html
|
||||
assert "Отдать менеджеру" in html # старая кнопка осталась рядом
|
||||
|
||||
|
||||
def test_results_html_no_managers_disables_warmup_too():
|
||||
from web import ui
|
||||
run = {"id": 5, "rubric": "stom", "city": "moscow", "status": "done",
|
||||
"firms": [asdict(_firm_with_mobile(name="A", director_inn="1"))]}
|
||||
html = ui.results_html(run, managers=[])
|
||||
idx = html.index("Отправить в прогрев")
|
||||
# атрибут disabled должен стоять на том же кнопочном теге
|
||||
tag_start = html.rindex("<button", 0, idx)
|
||||
tag_end = html.index(">", idx)
|
||||
assert "disabled" in html[tag_start:tag_end]
|
||||
@@ -208,6 +208,7 @@ from salesfinder import portal as _portal
|
||||
from salesfinder.portal import PortalError
|
||||
PORTAL_SEND = _portal.send_prospects
|
||||
PORTAL_MANAGERS = _portal.get_managers
|
||||
PORTAL_SEND_AD_AUDIENCE = _portal.send_ad_audience # Task 9: «Отправить в прогрев»
|
||||
|
||||
|
||||
def _portal_cfg():
|
||||
@@ -401,6 +402,87 @@ def assign_manager(rid: int, sel: list = Form(default=[]), manager_id: str = For
|
||||
return RedirectResponse(url=_u(f"/view/{rid}"), status_code=303)
|
||||
|
||||
|
||||
@app.post("/view/{rid}/warmup")
|
||||
def send_to_warmup(rid: int, sel: list = Form(default=[])):
|
||||
"""Отправить отмеченные фирмы в прогрев рекламой (Яндекс.Аудитории через портал).
|
||||
|
||||
Кнопка «Отдать менеджеру» рядом ОСТАЁТСЯ — решение владельца: быстрая дорожка
|
||||
в обход прогрева для горячих фирм. Фирма без единого годного мобильного номера
|
||||
в прогрев не берётся — сообщаем об этом честно, а не теряем молча."""
|
||||
from dataclasses import asdict
|
||||
from salesfinder.adaudience import pick_ad_phones
|
||||
from salesfinder.models import Firm
|
||||
from salesfinder.ranker import heat_label
|
||||
from salesfinder.rubrics import label_for as rubric_label
|
||||
|
||||
r = STORE.get(rid)
|
||||
if not r or r.get("status") != "done":
|
||||
return RedirectResponse(url=_u(f"/view/{rid}"), status_code=303)
|
||||
|
||||
base, token = _portal_cfg()
|
||||
if not base or not token:
|
||||
body = ('<div class=card><h1>Не удалось отправить в прогрев</h1>'
|
||||
'<p class=lead>Связь с порталом не настроена (адрес/токен). '
|
||||
'Ничего не отправлено.</p>'
|
||||
f'<a class="btn" href="{_u(f"/view/{rid}")}">← к списку</a></div>')
|
||||
return HTMLResponse(ui.page("Портал недоступен", body))
|
||||
|
||||
firms = [Firm(**row) for row in r["firms"]]
|
||||
picked = sorted({int(s) for s in sel if str(s).isdigit()})
|
||||
chosen = [firms[i] for i in picked if 0 <= i < len(firms)]
|
||||
if not chosen:
|
||||
return RedirectResponse(url=_u(f"/view/{rid}"), status_code=303)
|
||||
|
||||
rubric_ru = rubric_label(r.get("rubric") or "") or (r.get("rubric") or "")
|
||||
payloads = []
|
||||
without_phone = []
|
||||
for f in chosen:
|
||||
phones = pick_ad_phones(asdict(f))
|
||||
if not phones:
|
||||
without_phone.append(f.name)
|
||||
continue
|
||||
contacts = [{"name": f.director, "phones": phones}] if f.director else []
|
||||
payloads.append({
|
||||
"firm_name": f.name,
|
||||
"firm_inn": f.inn,
|
||||
"legal_name": f.legal_name,
|
||||
"city": f.city,
|
||||
"rubric": rubric_ru,
|
||||
"site": f.domain,
|
||||
"phone": f.phone,
|
||||
"rating_label": heat_label(f.hotness or 0),
|
||||
"payload": asdict(f),
|
||||
"contacts": contacts,
|
||||
"phones": phones,
|
||||
})
|
||||
|
||||
if not payloads:
|
||||
body = ('<div class=card><h1>Не отправлено — нет годных номеров</h1>'
|
||||
f'<p class=lead>У отмеченных фирм ({", ".join(without_phone)}) нет ни одного '
|
||||
'живого мобильного номера — рекламу показывать некому. Соберите контакты '
|
||||
'заново или отметьте другие фирмы.</p>'
|
||||
f'<a class="btn" href="{_u(f"/view/{rid}")}">← к списку</a></div>')
|
||||
return HTMLResponse(ui.page("Нет годных номеров", body))
|
||||
|
||||
try:
|
||||
PORTAL_SEND_AD_AUDIENCE(base, token, payloads)
|
||||
except PortalError as e:
|
||||
body = ('<div class=card><h1>Портал не принял фирмы</h1>'
|
||||
f'<p class=lead>{str(e)}. Ничего не отправлено — попробуйте ещё раз '
|
||||
'через минуту.</p>'
|
||||
f'<a class="btn" href="{_u(f"/view/{rid}")}">← к списку</a></div>')
|
||||
return HTMLResponse(ui.page("Ошибка отправки", body))
|
||||
|
||||
if without_phone:
|
||||
body = ('<div class=card><h1>Отправлено частично</h1>'
|
||||
f'<p class=lead>В прогрев ушло {len(payloads)} фирм{"а" if len(payloads) == 1 else ""}. '
|
||||
f'Без годного мобильного номера пропущены: {", ".join(without_phone)}.</p>'
|
||||
f'<a class="btn" href="{_u(f"/view/{rid}")}">← к списку</a></div>')
|
||||
return HTMLResponse(ui.page("Отправлено частично", body))
|
||||
|
||||
return RedirectResponse(url=_u(f"/view/{rid}"), status_code=303)
|
||||
|
||||
|
||||
@app.post("/run")
|
||||
def start_run(background: BackgroundTasks, rubric: str = Form(...), city: str = Form(...)):
|
||||
rid = STORE.create(rubric, city)
|
||||
|
||||
@@ -148,3 +148,17 @@
|
||||
}
|
||||
} catch (e) { /* мусор в памяти браузера не должен ломать страницу */ }
|
||||
})();
|
||||
|
||||
/* «Отправить в прогрев» (страница /view/{id}) — с этого момента директору начинает
|
||||
показываться реклама Лидерры, поэтому спрашиваем подтверждение перед отправкой
|
||||
(тот же приём, что у подтверждения удаления списка выше — событие submit,
|
||||
не onsubmit-атрибут, чтобы CSP боевого портала его не вырезал). Кнопка живёт
|
||||
в форме результатов, а не на главной, поэтому это отдельный top-level блок,
|
||||
не завязанный на #cascade. */
|
||||
document.addEventListener("submit", function (e) {
|
||||
var btn = e.submitter;
|
||||
if (btn && btn.id === "warmup-btn" &&
|
||||
!confirm("Отправить отмеченные фирмы в прогрев рекламой?")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -400,6 +400,8 @@ def results_html(run, managers=None):
|
||||
+ "".join(rows) + '</table>')
|
||||
# Кнопка «Отдать менеджеру» (Этап 2): выбор менеджера из портала + отправка отмеченных.
|
||||
# Портал недоступен (список пуст) → кнопка disabled, чтобы не отправлять в никуда.
|
||||
# «Отправить в прогрев» (Task 9) — РЯДОМ, старую кнопку не убираем: решение владельца,
|
||||
# это быстрая дорожка в обход прогрева для горячих фирм.
|
||||
if managers:
|
||||
opts = "".join(f'<option value="{_esc(m.get("id"))}">{_esc(m.get("name"))}</option>'
|
||||
for m in managers)
|
||||
@@ -407,15 +409,24 @@ def results_html(run, managers=None):
|
||||
f'{opts}</select>'
|
||||
f'<button class=btn formaction="{_u(f"/view/{_esc(rid)}/assign")}" '
|
||||
f'style="margin-left:8px">Отдать менеджеру</button>')
|
||||
warmup = (f'<button class="btn ghost" id=warmup-btn '
|
||||
f'formaction="{_u(f"/view/{_esc(rid)}/warmup")}" '
|
||||
f'style="margin-left:8px">Отправить в прогрев</button>')
|
||||
else:
|
||||
assign = ('<button class=btn disabled '
|
||||
'title="портал недоступен — список менеджеров не получен" '
|
||||
'style="margin-left:16px">Отдать менеджеру</button>')
|
||||
# Одна форма с чекбоксами: обе кнопки шлют отмеченные фирмы, каждая на свой адрес (formaction).
|
||||
warmup = ('<button class="btn ghost" disabled '
|
||||
'title="портал недоступен — список менеджеров не получен" '
|
||||
'style="margin-left:8px">Отправить в прогрев</button>')
|
||||
# Одна форма с чекбоксами: все кнопки шлют отмеченные фирмы, каждая на свой адрес (formaction).
|
||||
# Таблица сдвинута отступом вправо (просьба владельца).
|
||||
# ⚠ Обработчик подтверждения для #warmup-btn — во внешнем web/static/app.js, НЕ
|
||||
# inline-onclick: CSP боевого портала (lk.liderra.ru) молча вырезает встроенные
|
||||
# скрипты и атрибуты on*= (инцидент 15.07–18.07 — «Поиск клиентов» был мёртв 3 дня).
|
||||
collect = (f'<form method=post action="{_u(f"/view/{_esc(rid)}/contacts")}">'
|
||||
f'<div style="margin:0 0 12px"><button class=btn>Собрать контакты по отмеченным</button>'
|
||||
f'{assign}'
|
||||
f'{assign}{warmup}'
|
||||
f'<span class=dim style="margin-left:12px">по ИНН → все телефоны и e-mail '
|
||||
f'директора; 2 платных запроса на фирму</span></div>'
|
||||
f'<div style="padding-left:24px">{table}</div></form>')
|
||||
@@ -426,4 +437,7 @@ def results_html(run, managers=None):
|
||||
'с её сайта; сверьте перед звонком. <b>Контакты</b> (все телефоны и почты) собираются '
|
||||
'по ИНН → ФИО+дата рождения → базе контактов: это персональные данные, только по '
|
||||
'отмеченным галочкам, 2 платных запроса на фирму.</div>')
|
||||
return page("Список на прозвон — Лидерра", head + collect + note)
|
||||
# ВНЕШНИЙ файл (не <script> в теле) — CSP боевого портала встроенный скрипт
|
||||
# молча вырезает; он даёт подтверждение перед «Отправить в прогрев».
|
||||
script = f'<script src="{_esc(_u("/app.js"))}" defer></script>'
|
||||
return page("Список на прозвон — Лидерра", head + collect + note + script)
|
||||
|
||||
Reference in New Issue
Block a user