Make monitoring PF API-only for Portal

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

View File

@@ -1,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/ манифесты для портала
```

View File

@@ -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))

View File

@@ -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))

View File

@@ -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)

View File

@@ -1,29 +0,0 @@
{% extends "base.html" %}
{% block title %}Вход администратора — DLD Monitor{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3">🔒 Режим администратора</h5>
<p class="text-muted small">
Введите PIN, чтобы разблокировать удаление проектов, конкурентов и
редактирование/удаление сотрудников. Сессия — на 8 часов.
</p>
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="post" action="{{ url_path('/admin/login') }}">
<input type="hidden" name="next" value="{{ next }}">
<div class="mb-3">
<input type="password" name="pin" required autofocus class="form-control"
placeholder="PIN администратора">
</div>
<button class="btn btn-primary w-100">Войти</button>
</form>
<a href="{{ url_path(next) }}" class="btn btn-link btn-sm w-100 mt-2">← Отмена</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,88 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{% block title %}DLD Monitor{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f4f5f9; }
.navbar-brand { letter-spacing: .3px; }
.nav-link.active { font-weight: 600; color: #0d6efd !important; }
.badge-sale { background: #198754; }
.badge-rent { background: #0d6efd; }
.card { border: 1px solid #e9ecef; }
.listing-card { border-left: 4px solid #dee2e6; }
.listing-card.removed { border-left-color: #dc3545; opacity: 0.65; }
.listing-card.active { border-left-color: #198754; }
.src-pf { color: #d63384; font-weight: 600; }
.src-bayut { color: #0d6efd; font-weight: 600; }
.price-up { color: #dc3545; }
.price-down { color: #198754; }
pre.permit { display: inline; background: #eee; padding: 2px 6px; border-radius: 4px; }
.stat-chip { background:#fff; border:1px solid #e9ecef; border-radius:.5rem; padding:.4rem .8rem; }
.stat-chip .n { font-size:1.25rem; font-weight:700; line-height:1; }
footer { color:#9aa0a6; font-size:.8rem; }
/* Mobile tweaks */
@media (max-width: 575.98px) {
body { background: #fff; }
.container { padding-left: .75rem; padding-right: .75rem; }
h3 { font-size: 1.35rem; }
.card-body { padding: .75rem; }
.fs-4 { font-size: 1.1rem !important; }
}
</style>
{% block head %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg bg-white border-bottom mb-4">
<div class="container">
<a class="navbar-brand fw-bold" href="{{ url_path('/') }}">🏠 DLD Monitor</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#mainnav" aria-controls="mainnav"
aria-expanded="false" aria-label="Меню">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainnav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a href="{{ url_path('/') }}" class="nav-link {{ 'active' if request.url.path == '/' or request.url.path.startswith('/projects') else '' }}">Проекты</a>
</li>
<li class="nav-item">
<a href="{{ url_path('/employees') }}" class="nav-link {{ 'active' if request.url.path.startswith('/employees') else '' }}">Сотрудники</a>
</li>
</ul>
<div class="d-flex flex-column flex-lg-row align-items-stretch align-items-lg-center gap-2">
<a href="{{ url_path('/projects/new') }}" class="btn btn-sm btn-primary">+ Проект</a>
<span class="vr d-none d-lg-block"></span>
{% if request.state.is_admin %}
{% if request.state.admin_configured %}
<span class="badge bg-success align-self-start align-self-lg-center">🔓 админ</span>
<form method="post" action="{{ url_path('/admin/logout') }}">
<input type="hidden" name="next" value="{{ request.url.path }}">
<button class="btn btn-sm btn-outline-secondary w-100">Выйти</button>
</form>
{% else %}
<span class="badge bg-warning text-dark align-self-start align-self-lg-center" title="ADMIN_PIN не задан в .env — права не ограничены">⚠ без PIN</span>
{% endif %}
{% else %}
<a href="{{ url_path('/admin/login?next=' ~ request.url.path) }}" class="btn btn-sm btn-outline-dark">🔒 Админ</a>
{% endif %}
</div>
</div>
</div>
</nav>
<div class="container">
{% if flash %}
<div class="alert alert-info">{{ flash }}</div>
{% endif %}
{% block content %}{% endblock %}
</div>
<footer class="container text-center py-4">DLD Monitor · HOME LIGA REAL ESTATE</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,86 +0,0 @@
{% extends "base.html" %}
{% block title %}Сотрудники — DLD Monitor{% endblock %}
{% block content %}
<h3>Сотрудники</h3>
<div class="alert alert-info">
<b>Как подключить Telegram:</b>
<ol class="mb-0">
<li>Сотрудник пишет боту в Telegram команду <code>/whoami</code> — бот вернёт его <code>chat_id</code>.</li>
<li>Вставьте этот <code>chat_id</code> в поле справа от имени и нажмите «Сохранить».</li>
</ol>
Если сотрудник пока не знает свой chat_id — пусть просто отправит <code>/start</code>, бот ответит и пришлёт id.
</div>
<form method="post" action="{{ url_path('/employees') }}" class="row g-2 mb-4 bg-white p-3 rounded shadow-sm">
<div class="col-md-5">
<input name="name" required class="form-control" placeholder="Имя сотрудника">
</div>
<div class="col-md-4">
<input name="tg_username" class="form-control" placeholder="@username (опционально)">
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100">Добавить</button>
</div>
</form>
{% if not request.state.is_admin %}
<div class="alert alert-secondary py-2 small">
🔒 Редактирование и удаление сотрудников доступно только в режиме админа.
<a href="{{ url_path('/admin/login?next=/employees') }}">Войти</a>.
</div>
{% endif %}
<div class="table-responsive">
<table class="table bg-white rounded shadow-sm align-middle">
<thead class="table-light">
<tr><th>Имя</th><th>@username</th><th>Chat ID</th><th>Проектов</th>{% if request.state.is_admin %}<th style="width:1%"></th>{% endif %}</tr>
</thead>
<tbody>
{% for e in employees %}
{% if request.state.is_admin %}
<tr>
<form method="post" action="{{ url_path('/employees/' ~ e.id ~ '/update') }}">
<td>
<input name="name" value="{{ e.name }}" class="form-control form-control-sm" required>
</td>
<td>
<input name="tg_username" value="{{ e.tg_username or '' }}" class="form-control form-control-sm" placeholder="username">
</td>
<td>
<div class="input-group input-group-sm">
<input name="tg_chat_id" value="{{ e.tg_chat_id or '' }}" class="form-control"
placeholder="например 123456789">
{% if e.tg_chat_id %}
<span class="input-group-text text-success"></span>
{% endif %}
</div>
</td>
<td>{{ e.projects|length }}</td>
<td class="text-nowrap">
<button class="btn btn-sm btn-primary">Сохранить</button>
</form>
<form method="post" action="{{ url_path('/employees/' ~ e.id ~ '/delete') }}" class="d-inline"
onsubmit="return confirm('Удалить сотрудника?');">
<button class="btn btn-sm btn-outline-danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td>{{ e.name }}</td>
<td>{% if e.tg_username %}@{{ e.tg_username }}{% else %}—{% endif %}</td>
<td>
{% if e.tg_chat_id %}<code>{{ e.tg_chat_id }}</code> <span class="text-success"></span>
{% else %}<span class="text-muted">не задан</span>{% endif %}
</td>
<td>{{ e.projects|length }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -1,136 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ project.title }} — DLD Monitor{% endblock %}
{% block content %}
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start gap-3 mb-3">
<div>
<h3 class="mb-1">{{ project.title }}</h3>
<div class="text-muted">
{% if project.deal_type.value == 'sale' %}
<span class="badge badge-sale">Продажа</span>
{% else %}
<span class="badge badge-rent">Аренда</span>
{% endif %}
· Владелец: {{ project.owner.name }}
{% if project.last_checked_at %}
· Проверено: {{ project.last_checked_at | msk }} МСК
{% endif %}
</div>
<div class="small text-muted mt-1">
{% 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: <code>{{ project.dld_permit }}</code>{% endif %}
</div>
{% if project.our_price %}
<div class="mt-2">Наша цена: <b>{{ "{:,.0f}".format(project.our_price).replace(",", " ") }} AED</b></div>
{% endif %}
{% if project.our_url %}
<div class="small mt-1">Наше объявление: <a href="{{ project.our_url }}" target="_blank" rel="noopener">{{ project.our_url }}</a></div>
{% endif %}
{% if project.notes %}<div class="mt-2 text-muted"><i>{{ project.notes }}</i></div>{% endif %}
</div>
<div class="d-flex gap-2 flex-shrink-0">
<form action="{{ url_path('/projects/' ~ project.id ~ '/check') }}" method="post">
<button class="btn btn-primary text-nowrap">Проверить сейчас</button>
</form>
{% if request.state.is_admin %}
<form action="{{ url_path('/projects/' ~ project.id ~ '/delete') }}" method="post"
onsubmit="return confirm('Удалить проект и всю историю?');">
<button class="btn btn-outline-danger">Удалить</button>
</form>
{% endif %}
</div>
</div>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
{% if message %}
<div class="alert alert-success">{{ message }}</div>
{% endif %}
<div class="bg-white rounded shadow-sm p-3 mb-4">
<form method="post" action="{{ url_path('/projects/' ~ project.id ~ '/listings') }}" class="row g-2">
<div class="col-md-9">
<input name="url" type="url" required class="form-control"
placeholder="Вставьте URL объявления конкурента с propertyfinder.ae или bayut.com">
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100">+ Добавить конкурента</button>
</div>
</form>
{% if project.our_url %}
<div class="mt-2">
<a href="{{ url_path('/projects/' ~ project.id ~ '/suggest') }}" class="btn btn-sm btn-outline-secondary">
🔍 Подобрать похожие на PropertyFinder
</a>
<span class="text-muted small ms-2">
— по зданию из вашего объявления{% if project.bedrooms is not none %}, {{ project.bedrooms }} BR{% endif %};
совпадения по DLD permit — первыми. Займёт ~1520 сек.
</span>
</div>
{% else %}
<div class="mt-2 text-muted small">
🔍 «Подобрать похожие» появится, когда у проекта заполнены <b>наше объявление (URL)</b> и <b>спальни</b>.
</div>
{% endif %}
</div>
<h5 class="mb-3">Отслеживаемые конкуренты ({{ project.listings|length }})</h5>
{% if not project.listings %}
<div class="alert alert-light border">
Пока ничего не отслеживается. Добавьте URL объявления конкурента выше.
</div>
{% else %}
{% for l in project.listings %}
<div class="card mb-3 listing-card {{ 'removed' if l.status.value == 'removed' else 'active' }}">
<div class="card-body">
<div class="d-flex flex-column flex-sm-row justify-content-between gap-2">
<div>
<span class="src-{{ 'pf' if l.source.value == 'propertyfinder' else 'bayut' }}">
{{ 'PropertyFinder' if l.source.value == 'propertyfinder' else 'Bayut' }}
</span>
{% if l.status.value == 'removed' %}<span class="badge bg-danger ms-2">Удалено</span>{% endif %}
<h6 class="mt-1 mb-1">
<a href="{{ l.url }}" target="_blank" rel="noopener">{{ l.title or 'без названия' }}</a>
</h6>
<div class="text-muted small">
Брокер: {{ l.agent_name or '—' }} ({{ l.agency_name or '—' }})<br>
Добавлено: {{ l.first_seen_at | msk }} ·
Последний раз видели активным: {{ l.last_seen_at | msk }} (МСК)
</div>
</div>
<div class="text-sm-end flex-shrink-0">
<div class="fs-4 fw-bold">
{% if l.current_price %}
{{ "{:,.0f}".format(l.current_price).replace(",", " ") }} {{ l.currency or 'AED' }}
{% else %}—{% endif %}
</div>
{% if request.state.is_admin %}
<form method="post" action="{{ url_path('/listings/' ~ l.id ~ '/delete') }}"
onsubmit="return confirm('Перестать отслеживать?');" class="mt-2">
<button class="btn btn-sm btn-outline-danger">Удалить</button>
</form>
{% endif %}
</div>
</div>
{% if l.price_history|length > 1 %}
<details class="mt-2">
<summary class="text-muted small">История цены ({{ l.price_history|length }} записей)</summary>
<ul class="small mt-2">
{% for h in l.price_history %}
<li>{{ h.recorded_at | msk }} —
{% if h.price %}{{ "{:,.0f}".format(h.price).replace(",", " ") }} AED{% else %}—{% endif %}</li>
{% endfor %}
</ul>
</details>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}

View File

@@ -1,81 +0,0 @@
{% extends "base.html" %}
{% block title %}Новый проект — DLD Monitor{% endblock %}
{% block content %}
<h3>Новый проект</h3>
{% if no_employees %}
<div class="alert alert-warning">
Сначала добавьте хотя бы одного <a href="{{ url_path('/employees') }}">сотрудника</a> — он будет получать уведомления в Telegram.
</div>
{% endif %}
<form method="post" action="{{ url_path('/projects/new') }}" class="bg-white p-4 rounded shadow-sm">
<div class="mb-3">
<label class="form-label">Название квартиры / лота</label>
<input name="title" required class="form-control" placeholder="Например: Aykon City Tower B, 2BR, Apt 1502">
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Тип сделки</label>
<select name="deal_type" class="form-select" required>
<option value="sale">Продажа</option>
<option value="rent">Аренда</option>
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Наша цена (AED)</label>
<input name="our_price" type="number" step="1" class="form-control" placeholder="например 1 720 000">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Владелец (сотрудник)</label>
<select name="owner_id" class="form-select" required {% if no_employees %}disabled{% endif %}>
{% for e in employees %}
<option value="{{ e.id }}">{{ e.name }}{% if e.tg_chat_id %} ✓ TG{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<hr>
<p class="text-muted small mb-3">
Поля ниже опциональны, но позволят системе предлагать «похожие» объявления конкурентов с PF и Bayut.
</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Здание / проект</label>
<input name="building" class="form-control" placeholder="Aykon City Tower B">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Спальни</label>
<input name="bedrooms" type="number" min="0" max="20" class="form-control" placeholder="2">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Площадь (sqft)</label>
<input name="size_sqft" type="number" step="1" class="form-control" placeholder="1058">
</div>
</div>
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label">Ссылка на наше объявление (опционально)</label>
<input name="our_url" type="url" class="form-control" placeholder="https://www.propertyfinder.ae/...">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">DLD permit (опционально)</label>
<input name="dld_permit" class="form-control" placeholder="71-1-1-...">
</div>
</div>
<div class="mb-3">
<label class="form-label">Заметка</label>
<textarea name="notes" class="form-control" rows="2"></textarea>
</div>
<button class="btn btn-primary" {% if no_employees %}disabled{% endif %}>Создать</button>
<a href="{{ url_path('/') }}" class="btn btn-link">Отмена</a>
</form>
{% endblock %}

View File

@@ -1,67 +0,0 @@
{% extends "base.html" %}
{% block title %}Проекты — DLD Monitor{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0">Наши проекты</h3>
<a href="{{ url_path('/projects/new') }}" class="btn btn-primary">+ Новый проект</a>
</div>
{% if projects %}
<div class="d-flex gap-2 mb-3">
<div class="stat-chip"><div class="n">{{ projects|length }}</div><div class="small text-muted">проектов</div></div>
<div class="stat-chip"><div class="n">{{ projects|map(attribute='listings')|map('length')|sum }}</div><div class="small text-muted">конкурентов</div></div>
</div>
{% endif %}
{% if not projects %}
<div class="alert alert-light border text-center py-5">
Пока ни одного проекта. <a href="{{ url_path('/projects/new') }}">Добавьте первый</a>.
</div>
{% else %}
<div class="table-responsive bg-white rounded shadow-sm">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Название</th>
<th>Здание</th>
<th>Тип</th>
<th>Наша цена</th>
<th>Конкуренты</th>
<th>Владелец</th>
<th>Последняя проверка (МСК)</th>
<th></th>
</tr>
</thead>
<tbody>
{% for p in projects %}
<tr>
<td><a href="{{ url_path('/projects/' ~ p.id) }}">{{ p.title }}</a></td>
<td class="small">
{% if p.building %}{{ p.building }}{% else %}—{% endif %}
{% if p.bedrooms is not none %} · {{ p.bedrooms }}BR{% endif %}
</td>
<td>
{% if p.deal_type.value == 'sale' %}
<span class="badge badge-sale">Продажа</span>
{% else %}
<span class="badge badge-rent">Аренда</span>
{% endif %}
</td>
<td>{% if p.our_price %}{{ "{:,.0f}".format(p.our_price).replace(",", " ") }} AED{% else %}—{% endif %}</td>
<td>{{ p.listings|length }}</td>
<td>{{ p.owner.name if p.owner else '—' }}</td>
<td>{{ p.last_checked_at | msk }}</td>
<td>
<form action="{{ url_path('/projects/' ~ p.id ~ '/check') }}" method="post" class="d-inline">
<button class="btn btn-sm btn-outline-primary">Проверить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,138 +0,0 @@
{% extends "base.html" %}
{% block title %}Похожие — {{ project.title }}{% endblock %}
{% block head %}
<style>
.suggest-card { cursor: pointer; transition: border-color .12s, box-shadow .12s; }
.suggest-card:hover { border-color: #adb5bd; }
.suggest-card.selected { border-color: #0d6efd; box-shadow: 0 0 0 1px #0d6efd inset; }
.suggest-card a { cursor: pointer; }
/* Sticky action bar so the submit button is always reachable, esp. on mobile */
.bulk-bar {
position: sticky; bottom: 0; z-index: 1020;
background: #fff; border-top: 1px solid #e9ecef;
padding: .6rem .25rem; margin-top: 1rem;
box-shadow: 0 -4px 12px rgba(0,0,0,.05);
}
@media (max-width: 575.98px) {
.bulk-bar #bulk-submit { flex: 1; }
}
</style>
{% endblock %}
{% block content %}
<div class="mb-3">
<a href="{{ url_path('/projects/' ~ project.id) }}" class="btn btn-sm btn-link px-0">← к проекту</a>
</div>
<h3 class="mb-1">Подсказки похожих объявлений</h3>
<p class="text-muted">
Ищем объявления в том же здании{% if project.bedrooms is not none %}, {{ project.bedrooms }} спален{% endif %},
тип сделки: {{ project.deal_type.value }}.
{% if our_permit %}Совпадение по DLD permit (<code>{{ our_permit }}</code>) — это тот же объект, такие показаны первыми. {% endif %}
Отметьте подходящие объявления галочкой (можно несколько) и нажмите «Отслеживать выбранные» — они попадут в трекинг.
</p>
<form id="bulk-form" method="post" action="{{ url_path('/projects/' ~ project.id ~ '/listings/bulk') }}">
{% for src_key, src_label in [('propertyfinder', 'PropertyFinder'), ('bayut', 'Bayut')] %}
<h5 class="mt-4">
<span class="src-{{ 'pf' if src_key == 'propertyfinder' else 'bayut' }}">{{ src_label }}</span>
{% if src_key == 'bayut' and not bayut_enabled %}
<small class="text-muted">— ⏸ временно отключён</small>
{% else %}
<small class="text-muted">— найдено {{ suggestions[src_key]|length }}</small>
{% endif %}
</h5>
{% if src_key == 'bayut' and not bayut_enabled %}
<div class="text-muted small">Bayut перешёл на защищённый рендеринг — поиск и трекинг временно недоступны.</div>
{% elif not suggestions[src_key] %}
<div class="text-muted small">Ничего не нашли (или площадка заблокировала запрос).</div>
{% else %}
{% for s in suggestions[src_key] %}
{% set same_permit = our_permit and s.permit_number and s.permit_number == our_permit %}
<label class="card mb-2 suggest-card{% if same_permit %} border-success{% endif %}">
<div class="card-body py-2">
<div class="d-flex gap-2 align-items-start">
<input class="form-check-input suggest-check flex-shrink-0 mt-1" type="checkbox"
name="urls" value="{{ s.url }}">
<div class="flex-grow-1">
<div class="d-flex flex-column flex-sm-row justify-content-between gap-1">
<div>
{% if same_permit %}<span class="badge bg-success">🎯 тот же permit</span> {% endif %}
<a href="{{ s.url }}" target="_blank" rel="noopener">{{ s.title or 'без названия' }}</a>
<div class="small text-muted">
{{ s.agent_name or '—' }} ({{ s.agency_name or '—' }})
{% if s.permit_number %} · permit <code>{{ s.permit_number }}</code>{% endif %}
</div>
</div>
<div class="fw-bold text-sm-end text-nowrap">
{% if s.price %}{{ "{:,.0f}".format(s.price).replace(",", " ") }} {{ s.currency or 'AED' }}{% else %}—{% endif %}
</div>
</div>
</div>
</div>
</div>
</label>
{% endfor %}
{% endif %}
{% endfor %}
<div class="bulk-bar">
<div class="d-flex align-items-center gap-3">
<div class="form-check m-0">
<input class="form-check-input" type="checkbox" id="select-all">
<label class="form-check-label small" for="select-all">Выбрать все</label>
</div>
<button type="submit" class="btn btn-primary ms-auto" id="bulk-submit" disabled>
+ Отслеживать выбранные (<span id="sel-count">0</span>)
</button>
</div>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
(function () {
const form = document.getElementById('bulk-form');
if (!form) return;
const checks = Array.from(form.querySelectorAll('.suggest-check'));
const selectAll = document.getElementById('select-all');
const countEl = document.getElementById('sel-count');
const submitBtn = document.getElementById('bulk-submit');
function refresh() {
let n = 0;
checks.forEach(c => {
const card = c.closest('.suggest-card');
if (card) card.classList.toggle('selected', c.checked);
if (c.checked) n++;
});
countEl.textContent = n;
submitBtn.disabled = n === 0;
if (selectAll) selectAll.checked = n > 0 && n === checks.length;
}
checks.forEach(c => c.addEventListener('change', refresh));
if (selectAll) {
selectAll.addEventListener('change', () => {
checks.forEach(c => { c.checked = selectAll.checked; });
refresh();
});
}
// Confirm before submitting a large batch — each add re-fetches the page.
form.addEventListener('submit', e => {
const n = checks.filter(c => c.checked).length;
if (n === 0) { e.preventDefault(); return; }
submitBtn.disabled = true;
submitBtn.textContent = 'Добавляем…';
});
refresh();
})();
</script>
{% endblock %}

View File

@@ -1,27 +1,21 @@
"""FastAPI web app — UI for managing projects, competitor listings, employees."""
"""FastAPI API for Monitoring PF.
The user interface lives in Portal. This service exposes only JSON endpoints
and trusts Portal-provided headers for admin state.
"""
from __future__ import annotations
from datetime import timedelta
from pathlib import Path
from datetime import datetime
from typing import Any
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from urllib.parse import quote
from fastapi.templating import Jinja2Templates
from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session, joinedload
from app.auth import (
ADMIN_COOKIE,
COOKIE_MAX_AGE,
admin_configured,
admin_token,
pin_ok,
request_is_admin,
)
from app.config import BASE_DIR, settings
from app.config import settings
from app.db import get_db, init_db
from app.models import CompetitorListing, DealType, Employee, Project
from app.models import CompetitorListing, DealType, Employee, ListingStatus, Project
from app.services.monitor import (
BAYUT_ENABLED,
add_competitor_url,
@@ -31,31 +25,53 @@ from app.services.monitor import (
suggest_similar,
)
app = FastAPI(title="DLD Monitor")
templates = Jinja2Templates(directory=str(Path(BASE_DIR) / "app" / "templates"))
# Timestamps are stored as naive UTC (datetime.utcnow). Show them in Moscow time
# (UTC+3) in the UI via the `msk` Jinja filter.
_MSK_OFFSET = timedelta(hours=3)
app = FastAPI(title="Monitoring PF")
def _to_msk(dt, fmt: str = "%Y-%m-%d %H:%M"):
if dt is None:
return ""
return (dt + _MSK_OFFSET).strftime(fmt)
class EmployeeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
tg_username: str | None = Field(None, max_length=200)
tg_chat_id: str | None = Field(None, max_length=64)
templates.env.filters["msk"] = _to_msk
class EmployeeUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
tg_username: str | None = Field(None, max_length=200)
tg_chat_id: str | None = Field(None, max_length=64)
def web_path(path: str = "/") -> str:
base = settings.public_base_path.rstrip("/")
if not path.startswith("/"):
path = "/" + path
return f"{base}{path}" if base else path
class ProjectCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
deal_type: DealType
owner_id: int
our_price: float | None = None
notes: str | None = None
dld_permit: str | None = Field(None, max_length=100)
building: str | None = Field(None, max_length=300)
bedrooms: int | None = None
size_sqft: float | None = None
our_url: str | None = None
templates.env.globals["url_path"] = web_path
class ProjectUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=300)
deal_type: DealType | None = None
owner_id: int | None = None
our_price: float | None = None
notes: str | None = None
dld_permit: str | None = Field(None, max_length=100)
building: str | None = Field(None, max_length=300)
bedrooms: int | None = None
size_sqft: float | None = None
our_url: str | None = None
class ListingCreate(BaseModel):
url: str = Field(..., min_length=1)
class ListingsBulkCreate(BaseModel):
urls: list[str] = Field(default_factory=list)
@app.on_event("startup")
@@ -63,242 +79,334 @@ def _startup() -> None:
init_db()
@app.middleware("http")
async def _admin_state(request: Request, call_next):
"""Expose admin status to every route and template (request.state.is_admin)."""
request.state.is_admin = request_is_admin(request)
request.state.admin_configured = admin_configured()
return await call_next(request)
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}
def admin_required(request: Request) -> None:
"""Dependency: block the request unless the session is in admin mode."""
if not request.state.is_admin:
raise HTTPException(403, "Действие доступно только администратору. Войдите в режим админа.")
@app.get("/")
def index() -> dict[str, str]:
return {"service": "monitoring-pf", "ui": "portal"}
def _safe_next(next_url: str) -> str:
"""Only allow same-site relative redirects after login."""
return next_url if next_url.startswith("/") and not next_url.startswith("//") else "/"
def _is_admin(request: Request) -> bool:
return request.headers.get("x-user-is-admin") == "1"
# --- Admin session --------------------------------------------------------
def _require_admin(request: Request) -> None:
if not _is_admin(request):
raise HTTPException(status_code=404, detail="not found")
@app.get("/admin/login", response_class=HTMLResponse)
def admin_login_page(request: Request, next: str = "/", error: str | None = None):
return templates.TemplateResponse(
"admin_login.html",
{"request": request, "next": _safe_next(next), "error": error, "flash": None},
def _clean(value: str | None) -> str | None:
value = (value or "").strip()
return value or None
def _dt(value: datetime | None) -> str | None:
return value.isoformat() if value else None
def _employee_out(employee: Employee) -> dict[str, Any]:
return {
"id": employee.id,
"name": employee.name,
"tg_chat_id": employee.tg_chat_id,
"tg_username": employee.tg_username,
"projects_total": len(employee.projects or []),
"created_at": _dt(employee.created_at),
}
def _history_out(listing: CompetitorListing) -> list[dict[str, Any]]:
return [
{"id": h.id, "price": h.price, "recorded_at": _dt(h.recorded_at)}
for h in listing.price_history
]
def _listing_out(listing: CompetitorListing, *, with_history: bool = False) -> dict[str, Any]:
out = {
"id": listing.id,
"project_id": listing.project_id,
"source": listing.source.value,
"external_id": listing.external_id,
"url": listing.url,
"title": listing.title,
"agent_name": listing.agent_name,
"agency_name": listing.agency_name,
"current_price": listing.current_price,
"currency": listing.currency,
"status": listing.status.value,
"first_seen_at": _dt(listing.first_seen_at),
"last_seen_at": _dt(listing.last_seen_at),
}
if with_history:
out["price_history"] = _history_out(listing)
return out
def _project_stats(project: Project) -> dict[str, Any]:
listings = project.listings or []
active = [l for l in listings if l.status == ListingStatus.ACTIVE]
prices = [l.current_price for l in active if l.current_price is not None]
return {
"listings_total": len(listings),
"listings_active": len(active),
"listings_removed": len(listings) - len(active),
"min_competitor_price": min(prices) if prices else None,
}
def _project_out(project: Project, *, detail: bool = False) -> dict[str, Any]:
out = {
"id": project.id,
"title": project.title,
"deal_type": project.deal_type.value,
"our_price": project.our_price,
"notes": project.notes,
"dld_permit": project.dld_permit,
"building": project.building,
"bedrooms": project.bedrooms,
"size_sqft": project.size_sqft,
"our_url": project.our_url,
"owner_id": project.owner_id,
"owner": _employee_out(project.owner) if project.owner else None,
"created_at": _dt(project.created_at),
"last_checked_at": _dt(project.last_checked_at),
**_project_stats(project),
}
if detail:
out["listings"] = [_listing_out(l, with_history=True) for l in project.listings]
return out
def _suggestion_out(item: Any) -> dict[str, Any]:
return {
"source": item.source,
"external_id": item.external_id,
"url": item.url,
"title": item.title,
"price": item.price,
"currency": item.currency,
"permit_number": item.permit_number,
"agent_name": item.agent_name,
"agency_name": item.agency_name,
"is_active": item.is_active,
}
@app.get("/api/v1/access/me")
def access_me(request: Request) -> dict[str, Any]:
return {"is_admin": _is_admin(request)}
@app.get("/api/v1/summary")
def summary(db: Session = Depends(get_db)) -> dict[str, Any]:
projects = db.query(Project).options(joinedload(Project.listings)).all()
employees = db.query(Employee).all()
listings = db.query(CompetitorListing).all()
active = [l for l in listings if l.status == ListingStatus.ACTIVE]
return {
"projects_total": len(projects),
"employees_total": len(employees),
"listings_total": len(listings),
"listings_active": len(active),
"listings_removed": len(listings) - len(active),
"scrape_interval_hours": settings.scrape_interval_hours,
"bayut_enabled": BAYUT_ENABLED,
}
@app.get("/api/v1/employees")
def employees_list(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
employees = (
db.query(Employee)
.options(joinedload(Employee.projects))
.order_by(Employee.name)
.all()
)
return [_employee_out(employee) for employee in employees]
@app.post("/admin/login")
def admin_login(request: Request, pin: str = Form(...), next: str = Form("/")):
dest = _safe_next(next)
if not pin_ok(pin):
return templates.TemplateResponse(
"admin_login.html",
{"request": request, "next": dest, "error": "Неверный PIN", "flash": None},
status_code=401,
@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),
)
resp = RedirectResponse(web_path(dest), status_code=303)
resp.set_cookie(
ADMIN_COOKIE, admin_token(), max_age=COOKIE_MAX_AGE,
httponly=True, samesite="lax",
db.add(employee)
db.commit()
db.refresh(employee)
return _employee_out(employee)
@app.patch("/api/v1/employees/{employee_id}")
def employee_update(
employee_id: int,
payload: EmployeeUpdate,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
_require_admin(request)
employee = db.get(Employee, employee_id)
if not employee:
raise HTTPException(404, "employee not found")
if payload.name is not None:
employee.name = payload.name.strip()
if payload.tg_username is not None:
employee.tg_username = payload.tg_username.strip().lstrip("@") or None
if payload.tg_chat_id is not None:
chat_id = _clean(payload.tg_chat_id)
if chat_id and chat_id != employee.tg_chat_id:
clash = (
db.query(Employee)
.filter(Employee.tg_chat_id == chat_id, Employee.id != employee.id)
.first()
)
return resp
if clash:
raise HTTPException(400, f"chat_id already belongs to {clash.name}")
employee.tg_chat_id = chat_id
db.commit()
db.refresh(employee)
return _employee_out(employee)
@app.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.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_float(s: str) -> float | None:
s = (s or "").strip()
if not s:
return None
try:
return float(s)
except ValueError:
return None
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,
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,
"flash": None,
"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)
}

View File

@@ -7,4 +7,3 @@ type: Opaque
stringData:
TG_BOT_TOKEN: "CHANGE_ME"
ADMIN_CHAT_ID: ""
ADMIN_PIN: "CHANGE_ME"

View File

@@ -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