diff --git a/README.md b/README.md
index 0e8ea34..ed225c0 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,9 @@
# monitoring-pf
-Сервис мониторинга объявлений PropertyFinder/Bayut по DLD Permit Number для
-портала. Он хранит проекты, конкурирующие объявления и историю цен, а UI
-публикуется через portal по `/monitoring-pf`.
+Сервис мониторинга объявлений PropertyFinder по DLD Permit Number для портала.
+Он хранит проекты, конкурирующие объявления и историю цен. Пользовательский UI
+живёт в Portal: `portal/frontend/src/app/features/monitoring-pf`; этот сервис
+отдаёт только JSON API, Telegram bot и scheduler.
## Назначение
@@ -13,15 +14,17 @@
## Развёртывание
-Сервис рассчитан на запуск внутри портала/k8s. Манифесты лежат в `k8s/`.
-Перед применением заполните секреты в `k8s/secrets.yaml`.
+Сервис рассчитан на запуск только внутри k8s. Настройки лежат в
+`k8s/configmap.yaml`, секреты — в `k8s/secrets.yaml`. Локальный `.env` не
+используется.
```bash
kubectl apply -k k8s
```
-Standalone-скрипты локального Windows-запуска и compose-обвязка удалены, чтобы
-проект не дублировал инфраструктуру портала.
+API доступен через portal proxy `/api/monitoring-pf/api/v1`. Админские действия
+определяются ролью `admin` в Portal через `X-User-Is-Admin=1`; локального
+PIN-login больше нет.
## Структура
@@ -30,11 +33,10 @@ app/
├── config.py настройки окружения
├── db.py SQLAlchemy engine/session
├── models.py Employee, Project, CompetitorListing, PriceHistory
-├── web.py FastAPI роуты и UI
+├── web.py FastAPI JSON API для Portal
├── bot.py Telegram-бот
├── scheduler.py фоновый сканер
├── scrapers/ PropertyFinder/Bayut парсеры
├── services/ бизнес-логика и уведомления
-└── templates/ Jinja2 UI
k8s/ манифесты для портала
```
diff --git a/app/auth.py b/app/auth.py
deleted file mode 100644
index 4734d98..0000000
--- a/app/auth.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Lightweight admin gate for the web UI.
-
-A single shared PIN (`ADMIN_PIN` in .env) unlocks destructive actions
-(deleting projects/competitors/employees, editing employees) for the browser
-session via an HMAC-signed cookie. No per-user accounts — this is an internal
-localhost tool, not a public app.
-
-If `ADMIN_PIN` is empty the gate is OPEN (nothing restricted) so the tool is
-never bricked by a missing setting; set a PIN to actually restrict.
-"""
-from __future__ import annotations
-
-import hashlib
-import hmac
-
-from starlette.requests import Request
-
-from app.config import settings
-
-ADMIN_COOKIE = "dld_admin"
-COOKIE_MAX_AGE = 60 * 60 * 8 # 8 hours
-
-
-def admin_configured() -> bool:
- return bool(settings.admin_pin)
-
-
-def admin_token() -> str | None:
- """The cookie value that proves admin: HMAC(pin, marker). None if no PIN."""
- if not settings.admin_pin:
- return None
- return hmac.new(settings.admin_pin.encode(), b"dld-admin-v1", hashlib.sha256).hexdigest()
-
-
-def pin_ok(pin: str) -> bool:
- """Constant-time check of a submitted PIN against the configured one."""
- return bool(settings.admin_pin) and hmac.compare_digest((pin or "").strip(), settings.admin_pin)
-
-
-def request_is_admin(request: Request) -> bool:
- if not settings.admin_pin:
- return True # gate not configured → open
- token = request.cookies.get(ADMIN_COOKIE)
- expected = admin_token()
- return bool(token and expected and hmac.compare_digest(token, expected))
diff --git a/app/bot.py b/app/bot.py
index 8c0ac05..c85cd6b 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -167,7 +167,7 @@ async def cmd_check(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
def main() -> None:
if not settings.tg_bot_token:
- raise SystemExit("TG_BOT_TOKEN не задан в .env")
+ 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))
diff --git a/app/config.py b/app/config.py
index 5ccab5a..17c96e8 100644
--- a/app/config.py
+++ b/app/config.py
@@ -25,11 +25,7 @@ def _resolve_sqlite_url(url: str) -> str:
class Settings(BaseSettings):
- model_config = SettingsConfigDict(
- env_file=BASE_DIR / ".env",
- env_file_encoding="utf-8",
- extra="ignore",
- )
+ model_config = SettingsConfigDict(extra="ignore")
tg_bot_token: str = ""
web_host: str = "127.0.0.1"
@@ -38,9 +34,6 @@ class Settings(BaseSettings):
scrape_interval_hours: int = 4
database_url: str = f"sqlite:///{DATA_DIR / 'monitor.db'}"
admin_chat_id: str = ""
- # Shared PIN that unlocks destructive web actions (delete/edit). Empty = gate
- # disabled (everything allowed). See app/auth.py.
- admin_pin: str = ""
def model_post_init(self, __context) -> None:
self.database_url = _resolve_sqlite_url(self.database_url)
diff --git a/app/templates/admin_login.html b/app/templates/admin_login.html
deleted file mode 100644
index 2dd446a..0000000
--- a/app/templates/admin_login.html
+++ /dev/null
@@ -1,29 +0,0 @@
-{% extends "base.html" %}
-{% block title %}Вход администратора — DLD Monitor{% endblock %}
-{% block content %}
-
-
-
-
-
-
🔒 Режим администратора
-
- Введите PIN, чтобы разблокировать удаление проектов, конкурентов и
- редактирование/удаление сотрудников. Сессия — на 8 часов.
-
- {% if error %}
{{ error }}
{% endif %}
-
-
← Отмена
-
-
-
-
-
-{% endblock %}
diff --git a/app/templates/base.html b/app/templates/base.html
deleted file mode 100644
index 6630823..0000000
--- a/app/templates/base.html
+++ /dev/null
@@ -1,88 +0,0 @@
-
-
-
-
-{% block title %}DLD Monitor{% endblock %}
-
-
-
-{% block head %}{% endblock %}
-
-
-
-
-
🏠 DLD Monitor
-
-
-
-
-
-
-
+ Проект
-
- {% if request.state.is_admin %}
- {% if request.state.admin_configured %}
-
🔓 админ
-
- {% else %}
-
⚠ без PIN
- {% endif %}
- {% else %}
-
🔒 Админ
- {% endif %}
-
-
-
-
-
-
- {% if flash %}
-
{{ flash }}
- {% endif %}
- {% block content %}{% endblock %}
-
-
-DLD Monitor · HOME LIGA REAL ESTATE
-
-
-{% block scripts %}{% endblock %}
-
-
diff --git a/app/templates/employees.html b/app/templates/employees.html
deleted file mode 100644
index 15e6d3e..0000000
--- a/app/templates/employees.html
+++ /dev/null
@@ -1,86 +0,0 @@
-{% extends "base.html" %}
-{% block title %}Сотрудники — DLD Monitor{% endblock %}
-{% block content %}
-
-Сотрудники
-
-
-
Как подключить Telegram:
-
- Сотрудник пишет боту в Telegram команду /whoami — бот вернёт его chat_id.
- Вставьте этот chat_id в поле справа от имени и нажмите «Сохранить».
-
- Если сотрудник пока не знает свой chat_id — пусть просто отправит
/start, бот ответит и пришлёт id.
-
-
-
-
-{% if not request.state.is_admin %}
-
- 🔒 Редактирование и удаление сотрудников доступно только в режиме админа.
-
Войти .
-
-{% endif %}
-
-
-
-
- Имя @username Chat ID Проектов {% if request.state.is_admin %} {% endif %}
-
-
- {% for e in employees %}
- {% if request.state.is_admin %}
-
-
- {% else %}
-
- {{ e.name }}
- {% if e.tg_username %}@{{ e.tg_username }}{% else %}—{% endif %}
-
- {% if e.tg_chat_id %}{{ e.tg_chat_id }} ✓
- {% else %}не задан {% endif %}
-
- {{ e.projects|length }}
-
- {% endif %}
- {% endfor %}
-
-
-
-
-{% endblock %}
diff --git a/app/templates/project_detail.html b/app/templates/project_detail.html
deleted file mode 100644
index 1c6bf88..0000000
--- a/app/templates/project_detail.html
+++ /dev/null
@@ -1,136 +0,0 @@
-{% extends "base.html" %}
-{% block title %}{{ project.title }} — DLD Monitor{% endblock %}
-{% block content %}
-
-
-
-
{{ project.title }}
-
- {% if project.deal_type.value == 'sale' %}
- Продажа
- {% else %}
- Аренда
- {% endif %}
- · Владелец: {{ project.owner.name }}
- {% if project.last_checked_at %}
- · Проверено: {{ project.last_checked_at | msk }} МСК
- {% endif %}
-
-
- {% if project.building %}🏢 {{ project.building }}{% endif %}
- {% if project.bedrooms is not none %} · 🛏️ {{ project.bedrooms }} BR{% endif %}
- {% if project.size_sqft %} · 📐 {{ "{:,.0f}".format(project.size_sqft).replace(",", " ") }} sqft{% endif %}
- {% if project.dld_permit %} · permit: {{ project.dld_permit }}{% endif %}
-
- {% if project.our_price %}
-
Наша цена: {{ "{:,.0f}".format(project.our_price).replace(",", " ") }} AED
- {% endif %}
- {% if project.our_url %}
-
- {% endif %}
- {% if project.notes %}
{{ project.notes }}
{% endif %}
-
-
-
- Проверить сейчас
-
- {% if request.state.is_admin %}
-
- Удалить
-
- {% endif %}
-
-
-
-{% if error %}
- {{ error }}
-{% endif %}
-{% if message %}
- {{ message }}
-{% endif %}
-
-
-
-
-
-
-
- + Добавить конкурента
-
-
- {% if project.our_url %}
-
-
- 🔍 Подобрать похожие на PropertyFinder
-
-
- — по зданию из вашего объявления{% if project.bedrooms is not none %}, {{ project.bedrooms }} BR{% endif %};
- совпадения по DLD permit — первыми. Займёт ~15–20 сек.
-
-
- {% else %}
-
- 🔍 «Подобрать похожие» появится, когда у проекта заполнены наше объявление (URL) и спальни .
-
- {% endif %}
-
-
-Отслеживаемые конкуренты ({{ project.listings|length }})
-
-{% if not project.listings %}
-
- Пока ничего не отслеживается. Добавьте URL объявления конкурента выше.
-
-{% else %}
- {% for l in project.listings %}
-
-
-
-
-
- {{ 'PropertyFinder' if l.source.value == 'propertyfinder' else 'Bayut' }}
-
- {% if l.status.value == 'removed' %}
Удалено {% endif %}
-
-
- Брокер: {{ l.agent_name or '—' }} ({{ l.agency_name or '—' }})
- Добавлено: {{ l.first_seen_at | msk }} ·
- Последний раз видели активным: {{ l.last_seen_at | msk }} (МСК)
-
-
-
-
- {% if l.current_price %}
- {{ "{:,.0f}".format(l.current_price).replace(",", " ") }} {{ l.currency or 'AED' }}
- {% else %}—{% endif %}
-
- {% if request.state.is_admin %}
-
- Удалить
-
- {% endif %}
-
-
-
- {% if l.price_history|length > 1 %}
-
- История цены ({{ l.price_history|length }} записей)
-
- {% for h in l.price_history %}
- {{ h.recorded_at | msk }} —
- {% if h.price %}{{ "{:,.0f}".format(h.price).replace(",", " ") }} AED{% else %}—{% endif %}
- {% endfor %}
-
-
- {% endif %}
-
-
- {% endfor %}
-{% endif %}
-
-{% endblock %}
diff --git a/app/templates/project_form.html b/app/templates/project_form.html
deleted file mode 100644
index 2b87510..0000000
--- a/app/templates/project_form.html
+++ /dev/null
@@ -1,81 +0,0 @@
-{% extends "base.html" %}
-{% block title %}Новый проект — DLD Monitor{% endblock %}
-{% block content %}
-
-Новый проект
-
-{% if no_employees %}
-
- Сначала добавьте хотя бы одного
сотрудника — он будет получать уведомления в Telegram.
-
-{% endif %}
-
-
-
- Название квартиры / лота
-
-
-
-
-
- Тип сделки
-
- Продажа
- Аренда
-
-
-
- Наша цена (AED)
-
-
-
- Владелец (сотрудник)
-
- {% for e in employees %}
- {{ e.name }}{% if e.tg_chat_id %} ✓ TG{% endif %}
- {% endfor %}
-
-
-
-
-
-
- Поля ниже опциональны, но позволят системе предлагать «похожие» объявления конкурентов с PF и Bayut.
-
-
-
-
-
-
-
- Заметка
-
-
-
- Создать
- Отмена
-
-
-{% endblock %}
diff --git a/app/templates/projects_list.html b/app/templates/projects_list.html
deleted file mode 100644
index e77d502..0000000
--- a/app/templates/projects_list.html
+++ /dev/null
@@ -1,67 +0,0 @@
-{% extends "base.html" %}
-{% block title %}Проекты — DLD Monitor{% endblock %}
-{% block content %}
-
-
-
Наши проекты
-
+ Новый проект
-
-
-{% if projects %}
-
-
{{ projects|length }}
проектов
-
{{ projects|map(attribute='listings')|map('length')|sum }}
конкурентов
-
-{% endif %}
-
-{% if not projects %}
-
- Пока ни одного проекта.
Добавьте первый .
-
-{% else %}
-
-
-
-
- Название
- Здание
- Тип
- Наша цена
- Конкуренты
- Владелец
- Последняя проверка (МСК)
-
-
-
-
- {% for p in projects %}
-
- {{ p.title }}
-
- {% if p.building %}{{ p.building }}{% else %}—{% endif %}
- {% if p.bedrooms is not none %} · {{ p.bedrooms }}BR{% endif %}
-
-
- {% if p.deal_type.value == 'sale' %}
- Продажа
- {% else %}
- Аренда
- {% endif %}
-
- {% if p.our_price %}{{ "{:,.0f}".format(p.our_price).replace(",", " ") }} AED{% else %}—{% endif %}
- {{ p.listings|length }}
- {{ p.owner.name if p.owner else '—' }}
- {{ p.last_checked_at | msk }}
-
-
- Проверить
-
-
-
- {% endfor %}
-
-
-
-{% endif %}
-
-{% endblock %}
diff --git a/app/templates/suggest.html b/app/templates/suggest.html
deleted file mode 100644
index 6f73d4b..0000000
--- a/app/templates/suggest.html
+++ /dev/null
@@ -1,138 +0,0 @@
-{% extends "base.html" %}
-{% block title %}Похожие — {{ project.title }}{% endblock %}
-
-{% block head %}
-
-{% endblock %}
-
-{% block content %}
-
-
-
-Подсказки похожих объявлений
-
- Ищем объявления в том же здании{% if project.bedrooms is not none %}, {{ project.bedrooms }} спален{% endif %},
- тип сделки: {{ project.deal_type.value }}.
- {% if our_permit %}Совпадение по DLD permit ({{ our_permit }}) — это тот же объект, такие показаны первыми. {% endif %}
- Отметьте подходящие объявления галочкой (можно несколько) и нажмите «Отслеживать выбранные» — они попадут в трекинг.
-
-
-
-{% for src_key, src_label in [('propertyfinder', 'PropertyFinder'), ('bayut', 'Bayut')] %}
-
- {{ src_label }}
- {% if src_key == 'bayut' and not bayut_enabled %}
- — ⏸ временно отключён
- {% else %}
- — найдено {{ suggestions[src_key]|length }}
- {% endif %}
-
- {% if src_key == 'bayut' and not bayut_enabled %}
- Bayut перешёл на защищённый рендеринг — поиск и трекинг временно недоступны.
- {% elif not suggestions[src_key] %}
- Ничего не нашли (или площадка заблокировала запрос).
- {% else %}
- {% for s in suggestions[src_key] %}
- {% set same_permit = our_permit and s.permit_number and s.permit_number == our_permit %}
-
-
-
-
-
-
-
- {% if same_permit %}
🎯 тот же permit {% endif %}
-
{{ s.title or 'без названия' }}
-
- {{ s.agent_name or '—' }} ({{ s.agency_name or '—' }})
- {% if s.permit_number %} · permit {{ s.permit_number }}{% endif %}
-
-
-
- {% if s.price %}{{ "{:,.0f}".format(s.price).replace(",", " ") }} {{ s.currency or 'AED' }}{% else %}—{% endif %}
-
-
-
-
-
-
- {% endfor %}
- {% endif %}
-{% endfor %}
-
-
-
-
-
- Выбрать все
-
-
- + Отслеживать выбранные (0 )
-
-
-
-
-
-{% endblock %}
-
-{% block scripts %}
-
-{% endblock %}
diff --git a/app/web.py b/app/web.py
index 4a7aa31..b3470f9 100644
--- a/app/web.py
+++ b/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)
+ }
diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml
index d89ae36..085c796 100644
--- a/k8s/secrets.yaml
+++ b/k8s/secrets.yaml
@@ -7,4 +7,3 @@ type: Opaque
stringData:
TG_BOT_TOKEN: "CHANGE_ME"
ADMIN_CHAT_ID: ""
- ADMIN_PIN: "CHANGE_ME"
diff --git a/requirements.txt b/requirements.txt
index a878a49..7cb0320 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,5 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
-jinja2==3.1.4
-python-multipart==0.0.20
sqlalchemy==2.0.36
httpx==0.28.1
brotli==1.2.0 # lets httpx decode Content-Encoding: br (Bayut serves Brotli)
@@ -9,6 +7,5 @@ beautifulsoup4==4.12.3
lxml==5.3.0
apscheduler==3.11.0
python-telegram-bot==21.9
-python-dotenv==1.0.1
pydantic==2.10.4
pydantic-settings==2.7.0