Make monitoring PF API-only for Portal
This commit is contained in:
20
README.md
20
README.md
@@ -1,8 +1,9 @@
|
|||||||
# monitoring-pf
|
# monitoring-pf
|
||||||
|
|
||||||
Сервис мониторинга объявлений PropertyFinder/Bayut по DLD Permit Number для
|
Сервис мониторинга объявлений PropertyFinder по DLD Permit Number для портала.
|
||||||
портала. Он хранит проекты, конкурирующие объявления и историю цен, а UI
|
Он хранит проекты, конкурирующие объявления и историю цен. Пользовательский UI
|
||||||
публикуется через portal по `/monitoring-pf`.
|
живёт в Portal: `portal/frontend/src/app/features/monitoring-pf`; этот сервис
|
||||||
|
отдаёт только JSON API, Telegram bot и scheduler.
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
@@ -13,15 +14,17 @@
|
|||||||
|
|
||||||
## Развёртывание
|
## Развёртывание
|
||||||
|
|
||||||
Сервис рассчитан на запуск внутри портала/k8s. Манифесты лежат в `k8s/`.
|
Сервис рассчитан на запуск только внутри k8s. Настройки лежат в
|
||||||
Перед применением заполните секреты в `k8s/secrets.yaml`.
|
`k8s/configmap.yaml`, секреты — в `k8s/secrets.yaml`. Локальный `.env` не
|
||||||
|
используется.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl apply -k k8s
|
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 настройки окружения
|
├── config.py настройки окружения
|
||||||
├── db.py SQLAlchemy engine/session
|
├── db.py SQLAlchemy engine/session
|
||||||
├── models.py Employee, Project, CompetitorListing, PriceHistory
|
├── models.py Employee, Project, CompetitorListing, PriceHistory
|
||||||
├── web.py FastAPI роуты и UI
|
├── web.py FastAPI JSON API для Portal
|
||||||
├── bot.py Telegram-бот
|
├── bot.py Telegram-бот
|
||||||
├── scheduler.py фоновый сканер
|
├── scheduler.py фоновый сканер
|
||||||
├── scrapers/ PropertyFinder/Bayut парсеры
|
├── scrapers/ PropertyFinder/Bayut парсеры
|
||||||
├── services/ бизнес-логика и уведомления
|
├── services/ бизнес-логика и уведомления
|
||||||
└── templates/ Jinja2 UI
|
|
||||||
k8s/ манифесты для портала
|
k8s/ манифесты для портала
|
||||||
```
|
```
|
||||||
|
|||||||
45
app/auth.py
45
app/auth.py
@@ -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))
|
|
||||||
@@ -167,7 +167,7 @@ async def cmd_check(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
if not settings.tg_bot_token:
|
if not settings.tg_bot_token:
|
||||||
raise SystemExit("TG_BOT_TOKEN не задан в .env")
|
raise SystemExit("TG_BOT_TOKEN не задан в k8s/secrets.yaml")
|
||||||
init_db()
|
init_db()
|
||||||
app = Application.builder().token(settings.tg_bot_token).build()
|
app = Application.builder().token(settings.tg_bot_token).build()
|
||||||
app.add_handler(CommandHandler("start", cmd_start))
|
app.add_handler(CommandHandler("start", cmd_start))
|
||||||
|
|||||||
@@ -25,11 +25,7 @@ def _resolve_sqlite_url(url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(extra="ignore")
|
||||||
env_file=BASE_DIR / ".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
tg_bot_token: str = ""
|
tg_bot_token: str = ""
|
||||||
web_host: str = "127.0.0.1"
|
web_host: str = "127.0.0.1"
|
||||||
@@ -38,9 +34,6 @@ class Settings(BaseSettings):
|
|||||||
scrape_interval_hours: int = 4
|
scrape_interval_hours: int = 4
|
||||||
database_url: str = f"sqlite:///{DATA_DIR / 'monitor.db'}"
|
database_url: str = f"sqlite:///{DATA_DIR / 'monitor.db'}"
|
||||||
admin_chat_id: str = ""
|
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:
|
def model_post_init(self, __context) -> None:
|
||||||
self.database_url = _resolve_sqlite_url(self.database_url)
|
self.database_url = _resolve_sqlite_url(self.database_url)
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 — первыми. Займёт ~15–20 сек.
|
|
||||||
</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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
603
app/web.py
603
app/web.py
@@ -1,27 +1,21 @@
|
|||||||
"""FastAPI web app — UI for managing projects, competitor listings, employees."""
|
"""FastAPI API for Monitoring PF.
|
||||||
|
|
||||||
|
The user interface lives in Portal. This service exposes only JSON endpoints
|
||||||
|
and trusts Portal-provided headers for admin state.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from pydantic import BaseModel, Field
|
||||||
from urllib.parse import quote
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from app.auth import (
|
from app.config import settings
|
||||||
ADMIN_COOKIE,
|
|
||||||
COOKIE_MAX_AGE,
|
|
||||||
admin_configured,
|
|
||||||
admin_token,
|
|
||||||
pin_ok,
|
|
||||||
request_is_admin,
|
|
||||||
)
|
|
||||||
from app.config import BASE_DIR, settings
|
|
||||||
from app.db import get_db, init_db
|
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 (
|
from app.services.monitor import (
|
||||||
BAYUT_ENABLED,
|
BAYUT_ENABLED,
|
||||||
add_competitor_url,
|
add_competitor_url,
|
||||||
@@ -31,31 +25,53 @@ from app.services.monitor import (
|
|||||||
suggest_similar,
|
suggest_similar,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI(title="DLD Monitor")
|
app = FastAPI(title="Monitoring PF")
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_msk(dt, fmt: str = "%Y-%m-%d %H:%M"):
|
class EmployeeCreate(BaseModel):
|
||||||
if dt is None:
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
return "—"
|
tg_username: str | None = Field(None, max_length=200)
|
||||||
return (dt + _MSK_OFFSET).strftime(fmt)
|
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:
|
class ProjectCreate(BaseModel):
|
||||||
base = settings.public_base_path.rstrip("/")
|
title: str = Field(..., min_length=1, max_length=300)
|
||||||
if not path.startswith("/"):
|
deal_type: DealType
|
||||||
path = "/" + path
|
owner_id: int
|
||||||
return f"{base}{path}" if base else path
|
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")
|
@app.on_event("startup")
|
||||||
@@ -63,242 +79,334 @@ def _startup() -> None:
|
|||||||
init_db()
|
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")
|
@app.get("/healthz")
|
||||||
def healthz() -> dict[str, str]:
|
def healthz() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
def admin_required(request: Request) -> None:
|
@app.get("/")
|
||||||
"""Dependency: block the request unless the session is in admin mode."""
|
def index() -> dict[str, str]:
|
||||||
if not request.state.is_admin:
|
return {"service": "monitoring-pf", "ui": "portal"}
|
||||||
raise HTTPException(403, "Действие доступно только администратору. Войдите в режим админа.")
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_next(next_url: str) -> str:
|
def _is_admin(request: Request) -> bool:
|
||||||
"""Only allow same-site relative redirects after login."""
|
return request.headers.get("x-user-is-admin") == "1"
|
||||||
return next_url if next_url.startswith("/") and not next_url.startswith("//") else "/"
|
|
||||||
|
|
||||||
|
|
||||||
# --- 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):
|
def _clean(value: str | None) -> str | None:
|
||||||
return templates.TemplateResponse(
|
value = (value or "").strip()
|
||||||
"admin_login.html",
|
return value or None
|
||||||
{"request": request, "next": _safe_next(next), "error": error, "flash": 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")
|
@app.post("/api/v1/employees", status_code=201)
|
||||||
def admin_login(request: Request, pin: str = Form(...), next: str = Form("/")):
|
def employee_create(payload: EmployeeCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
dest = _safe_next(next)
|
employee = Employee(
|
||||||
if not pin_ok(pin):
|
name=payload.name.strip(),
|
||||||
return templates.TemplateResponse(
|
tg_username=_clean(payload.tg_username).lstrip("@") if _clean(payload.tg_username) else None,
|
||||||
"admin_login.html",
|
tg_chat_id=_clean(payload.tg_chat_id),
|
||||||
{"request": request, "next": dest, "error": "Неверный PIN", "flash": None},
|
|
||||||
status_code=401,
|
|
||||||
)
|
)
|
||||||
resp = RedirectResponse(web_path(dest), status_code=303)
|
db.add(employee)
|
||||||
resp.set_cookie(
|
db.commit()
|
||||||
ADMIN_COOKIE, admin_token(), max_age=COOKIE_MAX_AGE,
|
db.refresh(employee)
|
||||||
httponly=True, samesite="lax",
|
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")
|
@app.delete("/api/v1/employees/{employee_id}", status_code=204)
|
||||||
def admin_logout(next: str = Form("/")):
|
def employee_delete(employee_id: int, request: Request, db: Session = Depends(get_db)) -> None:
|
||||||
resp = RedirectResponse(web_path(_safe_next(next)), status_code=303)
|
_require_admin(request)
|
||||||
resp.delete_cookie(ADMIN_COOKIE)
|
employee = db.get(Employee, employee_id)
|
||||||
return resp
|
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:
|
@app.get("/api/v1/projects")
|
||||||
s = (s or "").strip()
|
def projects_list(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
|
||||||
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)):
|
|
||||||
projects = (
|
projects = (
|
||||||
db.query(Project)
|
db.query(Project)
|
||||||
.options(joinedload(Project.owner), joinedload(Project.listings))
|
.options(joinedload(Project.owner), joinedload(Project.listings))
|
||||||
.order_by(Project.created_at.desc())
|
.order_by(Project.created_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(
|
return [_project_out(project) for project in projects]
|
||||||
"projects_list.html", {"request": request, "projects": projects, "flash": None}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/new", response_class=HTMLResponse)
|
@app.post("/api/v1/projects", status_code=201)
|
||||||
def new_project_form(request: Request, db: Session = Depends(get_db)):
|
def project_create(payload: ProjectCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
employees = db.query(Employee).order_by(Employee.name).all()
|
owner = db.get(Employee, payload.owner_id)
|
||||||
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)
|
|
||||||
if not owner:
|
if not owner:
|
||||||
raise HTTPException(404, "Employee not found")
|
raise HTTPException(404, "employee not found")
|
||||||
project = Project(
|
project = Project(
|
||||||
title=title.strip(),
|
title=payload.title.strip(),
|
||||||
deal_type=DealType(deal_type),
|
deal_type=payload.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,
|
|
||||||
owner_id=owner.id,
|
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.add(project)
|
||||||
db.commit()
|
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)
|
@app.get("/api/v1/projects/{project_id}")
|
||||||
def project_detail(
|
def project_detail(project_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
project_id: int,
|
|
||||||
request: Request,
|
|
||||||
error: str | None = None,
|
|
||||||
message: str | None = None,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
project = (
|
project = (
|
||||||
db.query(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)
|
.filter(Project.id == project_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(404, "Project not found")
|
raise HTTPException(404, "project not found")
|
||||||
return templates.TemplateResponse(
|
return _project_out(project, detail=True)
|
||||||
"project_detail.html",
|
|
||||||
{"request": request, "project": project, "error": error, "message": message, "flash": None},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/projects/{project_id}/check")
|
@app.patch("/api/v1/projects/{project_id}")
|
||||||
def project_check_now(project_id: int, db: Session = Depends(get_db)):
|
def project_update(project_id: int, payload: ProjectUpdate, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
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)):
|
|
||||||
project = db.get(Project, project_id)
|
project = db.get(Project, project_id)
|
||||||
if not project:
|
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.delete(project)
|
||||||
db.commit()
|
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)
|
project = db.get(Project, project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(404, "Project not found")
|
raise HTTPException(404, "project not found")
|
||||||
listing, err = add_competitor_url(db, project, url)
|
listing, err = add_competitor_url(db, project, payload.url)
|
||||||
if err:
|
if err:
|
||||||
return RedirectResponse(web_path(f"/projects/{project_id}?error={quote(err)}"), status_code=303)
|
raise HTTPException(400, err)
|
||||||
return RedirectResponse(
|
return _listing_out(listing, with_history=True)
|
||||||
web_path(f"/projects/{project_id}?message=Добавлено"), status_code=303,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/projects/{project_id}/listings/bulk")
|
@app.post("/api/v1/projects/{project_id}/listings/bulk")
|
||||||
def add_listings_bulk(
|
def listings_bulk(project_id: int, payload: ListingsBulkCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
project_id: int,
|
|
||||||
urls: list[str] = Form(default=[]),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
project = db.get(Project, project_id)
|
project = db.get(Project, project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(404, "Project not found")
|
raise HTTPException(404, "project not found")
|
||||||
if not urls:
|
return add_competitor_urls(db, project, payload.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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/listings/{listing_id}/delete")
|
@app.delete("/api/v1/listings/{listing_id}", status_code=204)
|
||||||
def listing_delete(listing_id: int, db: Session = Depends(get_db), _: None = Depends(admin_required)):
|
def listing_delete(listing_id: int, request: Request, db: Session = Depends(get_db)) -> None:
|
||||||
|
_require_admin(request)
|
||||||
listing = db.get(CompetitorListing, listing_id)
|
listing = db.get(CompetitorListing, listing_id)
|
||||||
if not listing:
|
if not listing:
|
||||||
raise HTTPException(404, "Listing not found")
|
raise HTTPException(404, "listing not found")
|
||||||
project_id = listing.project_id
|
|
||||||
db.delete(listing)
|
db.delete(listing)
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(web_path(f"/projects/{project_id}"), status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}/suggest", response_class=HTMLResponse)
|
@app.get("/api/v1/projects/{project_id}/suggest")
|
||||||
def project_suggest(project_id: int, request: Request, db: Session = Depends(get_db)):
|
def project_suggest(project_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
project = (
|
project = (
|
||||||
db.query(Project)
|
db.query(Project)
|
||||||
.options(joinedload(Project.listings))
|
.options(joinedload(Project.listings))
|
||||||
@@ -306,81 +414,14 @@ def project_suggest(project_id: int, request: Request, db: Session = Depends(get
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(404, "Project not found")
|
raise HTTPException(404, "project not found")
|
||||||
our_permit = resolve_our_permit(project)
|
permit = resolve_our_permit(project)
|
||||||
suggestions = suggest_similar(project, our_permit=our_permit)
|
suggestions = suggest_similar(project, our_permit=permit)
|
||||||
return templates.TemplateResponse(
|
return {
|
||||||
"suggest.html",
|
"our_permit": permit,
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"project": project,
|
|
||||||
"suggestions": suggestions,
|
|
||||||
"our_permit": our_permit,
|
|
||||||
"bayut_enabled": BAYUT_ENABLED,
|
"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)
|
|
||||||
|
|||||||
@@ -7,4 +7,3 @@ type: Opaque
|
|||||||
stringData:
|
stringData:
|
||||||
TG_BOT_TOKEN: "CHANGE_ME"
|
TG_BOT_TOKEN: "CHANGE_ME"
|
||||||
ADMIN_CHAT_ID: ""
|
ADMIN_CHAT_ID: ""
|
||||||
ADMIN_PIN: "CHANGE_ME"
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn[standard]==0.32.1
|
uvicorn[standard]==0.32.1
|
||||||
jinja2==3.1.4
|
|
||||||
python-multipart==0.0.20
|
|
||||||
sqlalchemy==2.0.36
|
sqlalchemy==2.0.36
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
brotli==1.2.0 # lets httpx decode Content-Encoding: br (Bayut serves Brotli)
|
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
|
lxml==5.3.0
|
||||||
apscheduler==3.11.0
|
apscheduler==3.11.0
|
||||||
python-telegram-bot==21.9
|
python-telegram-bot==21.9
|
||||||
python-dotenv==1.0.1
|
|
||||||
pydantic==2.10.4
|
pydantic==2.10.4
|
||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
|
|||||||
Reference in New Issue
Block a user