Move monitoring PF infrastructure to Go

This commit is contained in:
Grendgi
2026-06-05 10:18:42 +03:00
parent ccfb261e7f
commit ed2a6c7f58
21 changed files with 2152 additions and 814 deletions

View File

@@ -1,228 +0,0 @@
"""Telegram bot — registers employees by chat_id and lets them trigger checks.
Run as a separate process: `python -m app.bot`.
Bot commands (set via @BotFather → /setcommands):
start - Подключить себя как сотрудника
list - Список своих проектов
check - Проверить все мои проекты сейчас
whoami - Показать свой chat_id
"""
from __future__ import annotations
import asyncio
import logging
from sqlalchemy.orm import joinedload
from telegram import Update
from telegram.ext import (
Application,
CommandHandler,
ContextTypes,
)
from app.config import settings
from app.db import SessionLocal, init_db
from app.models import Employee, Project
from app.services.monitor import run_check_for_project
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__)
def _portal_user_code(context: ContextTypes.DEFAULT_TYPE) -> str | None:
if not context.args:
return None
code = context.args[0].strip()
return code or None
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.effective_user or not update.effective_chat:
return
user = update.effective_user
chat_id = str(update.effective_chat.id)
username = user.username
portal_user_id = _portal_user_code(context)
db = SessionLocal()
try:
existing = (
db.query(Employee).filter(Employee.tg_chat_id == chat_id).first()
)
if existing:
if portal_user_id and existing.portal_user_id and existing.portal_user_id != portal_user_id:
await update.message.reply_text(
"Этот Telegram уже подключен к другому пользователю Portal.",
)
return
if portal_user_id and not existing.portal_user_id:
clash = db.query(Employee).filter(Employee.portal_user_id == portal_user_id).first()
if clash and clash.id != existing.id:
await update.message.reply_text(
"Этот пользователь Portal уже подключен к другому Telegram.",
)
return
existing.portal_user_id = portal_user_id
existing.tg_username = username
db.commit()
await update.message.reply_text(
f"✅ Вы уже подключены как <b>{existing.name}</b>.\n"
f"chat_id: <code>{chat_id}</code>",
parse_mode="HTML",
)
return
if portal_user_id:
employee = db.query(Employee).filter(Employee.portal_user_id == portal_user_id).first()
name = (user.full_name or username or f"user_{chat_id}").strip()
if employee:
if employee.tg_chat_id and employee.tg_chat_id != chat_id:
await update.message.reply_text(
"Этот пользователь Portal уже подключен к другому Telegram.",
)
return
employee.name = employee.name or name
employee.tg_chat_id = chat_id
employee.tg_username = username
else:
employee = Employee(
name=name,
portal_user_id=portal_user_id,
tg_chat_id=chat_id,
tg_username=username,
)
db.add(employee)
db.commit()
await update.message.reply_text(
f"✅ Привет, <b>{name}</b>! Telegram подключен к вашему аккаунту Portal.\n"
f"Теперь можно добавлять объекты мониторинга в Portal.",
parse_mode="HTML",
)
return
# Try to find by username (admin pre-created employee w/o chat_id)
if username:
placeholder = (
db.query(Employee)
.filter(Employee.tg_username == username, Employee.tg_chat_id.is_(None))
.first()
)
if placeholder:
placeholder.tg_chat_id = chat_id
db.commit()
await update.message.reply_text(
f"✅ Привет, <b>{placeholder.name}</b>! Вы успешно подключены.\n"
f"Уведомления будут приходить сюда.",
parse_mode="HTML",
)
return
await update.message.reply_text(
"Откройте Portal → Мониторинг PF и нажмите подключение Telegram.\n"
"Бот должен получить команду вида:\n"
"<code>/start ваш_код_из_Portal</code>",
parse_mode="HTML",
)
finally:
db.close()
async def cmd_whoami(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
if not update.effective_chat:
return
chat_id = str(update.effective_chat.id)
db = SessionLocal()
try:
e = db.query(Employee).filter(Employee.tg_chat_id == chat_id).first()
if e:
await update.message.reply_text(
f"Вы: <b>{e.name}</b>\nchat_id: <code>{chat_id}</code>",
parse_mode="HTML",
)
else:
await update.message.reply_text(
f"Вы пока не подключены. Отправьте /start.\nchat_id: <code>{chat_id}</code>",
parse_mode="HTML",
)
finally:
db.close()
async def cmd_list(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
if not update.effective_chat:
return
chat_id = str(update.effective_chat.id)
db = SessionLocal()
try:
e = (
db.query(Employee)
.options(joinedload(Employee.projects))
.filter(Employee.tg_chat_id == chat_id)
.first()
)
if not e:
await update.message.reply_text("Сначала /start.")
return
if not e.projects:
await update.message.reply_text("У вас пока нет проектов.")
return
lines = [f"<b>Ваши проекты ({len(e.projects)}):</b>"]
for p in e.projects:
lines.append(
f"• #{p.id} {p.title} — <code>{p.dld_permit}</code> "
f"({p.deal_type.value})"
)
await update.message.reply_text("\n".join(lines), parse_mode="HTML")
finally:
db.close()
async def cmd_check(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
if not update.effective_chat:
return
chat_id = str(update.effective_chat.id)
db = SessionLocal()
try:
e = (
db.query(Employee)
.options(joinedload(Employee.projects))
.filter(Employee.tg_chat_id == chat_id)
.first()
)
if not e:
await update.message.reply_text("Сначала /start.")
return
if not e.projects:
await update.message.reply_text("У вас нет проектов.")
return
ids = [p.id for p in e.projects]
finally:
db.close()
await update.message.reply_text(f"⏳ Запускаю проверку {len(ids)} проектов…")
total_changes = 0
for pid in ids:
try:
total_changes += await asyncio.to_thread(run_check_for_project, pid)
except Exception as ex:
logger.exception("check failed for %s: %s", pid, ex)
await update.message.reply_text(f"✅ Готово. Изменений: {total_changes}")
def main() -> None:
if not settings.tg_bot_token:
raise SystemExit("TG_BOT_TOKEN не задан в k8s/secrets.yaml")
init_db()
app = Application.builder().token(settings.tg_bot_token).build()
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("whoami", cmd_whoami))
app.add_handler(CommandHandler("list", cmd_list))
app.add_handler(CommandHandler("check", cmd_check))
logger.info("Bot polling…")
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()

View File

@@ -28,9 +28,6 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
tg_bot_token: str = ""
web_host: str = "127.0.0.1"
web_port: int = 8000
public_base_path: str = ""
scrape_interval_hours: int = 4
database_url: str = f"sqlite:///{DATA_DIR / 'monitor.db'}"
admin_chat_id: str = ""

View File

@@ -1,57 +0,0 @@
"""Background scheduler — runs run_check_all() every N hours.
Run as a separate process: `python -m app.scheduler`.
"""
from __future__ import annotations
import logging
import time
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.interval import IntervalTrigger
from app.config import settings
from app.db import init_db
from app.services.monitor import run_check_all
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__)
def job() -> None:
logger.info("Scheduled scan starting…")
start = time.time()
summary = run_check_all()
elapsed = time.time() - start
total_changes = sum(c for c in summary.values() if c > 0)
logger.info(
"Scan done in %.1fs. Projects: %d, total changes: %d",
elapsed, len(summary), total_changes,
)
def main() -> None:
init_db()
hours = max(1, settings.scrape_interval_hours)
scheduler = BlockingScheduler(timezone="UTC")
scheduler.add_job(
job,
trigger=IntervalTrigger(hours=hours),
# Omit next_run_time so APScheduler defaults the first run to now+interval
# (i.e. don't fire immediately at startup, fire after one interval, then
# every interval). Passing next_run_time=None instead creates the job in a
# PAUSED state and it never fires — that was the bug.
id="periodic-scan",
max_instances=1,
coalesce=True,
)
logger.info("Scheduler started — interval %d hour(s).", hours)
try:
scheduler.start()
except (KeyboardInterrupt, SystemExit):
logger.info("Scheduler stopped.")
if __name__ == "__main__":
main()

View File

@@ -1,498 +0,0 @@
"""FastAPI API for Monitoring PF.
The user interface lives in Portal. This service exposes only JSON endpoints
and trusts Portal-provided headers for admin state.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session, joinedload
from app.config import settings
from app.db import get_db, init_db
from app.models import CompetitorListing, DealType, Employee, ListingStatus, Project
from app.services.monitor import (
BAYUT_ENABLED,
add_competitor_url,
add_competitor_urls,
resolve_our_permit,
run_check_for_project,
suggest_similar,
)
app = FastAPI(title="Monitoring PF")
class EmployeeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
portal_user_id: str | None = Field(None, max_length=100)
tg_username: str | None = Field(None, max_length=200)
tg_chat_id: str | None = Field(None, max_length=64)
class EmployeeUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
tg_username: str | None = Field(None, max_length=200)
tg_chat_id: str | None = Field(None, max_length=64)
class ProjectCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
deal_type: DealType
owner_id: int | None = None
our_price: float | None = None
notes: str | None = None
dld_permit: str | None = Field(None, max_length=100)
building: str | None = Field(None, max_length=300)
bedrooms: int | None = None
size_sqft: float | None = None
our_url: str | None = None
class ProjectUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=300)
deal_type: DealType | None = None
owner_id: int | None = None
our_price: float | None = None
notes: str | None = None
dld_permit: str | None = Field(None, max_length=100)
building: str | None = Field(None, max_length=300)
bedrooms: int | None = None
size_sqft: float | None = None
our_url: str | None = None
class ListingCreate(BaseModel):
url: str = Field(..., min_length=1)
class ListingsBulkCreate(BaseModel):
urls: list[str] = Field(default_factory=list)
@app.on_event("startup")
def _startup() -> None:
init_db()
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/")
def index() -> dict[str, str]:
return {"service": "monitoring-pf", "ui": "portal"}
def _is_admin(request: Request) -> bool:
return request.headers.get("x-user-is-admin") == "1"
def _require_admin(request: Request) -> None:
if not _is_admin(request):
raise HTTPException(status_code=404, detail="not found")
def _clean(value: str | None) -> str | None:
value = (value or "").strip()
return value or None
def _portal_user_id(request: Request) -> str | None:
return _clean(request.headers.get("x-user-id"))
def _telegram_start_command(portal_user_id: str | None) -> str | None:
return f"/start {portal_user_id}" if portal_user_id else None
def _telegram_start_link(portal_user_id: str | None) -> str | None:
username = settings.tg_bot_username.strip().lstrip("@")
if not username or not portal_user_id:
return None
return f"https://t.me/{username}?start={portal_user_id}"
def _current_employee(request: Request, db: Session, *, required: bool = True) -> Employee | None:
portal_user_id = _portal_user_id(request)
if not portal_user_id:
if required:
raise HTTPException(status_code=401, detail="portal user is not available")
return None
employee = db.query(Employee).filter(Employee.portal_user_id == portal_user_id).first()
if (not employee or not employee.tg_chat_id) and required:
raise HTTPException(status_code=403, detail="Сначала авторизуйтесь в Telegram-боте Monitoring PF")
return employee
def _owned_project(request: Request, db: Session, project_id: int, *, with_detail: bool = False) -> Project:
employee = _current_employee(request, db)
query = db.query(Project).filter(Project.id == project_id, Project.owner_id == employee.id)
if with_detail:
query = query.options(
joinedload(Project.owner),
joinedload(Project.listings).joinedload(CompetitorListing.price_history),
)
else:
query = query.options(joinedload(Project.owner), joinedload(Project.listings))
project = query.first()
if not project:
raise HTTPException(404, "project not found")
return project
def _dt(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _employee_out(employee: Employee) -> dict[str, Any]:
return {
"id": employee.id,
"name": employee.name,
"portal_user_id": employee.portal_user_id,
"tg_chat_id": employee.tg_chat_id,
"tg_username": employee.tg_username,
"projects_total": len(employee.projects or []),
"created_at": _dt(employee.created_at),
}
def _history_out(listing: CompetitorListing) -> list[dict[str, Any]]:
return [
{"id": h.id, "price": h.price, "recorded_at": _dt(h.recorded_at)}
for h in listing.price_history
]
def _listing_out(listing: CompetitorListing, *, with_history: bool = False) -> dict[str, Any]:
out = {
"id": listing.id,
"project_id": listing.project_id,
"source": listing.source.value,
"external_id": listing.external_id,
"url": listing.url,
"title": listing.title,
"agent_name": listing.agent_name,
"agency_name": listing.agency_name,
"current_price": listing.current_price,
"currency": listing.currency,
"status": listing.status.value,
"first_seen_at": _dt(listing.first_seen_at),
"last_seen_at": _dt(listing.last_seen_at),
}
if with_history:
out["price_history"] = _history_out(listing)
return out
def _project_stats(project: Project) -> dict[str, Any]:
listings = project.listings or []
active = [l for l in listings if l.status == ListingStatus.ACTIVE]
prices = [l.current_price for l in active if l.current_price is not None]
return {
"listings_total": len(listings),
"listings_active": len(active),
"listings_removed": len(listings) - len(active),
"min_competitor_price": min(prices) if prices else None,
}
def _project_out(project: Project, *, detail: bool = False) -> dict[str, Any]:
out = {
"id": project.id,
"title": project.title,
"deal_type": project.deal_type.value,
"our_price": project.our_price,
"notes": project.notes,
"dld_permit": project.dld_permit,
"building": project.building,
"bedrooms": project.bedrooms,
"size_sqft": project.size_sqft,
"our_url": project.our_url,
"owner_id": project.owner_id,
"owner": _employee_out(project.owner) if project.owner else None,
"created_at": _dt(project.created_at),
"last_checked_at": _dt(project.last_checked_at),
**_project_stats(project),
}
if detail:
out["listings"] = [_listing_out(l, with_history=True) for l in project.listings]
return out
def _suggestion_out(item: Any) -> dict[str, Any]:
return {
"source": item.source,
"external_id": item.external_id,
"url": item.url,
"title": item.title,
"price": item.price,
"currency": item.currency,
"permit_number": item.permit_number,
"agent_name": item.agent_name,
"agency_name": item.agency_name,
"is_active": item.is_active,
}
@app.get("/api/v1/access/me")
def access_me(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
portal_user_id = _portal_user_id(request)
employee = _current_employee(request, db, required=False)
return {
"is_admin": _is_admin(request),
"portal_user_id": portal_user_id,
"telegram_linked": bool(employee and employee.tg_chat_id),
"employee": _employee_out(employee) if employee else None,
"telegram_bot_username": settings.tg_bot_username.strip().lstrip("@") or None,
"telegram_start_command": _telegram_start_command(portal_user_id),
"telegram_start_link": _telegram_start_link(portal_user_id),
}
@app.get("/api/v1/summary")
def summary(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
employee = _current_employee(request, db, required=False)
if not employee or not employee.tg_chat_id:
projects = []
employees = []
listings = []
else:
projects = (
db.query(Project)
.options(joinedload(Project.listings))
.filter(Project.owner_id == employee.id)
.all()
)
employees = [employee]
listings = (
db.query(CompetitorListing)
.join(Project)
.filter(Project.owner_id == employee.id)
.all()
)
active = [l for l in listings if l.status == ListingStatus.ACTIVE]
return {
"projects_total": len(projects),
"employees_total": len(employees),
"listings_total": len(listings),
"listings_active": len(active),
"listings_removed": len(listings) - len(active),
"scrape_interval_hours": settings.scrape_interval_hours,
"bayut_enabled": BAYUT_ENABLED,
}
@app.get("/api/v1/employees")
def employees_list(request: Request, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
if _is_admin(request):
employees = (
db.query(Employee)
.options(joinedload(Employee.projects))
.order_by(Employee.name)
.all()
)
else:
employee = _current_employee(request, db, required=False)
employees = [employee] if employee and employee.tg_chat_id else []
return [_employee_out(employee) for employee in employees]
@app.post("/api/v1/employees", status_code=201)
def employee_create(
payload: EmployeeCreate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
_require_admin(request)
employee = Employee(
name=payload.name.strip(),
portal_user_id=_clean(payload.portal_user_id),
tg_username=_clean(payload.tg_username).lstrip("@") if _clean(payload.tg_username) else None,
tg_chat_id=_clean(payload.tg_chat_id),
)
db.add(employee)
db.commit()
db.refresh(employee)
return _employee_out(employee)
@app.patch("/api/v1/employees/{employee_id}")
def employee_update(
employee_id: int,
payload: EmployeeUpdate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
_require_admin(request)
employee = db.get(Employee, employee_id)
if not employee:
raise HTTPException(404, "employee not found")
if payload.name is not None:
employee.name = payload.name.strip()
if payload.tg_username is not None:
employee.tg_username = payload.tg_username.strip().lstrip("@") or None
if payload.tg_chat_id is not None:
chat_id = _clean(payload.tg_chat_id)
if chat_id and chat_id != employee.tg_chat_id:
clash = (
db.query(Employee)
.filter(Employee.tg_chat_id == chat_id, Employee.id != employee.id)
.first()
)
if clash:
raise HTTPException(400, f"chat_id already belongs to {clash.name}")
employee.tg_chat_id = chat_id
db.commit()
db.refresh(employee)
return _employee_out(employee)
@app.delete("/api/v1/employees/{employee_id}", status_code=204)
def employee_delete(employee_id: int, request: Request, db: Session = Depends(get_db)) -> None:
_require_admin(request)
employee = db.get(Employee, employee_id)
if not employee:
raise HTTPException(404, "employee not found")
if employee.projects:
raise HTTPException(400, "employee has projects")
db.delete(employee)
db.commit()
@app.get("/api/v1/projects")
def projects_list(request: Request, db: Session = Depends(get_db)) -> list[dict[str, Any]]:
employee = _current_employee(request, db)
projects = (
db.query(Project)
.options(joinedload(Project.owner), joinedload(Project.listings))
.filter(Project.owner_id == employee.id)
.order_by(Project.created_at.desc())
.all()
)
return [_project_out(project) for project in projects]
@app.post("/api/v1/projects", status_code=201)
def project_create(payload: ProjectCreate, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
owner = _current_employee(request, db)
project = Project(
title=payload.title.strip(),
deal_type=payload.deal_type,
owner_id=owner.id,
our_price=payload.our_price,
notes=_clean(payload.notes),
dld_permit=_clean(payload.dld_permit),
building=_clean(payload.building),
bedrooms=payload.bedrooms,
size_sqft=payload.size_sqft,
our_url=_clean(payload.our_url),
)
db.add(project)
db.commit()
db.refresh(project)
return _project_out(project, detail=True)
@app.get("/api/v1/projects/{project_id}")
def project_detail(project_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
project = _owned_project(request, db, project_id, with_detail=True)
return _project_out(project, detail=True)
@app.patch("/api/v1/projects/{project_id}")
def project_update(
project_id: int,
payload: ProjectUpdate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
project = _owned_project(request, db, project_id)
data = payload.model_dump(exclude_unset=True)
data.pop("owner_id", None)
for field in ("title", "deal_type", "our_price", "notes", "dld_permit", "building", "bedrooms", "size_sqft", "our_url"):
if field not in data:
continue
value = data[field]
if isinstance(value, str):
value = _clean(value)
setattr(project, field, value)
db.commit()
db.refresh(project)
return _project_out(_owned_project(request, db, project_id, with_detail=True), detail=True)
@app.delete("/api/v1/projects/{project_id}", status_code=204)
def project_delete(project_id: int, request: Request, db: Session = Depends(get_db)) -> None:
project = _owned_project(request, db, project_id)
db.delete(project)
db.commit()
@app.post("/api/v1/projects/{project_id}/check")
def project_check_now(project_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, int]:
_owned_project(request, db, project_id)
db.close()
changes = run_check_for_project(project_id)
return {"changes": changes}
@app.post("/api/v1/projects/{project_id}/listings", status_code=201)
def listing_create(
project_id: int,
payload: ListingCreate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
project = _owned_project(request, db, project_id)
listing, err = add_competitor_url(db, project, payload.url)
if err:
raise HTTPException(400, err)
return _listing_out(listing, with_history=True)
@app.post("/api/v1/projects/{project_id}/listings/bulk")
def listings_bulk(
project_id: int,
payload: ListingsBulkCreate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
project = _owned_project(request, db, project_id)
return add_competitor_urls(db, project, payload.urls)
@app.delete("/api/v1/listings/{listing_id}", status_code=204)
def listing_delete(listing_id: int, request: Request, db: Session = Depends(get_db)) -> None:
employee = _current_employee(request, db)
listing = (
db.query(CompetitorListing)
.join(Project)
.filter(CompetitorListing.id == listing_id, Project.owner_id == employee.id)
.first()
)
if not listing:
raise HTTPException(404, "listing not found")
db.delete(listing)
db.commit()
@app.get("/api/v1/projects/{project_id}/suggest")
def project_suggest(project_id: int, request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
project = _owned_project(request, db, project_id)
permit = resolve_our_permit(project)
suggestions = suggest_similar(project, our_permit=permit)
return {
"our_permit": permit,
"bayut_enabled": BAYUT_ENABLED,
"suggestions": {
"propertyfinder": [_suggestion_out(item) for item in suggestions["propertyfinder"]],
"bayut": [_suggestion_out(item) for item in suggestions["bayut"]],
},
}

136
app/worker.py Normal file
View File

@@ -0,0 +1,136 @@
"""Internal JSON worker for Go processes.
The Go API/bot/scheduler own infrastructure concerns. Python stays here for
PropertyFinder/Bayut scraping and the existing SQLAlchemy monitoring logic.
"""
from __future__ import annotations
import json
import sys
from typing import Any
from app.db import SessionLocal, init_db
from app.models import Project
from app.services.monitor import (
BAYUT_ENABLED,
add_competitor_url,
add_competitor_urls,
resolve_our_permit,
run_check_all,
run_check_for_project,
suggest_similar,
)
def _read_payload() -> dict[str, Any]:
raw = sys.stdin.read().strip()
if not raw:
return {}
return json.loads(raw)
def _write(payload: Any) -> None:
json.dump(payload, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
def _fail(message: str, status: int = 1) -> None:
_write({"error": message})
raise SystemExit(status)
def _suggestion_out(item: Any) -> dict[str, Any]:
return {
"source": item.source,
"external_id": item.external_id,
"url": item.url,
"title": item.title,
"price": item.price,
"currency": item.currency,
"permit_number": item.permit_number,
"agent_name": item.agent_name,
"agency_name": item.agency_name,
"is_active": item.is_active,
}
def cmd_add_listing(payload: dict[str, Any]) -> None:
project_id = int(payload.get("project_id") or 0)
url = str(payload.get("url") or "")
db = SessionLocal()
try:
project = db.get(Project, project_id)
if not project:
_fail("project not found")
listing, err = add_competitor_url(db, project, url)
if err:
_fail(err)
_write({"listing_id": listing.id})
finally:
db.close()
def cmd_add_listings(payload: dict[str, Any]) -> None:
project_id = int(payload.get("project_id") or 0)
urls = payload.get("urls") or []
db = SessionLocal()
try:
project = db.get(Project, project_id)
if not project:
_fail("project not found")
_write(add_competitor_urls(db, project, urls))
finally:
db.close()
def cmd_check_project(payload: dict[str, Any]) -> None:
project_id = int(payload.get("project_id") or 0)
_write({"changes": run_check_for_project(project_id)})
def cmd_check_all(_: dict[str, Any]) -> None:
summary = run_check_all()
_write({str(k): v for k, v in summary.items()})
def cmd_suggest(payload: dict[str, Any]) -> None:
project_id = int(payload.get("project_id") or 0)
db = SessionLocal()
try:
project = db.get(Project, project_id)
if not project:
_fail("project not found")
permit = resolve_our_permit(project)
suggestions = suggest_similar(project, our_permit=permit)
_write({
"our_permit": permit,
"bayut_enabled": BAYUT_ENABLED,
"suggestions": {
"propertyfinder": [_suggestion_out(item) for item in suggestions["propertyfinder"]],
"bayut": [_suggestion_out(item) for item in suggestions["bayut"]],
},
})
finally:
db.close()
COMMANDS = {
"add-listing": cmd_add_listing,
"add-listings": cmd_add_listings,
"check-project": cmd_check_project,
"check-all": cmd_check_all,
"suggest": cmd_suggest,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
_fail("unknown worker command")
init_db()
payload = _read_payload()
COMMANDS[sys.argv[1]](payload)
if __name__ == "__main__":
main()