Make monitoring PF API-only for Portal
This commit is contained in:
603
app/web.py
603
app/web.py
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user