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 %} - - - - -
- {% if flash %} -
{{ flash }}
- {% endif %} - {% block content %}{% endblock %} -
- - - - -{% 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: -
    -
  1. Сотрудник пишет боту в Telegram команду /whoami — бот вернёт его chat_id.
  2. -
  3. Вставьте этот chat_id в поле справа от имени и нажмите «Сохранить».
  4. -
- Если сотрудник пока не знает свой chat_id — пусть просто отправит /start, бот ответит и пришлёт id. -
- -
-
- -
-
- -
-
- -
-
- -{% if not request.state.is_admin %} -
- 🔒 Редактирование и удаление сотрудников доступно только в режиме админа. - Войти. -
-{% endif %} - -
- - - {% if request.state.is_admin %}{% endif %} - - - {% for e in employees %} - {% if request.state.is_admin %} - - - - - - - - - {% else %} - - - - - - - {% endif %} - {% endfor %} - -
Имя@usernameChat IDПроектов
- - - - -
- - {% if e.tg_chat_id %} - - {% endif %} -
-
{{ e.projects|length }} - - -
- -
-
{{ 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 }}
-
- -{% 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 %} -
Наше объявление: {{ 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.title or 'без названия' }} -
-
- Брокер: {{ 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 %} - -
-
- - -
- -
-
- - -
-
- - -
-
- - -
-
- -
-

- Поля ниже опциональны, но позволят системе предлагать «похожие» объявления конкурентов с 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 %} - - - - - - - - - - - {% endfor %} - -
НазваниеЗданиеТипНаша ценаКонкурентыВладелецПоследняя проверка (МСК)
{{ 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 }} -
- -
-
-
-{% 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 %} - - {% endfor %} - {% endif %} -{% endfor %} - -
-
-
- - -
- -
-
-
- -{% 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