499 lines
16 KiB
Python
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"]],
|
|
},
|
|
}
|