feat: Admin-панель в CRM + CLI --repass / --delete-db
- app/app.py: навигация CRM / Управление (st.sidebar.radio), render_admin() со статистикой, кнопками rescore / repass / сброс флагов / удаление БД с чекбоксом - main.py: run_repass() (ренормализация телефонов+доменов + rescore, без HTTP), флаги --delete-db и --repass, Path import добавлен Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+138
-11
@@ -7,6 +7,7 @@
|
|||||||
Работает над той же leads.db что и парсер — изменения видны
|
Работает над той же leads.db что и парсер — изменения видны
|
||||||
с обеих сторон сразу.
|
с обеих сторон сразу.
|
||||||
"""
|
"""
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,21 +17,13 @@ import streamlit as st
|
|||||||
|
|
||||||
# Чтобы импортировать config из родительской папки parser_v1/
|
# Чтобы импортировать config из родительской папки parser_v1/
|
||||||
PARENT = Path(__file__).parent.parent
|
PARENT = Path(__file__).parent.parent
|
||||||
|
if str(PARENT) not in sys.path:
|
||||||
sys.path.insert(0, str(PARENT))
|
sys.path.insert(0, str(PARENT))
|
||||||
|
|
||||||
import config # noqa: E402
|
import config # noqa: E402
|
||||||
import database # noqa: E402 (для автомиграции БД при первом запуске CRM)
|
import database # noqa: E402 (для автомиграции БД при первом запуске CRM)
|
||||||
import db_layer # noqa: E402 (рядом с app.py — Streamlit добавляет эту папку в sys.path)
|
import db_layer # noqa: E402 (рядом с app.py — Streamlit добавляет эту папку в sys.path)
|
||||||
|
|
||||||
|
|
||||||
# ─── Настройка страницы ──────────────────────────────────────────────
|
|
||||||
st.set_page_config(
|
|
||||||
page_title="CRM — 44AS Парсер лидов",
|
|
||||||
page_icon="🎯",
|
|
||||||
layout="wide",
|
|
||||||
initial_sidebar_state="expanded",
|
|
||||||
)
|
|
||||||
|
|
||||||
DB_PATH = PARENT / config.DB_PATH
|
DB_PATH = PARENT / config.DB_PATH
|
||||||
|
|
||||||
# Автомиграция: если БД старая (без 4 CRM-колонок / outreach_events / lead_in_run) —
|
# Автомиграция: если БД старая (без 4 CRM-колонок / outreach_events / lead_in_run) —
|
||||||
@@ -342,8 +335,8 @@ def render_lead_detail(lead_id: int) -> None:
|
|||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
# ─── Главная страница ───────────────────────────────────────────────
|
# ─── Главная страница (CRM) ─────────────────────────────────────────
|
||||||
def main():
|
def render_crm():
|
||||||
st.title("🎯 CRM — Парсер лидов 44AS")
|
st.title("🎯 CRM — Парсер лидов 44AS")
|
||||||
|
|
||||||
# ── Сайдбар: фильтры ────────────────────────────────────────────
|
# ── Сайдбар: фильтры ────────────────────────────────────────────
|
||||||
@@ -550,5 +543,139 @@ def main():
|
|||||||
render_lead_detail(selected_lead_id)
|
render_lead_detail(selected_lead_id)
|
||||||
|
|
||||||
|
|
||||||
|
def render_admin() -> None:
|
||||||
|
"""Страница управления: статистика, repass, удаление БД."""
|
||||||
|
import database as _db
|
||||||
|
|
||||||
|
st.header("Управление / Admin")
|
||||||
|
|
||||||
|
# ── Статистика ───────────────────────────────────────────────────
|
||||||
|
st.subheader("Статистика БД")
|
||||||
|
try:
|
||||||
|
_conn = _db.get_connection(str(DB_PATH))
|
||||||
|
stats = _db.get_stats(_conn)
|
||||||
|
# band distribution (отдельный запрос — get_stats не содержит band)
|
||||||
|
band_rows = _conn.execute(
|
||||||
|
"SELECT band, COUNT(*) as cnt FROM leads GROUP BY band ORDER BY cnt DESC"
|
||||||
|
).fetchall()
|
||||||
|
_conn.close()
|
||||||
|
has_db = True
|
||||||
|
except Exception as e:
|
||||||
|
st.warning(f"БД недоступна: {e}")
|
||||||
|
has_db = False
|
||||||
|
stats = {}
|
||||||
|
band_rows = []
|
||||||
|
|
||||||
|
if has_db:
|
||||||
|
col1, col2, col3 = st.columns(3)
|
||||||
|
col1.metric("Всего лидов", stats.get("total", 0))
|
||||||
|
col2.metric("С телефоном", stats.get("with_phone", 0))
|
||||||
|
col3.metric("С email", stats.get("with_email", 0))
|
||||||
|
|
||||||
|
if band_rows:
|
||||||
|
st.caption("Распределение по band")
|
||||||
|
band_dict = {r[0] or "—": r[1] for r in band_rows}
|
||||||
|
st.bar_chart(band_dict)
|
||||||
|
|
||||||
|
by_source = stats.get("by_source", {})
|
||||||
|
if by_source:
|
||||||
|
with st.expander("По источнику"):
|
||||||
|
st.write(by_source)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# ── Пересчёт score ───────────────────────────────────────────────
|
||||||
|
st.subheader("Пересчитать score")
|
||||||
|
st.caption("Применяет текущую формулу скоринга ко всем лидам. Без HTTP (~секунды).")
|
||||||
|
if st.button("Пересчитать score", type="primary", key="btn_rescore"):
|
||||||
|
with st.spinner("Пересчёт..."):
|
||||||
|
try:
|
||||||
|
_main = importlib.import_module("main")
|
||||||
|
_conn = _db.get_connection(str(DB_PATH))
|
||||||
|
changed = _main.run_rescore(_conn)
|
||||||
|
_conn.close()
|
||||||
|
st.success(f"Score изменился у {changed} лидов")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Ошибка: {e}")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# ── Repass ───────────────────────────────────────────────────────
|
||||||
|
st.subheader("Перепросчитать всё (repass)")
|
||||||
|
st.caption("Нормализует телефоны/домены + пересчитывает score. Без HTTP. ~10–30 сек.")
|
||||||
|
if st.button("Запустить repass", type="primary", key="btn_repass"):
|
||||||
|
with st.spinner("Нормализация + rescore..."):
|
||||||
|
try:
|
||||||
|
_main = importlib.import_module("main")
|
||||||
|
_conn = _db.get_connection(str(DB_PATH))
|
||||||
|
result = _main.run_repass(_conn)
|
||||||
|
_conn.close()
|
||||||
|
st.success(
|
||||||
|
f"Готово: телефонов={result['phones_fixed']}, "
|
||||||
|
f"доменов={result['domains_fixed']}, "
|
||||||
|
f"score изменился у {result['scores_changed']}/{result['total']}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Ошибка: {e}")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# ── Сбросить флаги обогащения ────────────────────────────────────
|
||||||
|
st.subheader("Сбросить флаги обогащения")
|
||||||
|
st.caption("site_checked_at → NULL у всех лидов. Следующий --enrich пройдёт заново.")
|
||||||
|
if st.button("Сбросить флаги сайтов", key="btn_rescan"):
|
||||||
|
try:
|
||||||
|
_conn = _db.get_connection(str(DB_PATH))
|
||||||
|
cur = _conn.execute(
|
||||||
|
"UPDATE leads SET site_checked_at = NULL WHERE site_checked_at IS NOT NULL"
|
||||||
|
)
|
||||||
|
_conn.commit()
|
||||||
|
count = cur.rowcount
|
||||||
|
_conn.close()
|
||||||
|
st.success(f"Сброшено у {count} лидов")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Ошибка: {e}")
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# ── Удалить БД ───────────────────────────────────────────────────
|
||||||
|
st.subheader("Удалить БД")
|
||||||
|
st.warning("Удаляет leads.db + WAL/SHM и пересоздаёт пустую схему. Все лиды будут стёрты.")
|
||||||
|
confirm = st.checkbox("Да, удалить всю БД безвозвратно", key="chk_confirm_delete")
|
||||||
|
if st.button("Удалить БД", type="primary", key="btn_delete_db", disabled=not confirm):
|
||||||
|
try:
|
||||||
|
db_path = Path(str(DB_PATH))
|
||||||
|
for suffix in ("", "-wal", "-shm"):
|
||||||
|
p = Path(str(db_path) + suffix) if suffix else db_path
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
_db.init_db(str(DB_PATH))
|
||||||
|
st.success("БД удалена и пересоздана (пустая схема)")
|
||||||
|
st.rerun()
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Ошибка: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="CRM — 44AS Парсер лидов",
|
||||||
|
page_icon="🎯",
|
||||||
|
layout="wide",
|
||||||
|
initial_sidebar_state="expanded",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = st.sidebar.radio(
|
||||||
|
"Раздел",
|
||||||
|
options=["crm", "admin"],
|
||||||
|
format_func=lambda m: {"crm": "CRM", "admin": "Управление / Admin"}[m],
|
||||||
|
)
|
||||||
|
st.sidebar.divider()
|
||||||
|
|
||||||
|
if mode == "admin":
|
||||||
|
render_admin()
|
||||||
|
else:
|
||||||
|
render_crm()
|
||||||
|
|
||||||
|
|
||||||
# Streamlit запускает скрипт целиком на каждое действие — без if __name__
|
# Streamlit запускает скрипт целиком на каждое действие — без if __name__
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import random
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from database import (
|
from database import (
|
||||||
@@ -691,6 +692,54 @@ def run_rescore(conn) -> int:
|
|||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def run_repass(conn) -> dict:
|
||||||
|
"""Ренормализация телефонов/доменов + rescore. Без HTTP-запросов."""
|
||||||
|
from normalization import normalize_phone, normalize_domain
|
||||||
|
|
||||||
|
leads = get_all_leads(conn)
|
||||||
|
phones_fixed = 0
|
||||||
|
domains_fixed = 0
|
||||||
|
|
||||||
|
for row in leads:
|
||||||
|
lead = dict(row)
|
||||||
|
updates = {}
|
||||||
|
|
||||||
|
# Нормализация phone_primary
|
||||||
|
raw_phone = lead.get("phone_primary")
|
||||||
|
if raw_phone and not raw_phone.startswith("+"):
|
||||||
|
normed = normalize_phone(raw_phone)
|
||||||
|
if normed and normed != raw_phone:
|
||||||
|
updates["phone_primary"] = normed
|
||||||
|
phones_fixed += 1
|
||||||
|
|
||||||
|
# Починить website без протокола
|
||||||
|
raw_site = lead.get("website")
|
||||||
|
if raw_site and not raw_site.startswith(("http://", "https://")):
|
||||||
|
updates["website"] = "https://" + raw_site.lstrip("/")
|
||||||
|
domains_fixed += 1
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
sets = ", ".join(f"{k} = ?" for k in updates)
|
||||||
|
vals = list(updates.values()) + [lead["id"]]
|
||||||
|
conn.execute(f"UPDATE leads SET {sets} WHERE id = ?", vals)
|
||||||
|
|
||||||
|
if phones_fixed or domains_fixed:
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
scores_changed = run_rescore(conn)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"repass: телефонов нормализовано={phones_fixed}, "
|
||||||
|
f"доменов исправлено={domains_fixed}, score изменился у {scores_changed}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"phones_fixed": phones_fixed,
|
||||||
|
"domains_fixed": domains_fixed,
|
||||||
|
"scores_changed": scores_changed,
|
||||||
|
"total": len(leads),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ───────────────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
# CLI
|
# CLI
|
||||||
# ───────────────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
@@ -770,6 +819,17 @@ def main():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Пересчитать score у всех лидов (после изменения формулы)",
|
help="Пересчитать score у всех лидов (после изменения формулы)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--delete-db",
|
||||||
|
action="store_true",
|
||||||
|
dest="delete_db",
|
||||||
|
help="Удалить leads.db + WAL/SHM файлы и пересоздать пустую схему",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--repass",
|
||||||
|
action="store_true",
|
||||||
|
help="Ренормализация телефонов/доменов + rescore без HTTP-запросов",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--full",
|
"--full",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -910,10 +970,29 @@ def main():
|
|||||||
args.city = known
|
args.city = known
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# --delete-db: обработать до открытия соединения
|
||||||
|
if args.delete_db:
|
||||||
|
db_path = Path(config.DB_PATH)
|
||||||
|
for suffix in ("", "-wal", "-shm"):
|
||||||
|
p = Path(str(db_path) + suffix) if suffix else db_path
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
logger.info(f"Удалён: {p}")
|
||||||
|
init_db(str(config.DB_PATH))
|
||||||
|
logger.info("БД пересоздана (пустая схема)")
|
||||||
|
return
|
||||||
|
|
||||||
# Инициализация БД
|
# Инициализация БД
|
||||||
init_db(config.DB_PATH)
|
init_db(config.DB_PATH)
|
||||||
conn = get_connection(config.DB_PATH)
|
conn = get_connection(config.DB_PATH)
|
||||||
try:
|
try:
|
||||||
|
if args.repass:
|
||||||
|
result = run_repass(conn)
|
||||||
|
logger.info(
|
||||||
|
f"repass завершён: телефонов={result['phones_fixed']}, "
|
||||||
|
f"доменов={result['domains_fixed']}, score={result['scores_changed']}/{result['total']}"
|
||||||
|
)
|
||||||
|
|
||||||
# Только статистика — выходим сразу
|
# Только статистика — выходим сразу
|
||||||
if args.stats:
|
if args.stats:
|
||||||
stats = get_stats(conn)
|
stats = get_stats(conn)
|
||||||
@@ -956,6 +1035,8 @@ def main():
|
|||||||
or args.find_sites
|
or args.find_sites
|
||||||
or args.export_master
|
or args.export_master
|
||||||
or args.export_run is not None
|
or args.export_run is not None
|
||||||
|
or args.delete_db
|
||||||
|
or args.repass
|
||||||
):
|
):
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user