Make monitoring PF API-only for Portal

This commit is contained in:
Grendgi
2026-06-05 09:56:07 +03:00
parent 2ff44091b5
commit 8bdac8b15b
14 changed files with 335 additions and 973 deletions

View File

@@ -1,27 +1,21 @@
"""FastAPI web app — UI for managing projects, competitor listings, employees."""
"""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 timedelta
from pathlib import Path
from datetime import datetime
from typing import Any
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from urllib.parse import quote
from fastapi.templating import Jinja2Templates
from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session, joinedload
from app.auth import (
ADMIN_COOKIE,
COOKIE_MAX_AGE,
admin_configured,
admin_token,
pin_ok,
request_is_admin,
)
from app.config import BASE_DIR, settings
from app.config import settings
from app.db import get_db, init_db
from app.models import CompetitorListing, DealType, Employee, Project
from app.models import CompetitorListing, DealType, Employee, ListingStatus, Project
from app.services.monitor import (
BAYUT_ENABLED,
add_competitor_url,
@@ -31,31 +25,53 @@ from app.services.monitor import (
suggest_similar,
)
app = FastAPI(title="DLD Monitor")
templates = Jinja2Templates(directory=str(Path(BASE_DIR) / "app" / "templates"))
# Timestamps are stored as naive UTC (datetime.utcnow). Show them in Moscow time
# (UTC+3) in the UI via the `msk` Jinja filter.
_MSK_OFFSET = timedelta(hours=3)
app = FastAPI(title="Monitoring PF")
def _to_msk(dt, fmt: str = "%Y-%m-%d %H:%M"):
if dt is None:
return ""
return (dt + _MSK_OFFSET).strftime(fmt)
class EmployeeCreate(BaseModel):
name: str = Field(..., 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)
templates.env.filters["msk"] = _to_msk
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)
def web_path(path: str = "/") -> str:
base = settings.public_base_path.rstrip("/")
if not path.startswith("/"):
path = "/" + path
return f"{base}{path}" if base else path
class ProjectCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
deal_type: DealType
owner_id: int
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
templates.env.globals["url_path"] = web_path
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")
@@ -63,242 +79,334 @@ def _startup() -> None:
init_db()
@app.middleware("http")
async def _admin_state(request: Request, call_next):
"""Expose admin status to every route and template (request.state.is_admin)."""
request.state.is_admin = request_is_admin(request)
request.state.admin_configured = admin_configured()
return await call_next(request)
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}
def admin_required(request: Request) -> None:
"""Dependency: block the request unless the session is in admin mode."""
if not request.state.is_admin:
raise HTTPException(403, "Действие доступно только администратору. Войдите в режим админа.")
@app.get("/")
def index() -> dict[str, str]:
return {"service": "monitoring-pf", "ui": "portal"}
def _safe_next(next_url: str) -> str:
"""Only allow same-site relative redirects after login."""
return next_url if next_url.startswith("/") and not next_url.startswith("//") else "/"
def _is_admin(request: Request) -> bool:
return request.headers.get("x-user-is-admin") == "1"
# --- Admin session --------------------------------------------------------
def _require_admin(request: Request) -> None:
if not _is_admin(request):
raise HTTPException(status_code=404, detail="not found")
@app.get("/admin/login", response_class=HTMLResponse)
def admin_login_page(request: Request, next: str = "/", error: str | None = None):
return templates.TemplateResponse(
"admin_login.html",
{"request": request, "next": _safe_next(next), "error": error, "flash": None},
def _clean(value: str | None) -> str | None:
value = (value or "").strip()
return value or None
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,
"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) -> dict[str, Any]:
return {"is_admin": _is_admin(request)}
@app.get("/api/v1/summary")
def summary(db: Session = Depends(get_db)) -> dict[str, Any]:
projects = db.query(Project).options(joinedload(Project.listings)).all()
employees = db.query(Employee).all()
listings = db.query(CompetitorListing).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(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
employees = (
db.query(Employee)
.options(joinedload(Employee.projects))
.order_by(Employee.name)
.all()
)
return [_employee_out(employee) for employee in employees]
@app.post("/admin/login")
def admin_login(request: Request, pin: str = Form(...), next: str = Form("/")):
dest = _safe_next(next)
if not pin_ok(pin):
return templates.TemplateResponse(
"admin_login.html",
{"request": request, "next": dest, "error": "Неверный PIN", "flash": None},
status_code=401,
)
resp = RedirectResponse(web_path(dest), status_code=303)
resp.set_cookie(
ADMIN_COOKIE, admin_token(), max_age=COOKIE_MAX_AGE,
httponly=True, samesite="lax",
@app.post("/api/v1/employees", status_code=201)
def employee_create(payload: EmployeeCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
employee = Employee(
name=payload.name.strip(),
tg_username=_clean(payload.tg_username).lstrip("@") if _clean(payload.tg_username) else None,
tg_chat_id=_clean(payload.tg_chat_id),
)
return resp
db.add(employee)
db.commit()
db.refresh(employee)
return _employee_out(employee)
@app.post("/admin/logout")
def admin_logout(next: str = Form("/")):
resp = RedirectResponse(web_path(_safe_next(next)), status_code=303)
resp.delete_cookie(ADMIN_COOKIE)
return resp
@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)
def _opt_float(s: str) -> float | None:
s = (s or "").strip()
if not s:
return None
try:
return float(s)
except ValueError:
return None
@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()
def _opt_int(s: str) -> int | None:
s = (s or "").strip()
if not s:
return None
try:
return int(s)
except ValueError:
return None
# --- Projects -------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def projects_list(request: Request, db: Session = Depends(get_db)):
@app.get("/api/v1/projects")
def projects_list(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
projects = (
db.query(Project)
.options(joinedload(Project.owner), joinedload(Project.listings))
.order_by(Project.created_at.desc())
.all()
)
return templates.TemplateResponse(
"projects_list.html", {"request": request, "projects": projects, "flash": None}
)
return [_project_out(project) for project in projects]
@app.get("/projects/new", response_class=HTMLResponse)
def new_project_form(request: Request, db: Session = Depends(get_db)):
employees = db.query(Employee).order_by(Employee.name).all()
return templates.TemplateResponse(
"project_form.html",
{
"request": request,
"employees": employees,
"no_employees": not employees,
"flash": None,
},
)
@app.post("/projects/new")
def create_project(
title: str = Form(...),
deal_type: str = Form(...),
owner_id: int = Form(...),
our_price: str = Form(""),
building: str = Form(""),
bedrooms: str = Form(""),
size_sqft: str = Form(""),
our_url: str = Form(""),
dld_permit: str = Form(""),
notes: str = Form(""),
db: Session = Depends(get_db),
):
owner = db.get(Employee, owner_id)
@app.post("/api/v1/projects", status_code=201)
def project_create(payload: ProjectCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
owner = db.get(Employee, payload.owner_id)
if not owner:
raise HTTPException(404, "Employee not found")
raise HTTPException(404, "employee not found")
project = Project(
title=title.strip(),
deal_type=DealType(deal_type),
our_price=_opt_float(our_price),
building=building.strip() or None,
bedrooms=_opt_int(bedrooms),
size_sqft=_opt_float(size_sqft),
our_url=our_url.strip() or None,
dld_permit=dld_permit.strip() or None,
notes=notes.strip() or None,
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()
return RedirectResponse(web_path(f"/projects/{project.id}"), status_code=303)
db.refresh(project)
return _project_out(project, detail=True)
@app.get("/projects/{project_id}", response_class=HTMLResponse)
def project_detail(
project_id: int,
request: Request,
error: str | None = None,
message: str | None = None,
db: Session = Depends(get_db),
):
@app.get("/api/v1/projects/{project_id}")
def project_detail(project_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
project = (
db.query(Project)
.options(joinedload(Project.owner), joinedload(Project.listings))
.options(
joinedload(Project.owner),
joinedload(Project.listings).joinedload(CompetitorListing.price_history),
)
.filter(Project.id == project_id)
.first()
)
if not project:
raise HTTPException(404, "Project not found")
return templates.TemplateResponse(
"project_detail.html",
{"request": request, "project": project, "error": error, "message": message, "flash": None},
)
raise HTTPException(404, "project not found")
return _project_out(project, detail=True)
@app.post("/projects/{project_id}/check")
def project_check_now(project_id: int, db: Session = Depends(get_db)):
if not db.get(Project, project_id):
raise HTTPException(404, "Project not found")
db.close()
run_check_for_project(project_id)
return RedirectResponse(web_path(f"/projects/{project_id}"), status_code=303)
@app.post("/projects/{project_id}/delete")
def project_delete(project_id: int, db: Session = Depends(get_db), _: None = Depends(admin_required)):
@app.patch("/api/v1/projects/{project_id}")
def project_update(project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db)) -> dict[str, Any]:
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, "Project not found")
raise HTTPException(404, "project not found")
data = payload.model_dump(exclude_unset=True)
if "owner_id" in data and data["owner_id"] is not None:
owner = db.get(Employee, data["owner_id"])
if not owner:
raise HTTPException(404, "employee not found")
project.owner_id = owner.id
for field in ("title", "deal_type", "our_price", "notes", "dld_permit", "building", "bedrooms", "size_sqft", "our_url"):
if field not in data or field == "owner_id":
continue
value = data[field]
if isinstance(value, str):
value = _clean(value)
setattr(project, field, value)
db.commit()
return project_detail(project_id, db)
@app.delete("/api/v1/projects/{project_id}", status_code=204)
def project_delete(project_id: int, request: Request, db: Session = Depends(get_db)) -> None:
_require_admin(request)
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, "project not found")
db.delete(project)
db.commit()
return RedirectResponse(web_path("/"), status_code=303)
# --- Competitor listings (per project) ------------------------------------
@app.post("/api/v1/projects/{project_id}/check")
def project_check_now(project_id: int, db: Session = Depends(get_db)) -> dict[str, int]:
if not db.get(Project, project_id):
raise HTTPException(404, "project not found")
db.close()
changes = run_check_for_project(project_id)
return {"changes": changes}
@app.post("/projects/{project_id}/listings")
def add_listing(project_id: int, url: str = Form(...), db: Session = Depends(get_db)):
@app.post("/api/v1/projects/{project_id}/listings", status_code=201)
def listing_create(project_id: int, payload: ListingCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, "Project not found")
listing, err = add_competitor_url(db, project, url)
raise HTTPException(404, "project not found")
listing, err = add_competitor_url(db, project, payload.url)
if err:
return RedirectResponse(web_path(f"/projects/{project_id}?error={quote(err)}"), status_code=303)
return RedirectResponse(
web_path(f"/projects/{project_id}?message=Добавлено"), status_code=303,
)
raise HTTPException(400, err)
return _listing_out(listing, with_history=True)
@app.post("/projects/{project_id}/listings/bulk")
def add_listings_bulk(
project_id: int,
urls: list[str] = Form(default=[]),
db: Session = Depends(get_db),
):
@app.post("/api/v1/projects/{project_id}/listings/bulk")
def listings_bulk(project_id: int, payload: ListingsBulkCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
project = db.get(Project, project_id)
if not project:
raise HTTPException(404, "Project not found")
if not urls:
return RedirectResponse(
web_path(f"/projects/{project_id}?error={quote('Ничего не выбрано')}"),
status_code=303,
)
result = add_competitor_urls(db, project, urls)
parts = [f"Добавлено: {result['added']}"]
if result["skipped"]:
parts.append(f"уже были: {result['skipped']}")
if result["errors"]:
parts.append(f"ошибок: {len(result['errors'])}")
msg = " · ".join(parts)
return RedirectResponse(
web_path(f"/projects/{project_id}?message={quote(msg)}"), status_code=303,
)
raise HTTPException(404, "project not found")
return add_competitor_urls(db, project, payload.urls)
@app.post("/listings/{listing_id}/delete")
def listing_delete(listing_id: int, db: Session = Depends(get_db), _: None = Depends(admin_required)):
@app.delete("/api/v1/listings/{listing_id}", status_code=204)
def listing_delete(listing_id: int, request: Request, db: Session = Depends(get_db)) -> None:
_require_admin(request)
listing = db.get(CompetitorListing, listing_id)
if not listing:
raise HTTPException(404, "Listing not found")
project_id = listing.project_id
raise HTTPException(404, "listing not found")
db.delete(listing)
db.commit()
return RedirectResponse(web_path(f"/projects/{project_id}"), status_code=303)
@app.get("/projects/{project_id}/suggest", response_class=HTMLResponse)
def project_suggest(project_id: int, request: Request, db: Session = Depends(get_db)):
@app.get("/api/v1/projects/{project_id}/suggest")
def project_suggest(project_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
project = (
db.query(Project)
.options(joinedload(Project.listings))
@@ -306,81 +414,14 @@ def project_suggest(project_id: int, request: Request, db: Session = Depends(get
.first()
)
if not project:
raise HTTPException(404, "Project not found")
our_permit = resolve_our_permit(project)
suggestions = suggest_similar(project, our_permit=our_permit)
return templates.TemplateResponse(
"suggest.html",
{
"request": request,
"project": project,
"suggestions": suggestions,
"our_permit": our_permit,
"bayut_enabled": BAYUT_ENABLED,
"flash": None,
raise HTTPException(404, "project not found")
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"]],
},
)
# --- Employees ------------------------------------------------------------
@app.get("/employees", response_class=HTMLResponse)
def employees_page(request: Request, db: Session = Depends(get_db)):
employees = (
db.query(Employee).options(joinedload(Employee.projects)).order_by(Employee.name).all()
)
return templates.TemplateResponse(
"employees.html", {"request": request, "employees": employees, "flash": None}
)
@app.post("/employees")
def employee_create(
name: str = Form(...),
tg_username: str = Form(""),
db: Session = Depends(get_db),
):
e = Employee(name=name.strip(), tg_username=tg_username.strip().lstrip("@") or None)
db.add(e)
db.commit()
return RedirectResponse(web_path("/employees"), status_code=303)
@app.post("/employees/{employee_id}/update")
def employee_update(
employee_id: int,
name: str = Form(...),
tg_username: str = Form(""),
tg_chat_id: str = Form(""),
db: Session = Depends(get_db),
_: None = Depends(admin_required),
):
e = db.get(Employee, employee_id)
if not e:
raise HTTPException(404, "Employee not found")
e.name = name.strip()
e.tg_username = tg_username.strip().lstrip("@") or None
new_chat_id = tg_chat_id.strip() or None
if new_chat_id and new_chat_id != e.tg_chat_id:
clash = (
db.query(Employee)
.filter(Employee.tg_chat_id == new_chat_id, Employee.id != e.id)
.first()
)
if clash:
raise HTTPException(400, f"chat_id {new_chat_id} уже привязан к сотруднику «{clash.name}»")
e.tg_chat_id = new_chat_id
db.commit()
return RedirectResponse(web_path("/employees"), status_code=303)
@app.post("/employees/{employee_id}/delete")
def employee_delete(employee_id: int, db: Session = Depends(get_db), _: None = Depends(admin_required)):
e = db.get(Employee, employee_id)
if not e:
raise HTTPException(404, "Employee not found")
if e.projects:
raise HTTPException(400, "Сотрудник связан с проектами — сначала переназначьте или удалите проекты")
db.delete(e)
db.commit()
return RedirectResponse(web_path("/employees"), status_code=303)
}