Move monitoring PF infrastructure to Go
This commit is contained in:
228
app/bot.py
228
app/bot.py
@@ -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()
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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()
|
||||
498
app/web.py
498
app/web.py
@@ -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
136
app/worker.py
Normal 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()
|
||||
Reference in New Issue
Block a user