Files
monitoring-pf/app/web.py

499 lines
16 KiB
Python

"""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"]],
},
}