Compare commits

...

17 Commits

Author SHA1 Message Date
Grendgi
6750722429 feat: parse project metadata from PF links
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 38s
CI / go (push) Successful in 26s
CI / python (push) Successful in 16s
2026-06-24 14:03:25 +03:00
Grendgi
31c498af39 fix: use python 3.9 compatible sqlalchemy annotations
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 13s
CI / go (push) Successful in 22s
CI / python (push) Successful in 14s
2026-06-18 11:18:16 +03:00
Grendgi
2648a74b8c fix: support python 3.9 model annotations
Some checks failed
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 14s
CI / go (push) Successful in 20s
CI / python (push) Failing after 14s
2026-06-18 11:09:42 +03:00
Grendgi
47ac87bbc3 fix: bootstrap pip in ci
Some checks failed
CI / hygiene (push) Successful in 1s
Build and Deploy / build-and-deploy (push) Successful in 12s
CI / go (push) Successful in 27s
CI / python (push) Failing after 14s
2026-06-18 10:29:46 +03:00
Grendgi
cb8e290d8f test: cover propertyfinder matching rules
Some checks failed
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 35s
CI / go (push) Successful in 46s
CI / python (push) Failing after 2s
2026-06-17 17:12:49 +03:00
Grendgi
f73c9fba5f feat: expose monitoring pf health detail 2026-06-17 15:59:53 +03:00
Grendgi
ccd56165c7 chore: use common internal auth 2026-06-17 14:26:04 +03:00
Grendgi
ea2063ff40 chore: use common header parsing 2026-06-17 14:19:13 +03:00
Grendgi
703f544cdf Allow managers to view subordinate PF projects
All checks were successful
CI / hygiene (push) Successful in 3s
Build and Deploy / build-and-deploy (push) Successful in 36s
CI / go (push) Successful in 23s
CI / python (push) Successful in 1s
2026-06-16 09:34:17 +03:00
Grendgi
ec62cc04cf Add monitoring PF CI hygiene guard
All checks were successful
CI / hygiene (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 34s
CI / go (push) Successful in 42s
CI / python (push) Successful in 2s
2026-06-12 16:42:38 +03:00
Grendgi
47e259fa28 Protect monitoring PF API with internal key 2026-06-12 16:32:12 +03:00
Grendgi
974090df4f Allow PF managers to manage subordinate projects
All checks were successful
CI / go (push) Successful in 22s
CI / python (push) Successful in 1s
Build and Deploy / build-and-deploy (push) Successful in 34s
2026-06-11 16:56:23 +03:00
Grendgi
c763ff423d Validate PF project listings and sync own price
All checks were successful
CI / go (push) Successful in 41s
CI / python (push) Successful in 2s
Build and Deploy / build-and-deploy (push) Successful in 33s
2026-06-11 14:46:35 +03:00
Grendgi
d53ecb2add Fix monitoring PF CI lint issues
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 37s
CI / go (push) Successful in 43s
CI / python (push) Successful in 2s
2026-06-09 11:33:33 +03:00
Grendgi
95112cc450 Add CI workflow
Some checks failed
CI / python (push) Failing after 12m10s
CI / go (push) Failing after 34s
Build and Deploy / build-and-deploy (push) Successful in 19s
2026-06-09 10:33:53 +03:00
Grendgi
1b8382a6ca Delay PF permit competitor removal
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 35s
2026-06-05 12:36:15 +03:00
Grendgi
7a4d03c905 Update monitoring PF competitor tracking 2026-06-05 12:31:52 +03:00
22 changed files with 1363 additions and 167 deletions

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
fail=0
while IFS= read -r -d '' path; do
base="$(basename "$path")"
case "$base" in
.DS_Store|.env)
echo "::error file=$path::tracked local-only file is forbidden"
fail=1
;;
esac
case "$path" in
*node_modules/*|node_modules/*)
echo "::error file=$path::tracked node_modules content is forbidden"
fail=1
;;
*.tmp|*.temp|*.bak|*.orig|*.rej|*.zip|*.tar|*.tar.gz|*.tgz|*.rar|*.7z)
echo "::error file=$path::tracked temporary/archive artifact is forbidden"
fail=1
;;
esac
if [ -f "$path" ]; then
size="$(wc -c < "$path" | tr -d ' ')"
if [ "${size:-0}" -gt 52428800 ]; then
echo "::error file=$path::tracked file is larger than 50 MiB"
fail=1
fi
fi
done < <(git ls-files -z)
exit "$fail"

41
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,41 @@
name: CI
on:
push:
pull_request:
jobs:
hygiene:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash .gitea/scripts/hygiene-check.sh
go:
runs-on: ubuntu-latest
needs: hygiene
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go build ./...
- run: go test ./...
- uses: golangci/golangci-lint-action@v7
with:
version: v2.4
args: --config .golangci.yml ./...
python:
runs-on: ubuntu-latest
needs: hygiene
steps:
- uses: actions/checkout@v4
- run: |
if ! python3 -m pip --version; then
python3 -m ensurepip --upgrade || (apt-get update && apt-get install -y python3-pip)
fi
- run: python3 -m pip install -r requirements.txt
- run: python3 -m compileall app
- run: python3 -m unittest discover -s tests

36
.golangci.yml Normal file
View File

@@ -0,0 +1,36 @@
version: "2"
run:
timeout: 3m
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
settings:
errcheck:
check-type-assertions: true
check-blank: false
exclude-functions:
- (io.Closer).Close
- (net/http.ResponseWriter).Write
- (*encoding/json.Encoder).Encode
- io.Copy
- fmt.Fprintf
- (github.com/jackc/pgx/v5.Tx).Rollback
- os.RemoveAll
staticcheck:
checks: ["all", "-SA1019", "-ST1000", "-ST1005", "-ST1020", "-ST1021", "-ST1022"]
exclusions:
rules:
- path: _test\.go
linters:
- errcheck
issues:
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -56,3 +56,7 @@ def _migrate_competitor_listings_auto_fields() -> None:
conn.execute(text("ALTER TABLE competitor_listings ADD COLUMN permit_number VARCHAR(100)")) conn.execute(text("ALTER TABLE competitor_listings ADD COLUMN permit_number VARCHAR(100)"))
if "auto_discovered" not in columns: if "auto_discovered" not in columns:
conn.execute(text("ALTER TABLE competitor_listings ADD COLUMN auto_discovered BOOLEAN NOT NULL DEFAULT 0")) conn.execute(text("ALTER TABLE competitor_listings ADD COLUMN auto_discovered BOOLEAN NOT NULL DEFAULT 0"))
if "permit_missing_checks" not in columns:
conn.execute(
text("ALTER TABLE competitor_listings ADD COLUMN permit_missing_checks INTEGER NOT NULL DEFAULT 0")
)

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import Optional
from sqlalchemy import Boolean, DateTime, Enum as SAEnum, Float, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy import Boolean, DateTime, Enum as SAEnum, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -27,9 +30,9 @@ class Employee(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200)) name: Mapped[str] = mapped_column(String(200))
portal_user_id: Mapped[str | None] = mapped_column(String(100), unique=True, index=True, nullable=True) portal_user_id: Mapped[Optional[str]] = mapped_column(String(100), unique=True, index=True, nullable=True)
tg_chat_id: Mapped[str | None] = mapped_column(String(64), unique=True, nullable=True) tg_chat_id: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True)
tg_username: Mapped[str | None] = mapped_column(String(200), nullable=True) tg_username: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
projects: Mapped[list["Project"]] = relationship(back_populates="owner") projects: Mapped[list["Project"]] = relationship(back_populates="owner")
@@ -43,21 +46,21 @@ class Project(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(300)) title: Mapped[str] = mapped_column(String(300))
deal_type: Mapped[DealType] = mapped_column(SAEnum(DealType)) deal_type: Mapped[DealType] = mapped_column(SAEnum(DealType))
our_price: Mapped[float | None] = mapped_column(Float, nullable=True) our_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True) notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Опциональные параметры — используются для подсказок похожих объявлений # Опциональные параметры — используются для подсказок похожих объявлений
dld_permit: Mapped[str | None] = mapped_column(String(100), index=True, nullable=True) dld_permit: Mapped[Optional[str]] = mapped_column(String(100), index=True, nullable=True)
building: Mapped[str | None] = mapped_column(String(300), nullable=True) building: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
bedrooms: Mapped[int | None] = mapped_column(Integer, nullable=True) bedrooms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
size_sqft: Mapped[float | None] = mapped_column(Float, nullable=True) size_sqft: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
our_url: Mapped[str | None] = mapped_column(Text, nullable=True) our_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("employees.id")) owner_id: Mapped[int] = mapped_column(ForeignKey("employees.id"))
owner: Mapped[Employee] = relationship(back_populates="projects") owner: Mapped[Employee] = relationship(back_populates="projects")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_checked_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
listings: Mapped[list["CompetitorListing"]] = relationship( listings: Mapped[list["CompetitorListing"]] = relationship(
back_populates="project", cascade="all, delete-orphan" back_populates="project", cascade="all, delete-orphan"
@@ -77,14 +80,15 @@ class CompetitorListing(Base):
source: Mapped[Source] = mapped_column(SAEnum(Source)) source: Mapped[Source] = mapped_column(SAEnum(Source))
external_id: Mapped[str] = mapped_column(String(100)) # ID на стороне PF/Bayut external_id: Mapped[str] = mapped_column(String(100)) # ID на стороне PF/Bayut
url: Mapped[str] = mapped_column(Text) url: Mapped[str] = mapped_column(Text)
title: Mapped[str | None] = mapped_column(String(500), nullable=True) title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
agent_name: Mapped[str | None] = mapped_column(String(300), nullable=True) agent_name: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
agency_name: Mapped[str | None] = mapped_column(String(300), nullable=True) agency_name: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
permit_number: Mapped[str | None] = mapped_column(String(100), nullable=True) permit_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
auto_discovered: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) auto_discovered: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
permit_missing_checks: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
current_price: Mapped[float | None] = mapped_column(Float, nullable=True) current_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
currency: Mapped[str | None] = mapped_column(String(10), nullable=True, default="AED") currency: Mapped[Optional[str]] = mapped_column(String(10), nullable=True, default="AED")
status: Mapped[ListingStatus] = mapped_column(SAEnum(ListingStatus), default=ListingStatus.ACTIVE) status: Mapped[ListingStatus] = mapped_column(SAEnum(ListingStatus), default=ListingStatus.ACTIVE)
first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
@@ -102,5 +106,5 @@ class PriceHistory(Base):
listing_id: Mapped[int] = mapped_column(ForeignKey("competitor_listings.id")) listing_id: Mapped[int] = mapped_column(ForeignKey("competitor_listings.id"))
listing: Mapped[CompetitorListing] = relationship(back_populates="price_history") listing: Mapped[CompetitorListing] = relationship(back_populates="price_history")
price: Mapped[float | None] = mapped_column(Float, nullable=True) price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) recorded_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -35,6 +35,9 @@ class ScrapedListing:
permit_number: str | None permit_number: str | None
agent_name: str | None agent_name: str | None
agency_name: str | None agency_name: str | None
building: str | None = None
bedrooms: int | None = None
size_sqft: float | None = None
is_active: bool = True is_active: bool = True

View File

@@ -100,6 +100,110 @@ def _extract_permit(item: dict) -> str | None:
return None return None
def _parse_int(value) -> int | None:
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return int(value)
text = str(value).strip().lower()
if text in {"studio", "студия"}:
return 0
m = re.search(r"\d+", text)
return int(m.group(0)) if m else None
def _extract_bedrooms(item: dict) -> int | None:
for key in ("bedrooms", "bedroom", "beds", "rooms", "bedroom_count", "bedrooms_count"):
value = item.get(key)
if isinstance(value, dict):
value = value.get("value") or value.get("count") or value.get("name")
parsed = _parse_int(value)
if parsed is not None:
return parsed
for node in _walk(item):
if not isinstance(node, dict):
continue
name = str(node.get("name") or node.get("label") or node.get("key") or "").lower()
if "bed" not in name and "спал" not in name:
continue
parsed = _parse_int(node.get("value") or node.get("count") or node.get("text"))
if parsed is not None:
return parsed
return None
def _area_to_sqft(value, unit: str | None = None) -> float | None:
parsed = parse_price(value)
if parsed is None:
return None
unit_text = (unit or "").lower()
if any(token in unit_text for token in ("sqm", "sq m", "m2", "", "метр")):
return round(parsed * 10.7639, 2)
return parsed
def _extract_size_sqft(item: dict) -> float | None:
for key in ("size", "area", "property_size", "built_up_area", "builtup_area", "plot_area"):
value = item.get(key)
unit = None
if isinstance(value, dict):
unit = value.get("unit") or value.get("unit_label") or value.get("unitLabel")
value = value.get("value") or value.get("amount") or value.get("text")
parsed = _area_to_sqft(value, unit)
if parsed is not None:
return parsed
for node in _walk(item):
if not isinstance(node, dict):
continue
name = str(node.get("name") or node.get("label") or node.get("key") or "").lower()
if not any(token in name for token in ("size", "area", "sqft", "sq ft", "площад")):
continue
parsed = _area_to_sqft(
node.get("value") or node.get("amount") or node.get("text"),
str(node.get("unit") or node.get("unit_label") or ""),
)
if parsed is not None:
return parsed
return None
def _location_candidate(node: dict) -> tuple[int, str] | None:
rank = _LOC_TYPE_PRIORITY.get(str(node.get("type", "")).upper(), -1)
name = str(node.get("name") or "").strip()
if rank < 0 or not name:
return None
return rank, name
def _extract_building_from(node) -> str | None:
best_name: str | None = None
best_rank = -1
for item in _walk(node):
if not isinstance(item, dict):
continue
candidate = _location_candidate(item)
if not candidate:
continue
rank, name = candidate
if rank > best_rank:
best_rank, best_name = rank, name
return best_name
def _extract_building(data: dict, item: dict) -> str | None:
for key in ("location", "location_tree", "locations", "locationTree", "community"):
value = item.get(key)
if value:
building = _extract_building_from(value)
if building:
return building
return _extract_building_from(data)
def _find_permit_on_page(data: dict) -> str | None: def _find_permit_on_page(data: dict) -> str | None:
"""The DLD permit number lives in a regulatory block rendered as an image, """The DLD permit number lives in a regulatory block rendered as an image,
but its plain value is still in __NEXT_DATA__: the dict that carries a but its plain value is still in __NEXT_DATA__: the dict that carries a
@@ -119,6 +223,15 @@ def _extract_id_from_url(url: str) -> str | None:
return m.group(1) if m else None return m.group(1) if m else None
def is_listing_url(url: str) -> bool:
"""True only for a concrete PF listing URL.
PF search pages also contain listing-like JSON. Treating them as a detail
page can bind monitoring to a random result, so callers must reject them.
"""
return bool(_extract_id_from_url(url or ""))
def _is_listing_dict(item: dict) -> bool: def _is_listing_dict(item: dict) -> bool:
"""Heuristic: a listing dict contains a price plus an id-like field.""" """Heuristic: a listing dict contains a price plus an id-like field."""
if not isinstance(item, dict): if not isinstance(item, dict):
@@ -131,12 +244,22 @@ def _is_listing_dict(item: dict) -> bool:
class PropertyFinderScraper: class PropertyFinderScraper:
source = SOURCE source = SOURCE
def is_listing_url(self, url: str) -> bool:
return is_listing_url(url)
def listing_id_from_url(self, url: str) -> str | None:
return _extract_id_from_url(url)
def fetch_listing(self, url: str) -> ScrapedListing | None: def fetch_listing(self, url: str) -> ScrapedListing | None:
"""Refetch a known listing URL. Returns: """Refetch a known listing URL. Returns:
- ScrapedListing(is_active=False) if the URL returns 404 (listing removed) - ScrapedListing(is_active=False) if the URL returns 404 (listing removed)
- ScrapedListing with current data if alive - ScrapedListing with current data if alive
- None on network/parse failure (we won't update the DB in that case) - None on network/parse failure (we won't update the DB in that case)
""" """
if not is_listing_url(url):
logger.warning("PF fetch_listing rejected non-listing URL: %s", url)
return None
try: try:
html = fetch_html(url) html = fetch_html(url)
except ScraperError as e: except ScraperError as e:
@@ -193,12 +316,18 @@ class PropertyFinderScraper:
permit_number=_find_permit_on_page(data) or _extract_permit(best), permit_number=_find_permit_on_page(data) or _extract_permit(best),
agent_name=agent_name, agent_name=agent_name,
agency_name=agency_name, agency_name=agency_name,
building=_extract_building(data, best),
bedrooms=_extract_bedrooms(best),
size_sqft=_extract_size_sqft(best),
is_active=True, is_active=True,
) )
def get_permit(self, url: str) -> str | None: def get_permit(self, url: str) -> str | None:
"""Fetch a listing page and return only its DLD permit number (or None). """Fetch a listing page and return only its DLD permit number (or None).
Used to compare candidates against our own permit during suggestions.""" Used to compare candidates against our own permit during suggestions."""
if not is_listing_url(url):
logger.warning("PF get_permit rejected non-listing URL: %s", url)
return None
try: try:
html = fetch_html(url) html = fetch_html(url)
except ScraperError as e: except ScraperError as e:
@@ -300,6 +429,8 @@ class PropertyFinderScraper:
agent_name, agency_name = _extract_broker(node) agent_name, agency_name = _extract_broker(node)
share = node.get("share_url") or node.get("path") share = node.get("share_url") or node.get("path")
cand_url = share if str(share).startswith("http") else urljoin(BASE_URL, str(share or "")) cand_url = share if str(share).startswith("http") else urljoin(BASE_URL, str(share or ""))
if not is_listing_url(cand_url):
continue
results.append( results.append(
ScrapedListing( ScrapedListing(
@@ -312,6 +443,9 @@ class PropertyFinderScraper:
permit_number=_extract_permit(node), permit_number=_extract_permit(node),
agent_name=agent_name, agent_name=agent_name,
agency_name=agency_name, agency_name=agency_name,
building=_extract_building(data, node),
bedrooms=_extract_bedrooms(node),
size_sqft=_extract_size_sqft(node),
is_active=True, is_active=True,
) )
) )

View File

@@ -14,7 +14,6 @@ Adding new competitors is done via the web UI (user pastes URLs) — not here.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime
@@ -40,6 +39,7 @@ BAYUT = BayutScraper()
# Same-building suggestions beyond exact permit matches are a browse heuristic — # Same-building suggestions beyond exact permit matches are a browse heuristic —
# cap how many we show so the page stays usable. # cap how many we show so the page stays usable.
_SUGGEST_OTHERS_LIMIT = 30 _SUGGEST_OTHERS_LIMIT = 30
_PERMIT_MISSING_DELETE_THRESHOLD = 3
# Bayut moved to fully client-side rendering (no __NEXT_DATA__, Algolia keys # Bayut moved to fully client-side rendering (no __NEXT_DATA__, Algolia keys
# hidden), so it can't be scraped over plain HTTP — disabled until we add a # hidden), so it can't be scraped over plain HTTP — disabled until we add a
@@ -60,6 +60,12 @@ def detect_source_from_url(url: str) -> Source | None:
return None return None
def _is_supported_listing_url(source: Source, url: str) -> bool:
if source == Source.PROPERTYFINDER:
return PF.is_listing_url(url)
return source == Source.BAYUT
def _fmt_price(value: float | None, currency: str | None = "AED") -> str: def _fmt_price(value: float | None, currency: str | None = "AED") -> str:
if value is None: if value is None:
return "" return ""
@@ -79,6 +85,21 @@ def _listing_key(source: Source | str, external_id: str) -> tuple[str, str]:
return source_value, str(external_id or "") return source_value, str(external_id or "")
def _project_own_listing_key(project: Project) -> tuple[str, str] | None:
if not project.our_url:
return None
source = detect_source_from_url(project.our_url)
if source == Source.PROPERTYFINDER:
listing_id = PF.listing_id_from_url(project.our_url)
return _listing_key(source, listing_id) if listing_id else None
return None
def _is_own_listing(project: Project, item: ScrapedListing) -> bool:
own_key = _project_own_listing_key(project)
return bool(own_key and own_key == _listing_key(item.source, item.external_id))
def _format_listing_added(project: Project, listing: CompetitorListing, *, auto: bool) -> str: def _format_listing_added(project: Project, listing: CompetitorListing, *, auto: bool) -> str:
title = listing.title or "без названия" title = listing.title or "без названия"
prefix = "✅ <b>Автоматически добавлен конкурент</b>" if auto else "✅ <b>Добавлен конкурент</b>" prefix = "✅ <b>Автоматически добавлен конкурент</b>" if auto else "✅ <b>Добавлен конкурент</b>"
@@ -118,6 +139,8 @@ def add_competitor_url(db: Session, project: Project, url: str) -> tuple[Competi
"Bayut временно не поддерживается — площадка перешла на защищённый " "Bayut временно не поддерживается — площадка перешла на защищённый "
"рендеринг. Добавляйте ссылки PropertyFinder." "рендеринг. Добавляйте ссылки PropertyFinder."
) )
if not _is_supported_listing_url(source, url):
return None, "Укажите ссылку на конкретное объявление, а не на страницу поиска"
scraper = _scraper_for(source) scraper = _scraper_for(source)
scraped = scraper.fetch_listing(url) scraped = scraper.fetch_listing(url)
@@ -138,6 +161,8 @@ def add_competitor_url(db: Session, project: Project, url: str) -> tuple[Competi
) )
if existing: if existing:
return None, "Это объявление уже добавлено в проект" return None, "Это объявление уже добавлено в проект"
if _is_own_listing(project, scraped):
return None, "Это ссылка на наш объект, а не на конкурента"
now = datetime.utcnow() now = datetime.utcnow()
listing = CompetitorListing( listing = CompetitorListing(
@@ -164,6 +189,43 @@ def add_competitor_url(db: Session, project: Project, url: str) -> tuple[Competi
return listing, "" return listing, ""
def parse_our_listing_url(url: str) -> dict:
"""Parse our own PF listing for project metadata.
Used by the Go API before project validation, so users can paste only the
concrete object URL and let the service fill price/permit/building/area.
"""
url = (url or "").strip()
if not url:
raise ValueError("URL пустой")
source = detect_source_from_url(url)
if source is None:
raise ValueError("URL должен быть с propertyfinder.ae или bayut.com")
if source == Source.BAYUT and not BAYUT_ENABLED:
raise ValueError(
"Bayut временно не поддерживается — площадка перешла на защищённый "
"рендеринг. Используйте ссылку PropertyFinder."
)
if not _is_supported_listing_url(source, url):
raise ValueError("Укажите ссылку на конкретное объявление, а не на страницу поиска")
scraped = _scraper_for(source).fetch_listing(url)
if scraped is None:
raise ValueError("Не удалось загрузить страницу — сайт мог заблокировать запрос, попробуйте позже")
if not scraped.is_active:
raise ValueError("Страница объявления вернула 404 — ссылка битая или объявление снято")
return {
"title": scraped.title,
"our_price": scraped.price,
"dld_permit": scraped.permit_number,
"building": scraped.building,
"bedrooms": scraped.bedrooms,
"size_sqft": scraped.size_sqft,
"currency": scraped.currency or "AED",
}
def add_competitor_urls(db: Session, project: Project, urls: list[str]) -> dict: def add_competitor_urls(db: Session, project: Project, urls: list[str]) -> dict:
"""Add several pasted/selected URLs in one go (used by the suggest page's """Add several pasted/selected URLs in one go (used by the suggest page's
multi-select). Processes them sequentially — each one re-fetches the page — multi-select). Processes them sequentially — each one re-fetches the page —
@@ -237,12 +299,14 @@ def _hide_tracked_suggestions(
def sync_permit_competitors( def sync_permit_competitors(
db: Session, db: Session,
project: Project, project: Project,
*,
count_missing: bool = True,
) -> tuple[list[str], dict[str, list[ScrapedListing]], str | None]: ) -> tuple[list[str], dict[str, list[ScrapedListing]], str | None]:
"""Auto-maintain competitor listings with the same DLD permit. """Auto-maintain competitor listings with the same DLD permit.
Exact-permit matches are added automatically. Previously auto-discovered Exact-permit matches are added automatically. Previously auto-discovered
exact-permit listings that disappear from the next permit search are exact-permit listings are deleted only after several consecutive permit
deleted. Manual competitors are never auto-deleted. searches miss them. Manual competitors are never auto-deleted.
""" """
changes: list[str] = [] changes: list[str] = []
our_permit = resolve_our_permit(project) our_permit = resolve_our_permit(project)
@@ -255,6 +319,7 @@ def sync_permit_competitors(
item item
for item in suggestions["propertyfinder"] for item in suggestions["propertyfinder"]
if _normalize_permit(item.permit_number) == normalized_permit if _normalize_permit(item.permit_number) == normalized_permit
and not _is_own_listing(project, item)
] ]
matched_keys = {_listing_key(item.source, item.external_id) for item in matches} matched_keys = {_listing_key(item.source, item.external_id) for item in matches}
@@ -268,6 +333,7 @@ def sync_permit_competitors(
listing = existing.get(key) listing = existing.get(key)
if listing: if listing:
listing.permit_number = item.permit_number or our_permit listing.permit_number = item.permit_number or our_permit
listing.permit_missing_checks = 0
if item.title: if item.title:
listing.title = item.title listing.title = item.title
if item.agent_name: if item.agent_name:
@@ -287,6 +353,11 @@ def sync_permit_competitors(
continue continue
if _listing_key(listing.source, listing.external_id) in matched_keys: if _listing_key(listing.source, listing.external_id) in matched_keys:
continue continue
if not count_missing:
continue
listing.permit_missing_checks = (listing.permit_missing_checks or 0) + 1
if listing.permit_missing_checks < _PERMIT_MISSING_DELETE_THRESHOLD:
continue
changes.append(_format_listing_removed(project, listing, auto=True)) changes.append(_format_listing_removed(project, listing, auto=True))
db.delete(listing) db.delete(listing)
@@ -297,6 +368,7 @@ def check_project(db: Session, project: Project) -> list[str]:
"""Re-scan all tracked competitor listings for one project. Returns notification texts.""" """Re-scan all tracked competitor listings for one project. Returns notification texts."""
changes: list[str] = [] changes: list[str] = []
now = datetime.utcnow() now = datetime.utcnow()
changes.extend(refresh_our_listing(db, project, now=now))
sync_changes, _, _ = sync_permit_competitors(db, project) sync_changes, _, _ = sync_permit_competitors(db, project)
changes.extend(sync_changes) changes.extend(sync_changes)
@@ -363,6 +435,58 @@ def check_project(db: Session, project: Project) -> list[str]:
return changes return changes
def refresh_our_listing(db: Session, project: Project, *, now: datetime | None = None) -> list[str]:
"""Parse our own listing and keep project.our_price in sync.
This never creates a competitor listing. It only updates project metadata
from the concrete `our_url`, so PF search pages are ignored.
"""
url = (project.our_url or "").strip()
if not url:
return []
source = detect_source_from_url(url)
if source is None or source == Source.BAYUT and not BAYUT_ENABLED:
return []
if not _is_supported_listing_url(source, url):
logger.warning("Project %s has non-listing our_url: %s", project.id, url)
return []
scraped = _scraper_for(source).fetch_listing(url)
if scraped is None or not scraped.is_active:
return []
changed: list[str] = []
if scraped.permit_number and not project.dld_permit:
project.dld_permit = scraped.permit_number
if scraped.building and not project.building:
project.building = scraped.building
if scraped.bedrooms is not None and project.bedrooms is None:
project.bedrooms = scraped.bedrooms
if scraped.size_sqft is not None and project.size_sqft is None:
project.size_sqft = scraped.size_sqft
old_price = project.our_price
new_price = scraped.price
if new_price is not None and old_price != new_price:
project.our_price = new_price
if old_price is not None:
delta = new_price - old_price
pct = (delta / old_price * 100.0) if old_price else 0.0
arrow = "📈" if delta > 0 else "📉"
changed.append(
f"{arrow} <b>Наша цена скорректирована</b> — {_source_label(source.value)}\n"
f"{project.title}\n"
f"Было: {_fmt_price(old_price)}\n"
f"Стало: {_fmt_price(new_price, scraped.currency or 'AED')} "
f"({'+' if delta > 0 else ''}{delta:,.0f} / {pct:+.1f}%)\n"
f"{url}".replace(",", " ")
)
if now is not None:
project.last_checked_at = now
db.flush()
return changed
def _notify_owner(project: Project, changes: list[str]) -> None: def _notify_owner(project: Project, changes: list[str]) -> None:
if not changes: if not changes:
return return
@@ -454,9 +578,10 @@ def suggest_similar(
excluded.update((l.source.value, l.external_id) for l in project.listings) excluded.update((l.source.value, l.external_id) for l in project.listings)
if project.our_url: if project.our_url:
own_src = detect_source_from_url(project.our_url) own_src = detect_source_from_url(project.our_url)
m = re.search(r"(\d+)\.html", project.our_url) if own_src == Source.PROPERTYFINDER:
if own_src and m: listing_id = PF.listing_id_from_url(project.our_url)
excluded.add((own_src.value, m.group(1))) if listing_id:
excluded.add((own_src.value, listing_id))
for src in out: for src in out:
out[src] = [c for c in out[src] if (src, c.external_id) not in excluded] out[src] = [c for c in out[src] if (src, c.external_id) not in excluded]
@@ -470,8 +595,9 @@ def suggest_similar(
permits = list(ex.map(PF.get_permit, [c.url for c in pf])) permits = list(ex.map(PF.get_permit, [c.url for c in pf]))
for cand, permit in zip(pf, permits): for cand, permit in zip(pf, permits):
cand.permit_number = permit cand.permit_number = permit
matches = [c for c in pf if c.permit_number == our_permit] normalized = _normalize_permit(our_permit)
others = [c for c in pf if c.permit_number != our_permit] matches = [c for c in pf if _normalize_permit(c.permit_number) == normalized]
others = [c for c in pf if _normalize_permit(c.permit_number) != normalized]
out["propertyfinder"] = matches + others[:_SUGGEST_OTHERS_LIMIT] out["propertyfinder"] = matches + others[:_SUGGEST_OTHERS_LIMIT]
except Exception as e: except Exception as e:
logger.exception("PF permit enrichment failed: %s", e) logger.exception("PF permit enrichment failed: %s", e)

View File

@@ -10,13 +10,15 @@ import json
import sys import sys
from typing import Any from typing import Any
from sqlalchemy import text
from app.db import SessionLocal, init_db from app.db import SessionLocal, init_db
from app.models import Project from app.models import Project
from app.services.monitor import ( from app.services.monitor import (
BAYUT_ENABLED, BAYUT_ENABLED,
add_competitor_url, add_competitor_url,
_format_listing_added,
notify_project_changes, notify_project_changes,
parse_our_listing_url,
run_check_all, run_check_all,
run_check_for_project, run_check_for_project,
sync_permit_competitors, sync_permit_competitors,
@@ -66,7 +68,6 @@ def cmd_add_listing(payload: dict[str, Any]) -> None:
listing, err = add_competitor_url(db, project, url) listing, err = add_competitor_url(db, project, url)
if err: if err:
_fail(err) _fail(err)
notify_project_changes(project, [_format_listing_added(project, listing, auto=False)])
_write({"listing_id": listing.id}) _write({"listing_id": listing.id})
finally: finally:
db.close() db.close()
@@ -83,7 +84,6 @@ def cmd_add_listings(payload: dict[str, Any]) -> None:
added = 0 added = 0
skipped = 0 skipped = 0
errors: list[str] = [] errors: list[str] = []
notifications: list[str] = []
seen: set[str] = set() seen: set[str] = set()
for raw in urls: for raw in urls:
url = str(raw or "").strip() url = str(raw or "").strip()
@@ -97,9 +97,6 @@ def cmd_add_listings(payload: dict[str, Any]) -> None:
errors.append(err) errors.append(err)
else: else:
added += 1 added += 1
notifications.append(_format_listing_added(project, listing, auto=False))
if notifications:
notify_project_changes(project, notifications)
_write({"added": added, "skipped": skipped, "errors": errors}) _write({"added": added, "skipped": skipped, "errors": errors})
finally: finally:
db.close() db.close()
@@ -122,7 +119,7 @@ def cmd_suggest(payload: dict[str, Any]) -> None:
project = db.get(Project, project_id) project = db.get(Project, project_id)
if not project: if not project:
_fail("project not found") _fail("project not found")
changes, suggestions, permit = sync_permit_competitors(db, project) changes, suggestions, permit = sync_permit_competitors(db, project, count_missing=False)
db.commit() db.commit()
if changes: if changes:
notify_project_changes(project, changes) notify_project_changes(project, changes)
@@ -138,12 +135,31 @@ def cmd_suggest(payload: dict[str, Any]) -> None:
db.close() db.close()
def cmd_parse_own_listing(payload: dict[str, Any]) -> None:
url = str(payload.get("url") or "")
try:
_write(parse_our_listing_url(url))
except ValueError as exc:
_fail(str(exc))
def cmd_health(_: dict[str, Any]) -> None:
db = SessionLocal()
try:
db.execute(text("SELECT 1"))
_write({"status": "ok"})
finally:
db.close()
COMMANDS = { COMMANDS = {
"health": cmd_health,
"add-listing": cmd_add_listing, "add-listing": cmd_add_listing,
"add-listings": cmd_add_listings, "add-listings": cmd_add_listings,
"check-project": cmd_check_project, "check-project": cmd_check_project,
"check-all": cmd_check_all, "check-all": cmd_check_all,
"suggest": cmd_suggest, "suggest": cmd_suggest,
"parse-own-listing": cmd_parse_own_listing,
} }

View File

@@ -29,7 +29,11 @@ func main() {
slog.Error("db_open_failed", "error", err) slog.Error("db_open_failed", "error", err)
os.Exit(1) os.Exit(1)
} }
defer app.Close() defer func() {
if err := app.Close(); err != nil {
slog.Warn("app_close_failed", "error", err)
}
}()
if !app.TG.Enabled() { if !app.TG.Enabled() {
slog.Error("telegram_token_missing") slog.Error("telegram_token_missing")
os.Exit(1) os.Exit(1)

View File

@@ -24,7 +24,11 @@ func main() {
slog.Error("db_open_failed", "error", err) slog.Error("db_open_failed", "error", err)
os.Exit(1) os.Exit(1)
} }
defer app.Close() defer func() {
if err := app.Close(); err != nil {
slog.Warn("app_close_failed", "error", err)
}
}()
interval := cfg.SchedulerInterval() interval := cfg.SchedulerInterval()
slog.Info("monitoring_pf_scheduler_started", "interval", interval.String()) slog.Info("monitoring_pf_scheduler_started", "interval", interval.String())

View File

@@ -27,7 +27,11 @@ func main() {
slog.Error("db_open_failed", "error", err) slog.Error("db_open_failed", "error", err)
os.Exit(1) os.Exit(1)
} }
defer app.Close() defer func() {
if err := app.Close(); err != nil {
slog.Warn("app_close_failed", "error", err)
}
}()
server := &http.Server{ server := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.WebHost, cfg.WebPort), Addr: fmt.Sprintf("%s:%d", cfg.WebHost, cfg.WebPort),

7
go.mod
View File

@@ -1,8 +1,11 @@
module monitoring-pf module monitoring-pf
go 1.25.0 go 1.25.7
require modernc.org/sqlite v1.50.1 require (
gitea.estateliga.work/admin/portal-common v0.3.0
modernc.org/sqlite v1.50.1
)
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect

2
go.sum
View File

@@ -1,3 +1,5 @@
gitea.estateliga.work/admin/portal-common v0.3.0 h1:xpr9UeLXk5pCcNXcTVGZzJZr0Ni7An7DV0OkuYv9qVM=
gitea.estateliga.work/admin/portal-common v0.3.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=

View File

@@ -15,6 +15,7 @@ type Config struct {
ScrapeIntervalHours int ScrapeIntervalHours int
TGBotToken string TGBotToken string
TGBotUsername string TGBotUsername string
InternalAPIKey string
WorkerPython string WorkerPython string
WorkerModule string WorkerModule string
} }
@@ -28,6 +29,7 @@ func LoadConfig() Config {
ScrapeIntervalHours: max(1, envInt("SCRAPE_INTERVAL_HOURS", 4)), ScrapeIntervalHours: max(1, envInt("SCRAPE_INTERVAL_HOURS", 4)),
TGBotToken: env("TG_BOT_TOKEN", ""), TGBotToken: env("TG_BOT_TOKEN", ""),
TGBotUsername: strings.TrimPrefix(env("TG_BOT_USERNAME", ""), "@"), TGBotUsername: strings.TrimPrefix(env("TG_BOT_USERNAME", ""), "@"),
InternalAPIKey: env("INTERNAL_API_KEY", env("PORTAL_INTERNAL_API_KEY", "")),
WorkerPython: env("WORKER_PYTHON", "python"), WorkerPython: env("WORKER_PYTHON", "python"),
WorkerModule: env("WORKER_MODULE", "app.worker"), WorkerModule: env("WORKER_MODULE", "app.worker"),
} }

View File

@@ -76,6 +76,22 @@ type PricePoint struct {
RecordedAt *string `json:"recorded_at"` RecordedAt *string `json:"recorded_at"`
} }
type TeamOverviewRow struct {
EmployeeID int64 `json:"employee_id"`
EmployeeName string `json:"employee_name"`
PortalUserID *string `json:"portal_user_id"`
TelegramLinked bool `json:"telegram_linked"`
ProjectID *int64 `json:"project_id"`
ProjectTitle *string `json:"project_title"`
DealType *string `json:"deal_type"`
DLDPermit *string `json:"dld_permit"`
LastCheckedAt *string `json:"last_checked_at"`
ListingsTotal int64 `json:"listings_total"`
ListingsActive int64 `json:"listings_active"`
ListingsRemoved int64 `json:"listings_removed"`
MinCompetitorPrice *float64 `json:"min_competitor_price"`
}
func OpenApp(ctx context.Context, cfg Config) (*App, error) { func OpenApp(ctx context.Context, cfg Config) (*App, error) {
db, err := sql.Open("sqlite", sqliteDSN(cfg.DatabaseURL)) db, err := sql.Open("sqlite", sqliteDSN(cfg.DatabaseURL))
if err != nil { if err != nil {
@@ -121,6 +137,14 @@ func (a *App) Close() error {
return a.DB.Close() return a.DB.Close()
} }
func closeRows(rows *sql.Rows) {
_ = rows.Close()
}
func rollbackTx(tx *sql.Tx) {
_ = tx.Rollback()
}
func (a *App) InitDB(ctx context.Context) error { func (a *App) InitDB(ctx context.Context) error {
stmts := []string{ stmts := []string{
`CREATE TABLE IF NOT EXISTS employees ( `CREATE TABLE IF NOT EXISTS employees (
@@ -161,6 +185,7 @@ func (a *App) InitDB(ctx context.Context) error {
agency_name VARCHAR(300), agency_name VARCHAR(300),
permit_number VARCHAR(100), permit_number VARCHAR(100),
auto_discovered BOOLEAN NOT NULL DEFAULT 0, auto_discovered BOOLEAN NOT NULL DEFAULT 0,
permit_missing_checks INTEGER NOT NULL DEFAULT 0,
current_price FLOAT, current_price FLOAT,
currency VARCHAR(10), currency VARCHAR(10),
status VARCHAR(7) NOT NULL, status VARCHAR(7) NOT NULL,
@@ -193,7 +218,7 @@ func (a *App) migrateEmployees(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
defer rows.Close() defer closeRows(rows)
columns := map[string]bool{} columns := map[string]bool{}
for rows.Next() { for rows.Next() {
var cid int var cid int
@@ -220,7 +245,7 @@ func (a *App) migrateCompetitorListings(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
defer rows.Close() defer closeRows(rows)
columns := map[string]bool{} columns := map[string]bool{}
for rows.Next() { for rows.Next() {
var cid int var cid int
@@ -243,6 +268,11 @@ func (a *App) migrateCompetitorListings(ctx context.Context) error {
return err return err
} }
} }
if !columns["permit_missing_checks"] {
if _, err := a.DB.ExecContext(ctx, `ALTER TABLE competitor_listings ADD COLUMN permit_missing_checks INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
}
return nil return nil
} }
@@ -303,15 +333,6 @@ func enumStatusOut(value string) string {
} }
} }
func enumStatusIn(value string) string {
switch strings.ToLower(value) {
case "removed":
return "REMOVED"
default:
return "ACTIVE"
}
}
func timeOut(raw sql.NullString) *string { func timeOut(raw sql.NullString) *string {
if !raw.Valid || strings.TrimSpace(raw.String) == "" { if !raw.Valid || strings.TrimSpace(raw.String) == "" {
return nil return nil

View File

@@ -1,11 +1,16 @@
package pf package pf
import ( import (
"context"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
commonmw "gitea.estateliga.work/admin/portal-common/middleware"
) )
type Server struct { type Server struct {
@@ -20,19 +25,38 @@ type bulkPayload struct {
URLs []string `json:"urls"` URLs []string `json:"urls"`
} }
type componentProbe struct {
Name string `json:"name"`
Status string `json:"status"`
LatencyMs int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
}
func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := s.apiPath(r.URL.Path) path := s.apiPath(r.URL.Path)
switch { switch {
case path == "/healthz": case path == "/healthz":
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
case path == "/health/detail":
s.healthDetail(w, r)
case path == "/": case path == "/":
writeJSON(w, http.StatusOK, map[string]string{"service": "monitoring-pf", "ui": "portal", "api": "go"}) writeJSON(w, http.StatusOK, map[string]string{"service": "monitoring-pf", "ui": "portal", "api": "go"})
case !strings.HasPrefix(path, "/api/v1"): case !strings.HasPrefix(path, "/api/v1"):
writeError(w, http.StatusNotFound, "not found") writeError(w, http.StatusNotFound, "not found")
default:
commonmw.InternalAuth(s.App.Cfg.InternalAPIKey)(http.HandlerFunc(s.serveAPI)).ServeHTTP(w, r)
}
}
func (s Server) serveAPI(w http.ResponseWriter, r *http.Request) {
path := s.apiPath(r.URL.Path)
switch {
case path == "/api/v1/access/me" && r.Method == http.MethodGet: case path == "/api/v1/access/me" && r.Method == http.MethodGet:
s.accessMe(w, r) s.accessMe(w, r)
case path == "/api/v1/summary" && r.Method == http.MethodGet: case path == "/api/v1/summary" && r.Method == http.MethodGet:
s.summary(w, r) s.summary(w, r)
case path == "/api/v1/team/overview" && r.Method == http.MethodGet:
s.teamOverview(w, r)
case path == "/api/v1/employees": case path == "/api/v1/employees":
s.employees(w, r) s.employees(w, r)
case strings.HasPrefix(path, "/api/v1/employees/"): case strings.HasPrefix(path, "/api/v1/employees/"):
@@ -59,6 +83,104 @@ func (s Server) apiPath(path string) string {
return path return path
} }
func (s Server) healthDetail(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
components := []componentProbe{
s.probeDatabase(ctx),
s.probeWorker(ctx),
s.probeTelegram(ctx),
s.probeScheduler(ctx),
s.probeProjectIntegrity(ctx),
}
writeJSON(w, http.StatusOK, map[string]any{"components": components})
}
func (s Server) probeDatabase(ctx context.Context) componentProbe {
start := time.Now()
if err := s.App.DB.PingContext(ctx); err != nil {
return componentProbe{Name: "sqlite", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "sqlite", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (s Server) probeWorker(ctx context.Context) componentProbe {
start := time.Now()
if err := s.App.Worker.Health(ctx); err != nil {
return componentProbe{Name: "python_worker", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "python_worker", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (s Server) probeTelegram(ctx context.Context) componentProbe {
start := time.Now()
if !s.App.TG.Enabled() {
return componentProbe{Name: "telegram_bot", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: "TG_BOT_TOKEN is not configured"}
}
if _, err := s.App.TG.BotUsername(ctx); err != nil {
return componentProbe{Name: "telegram_bot", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
return componentProbe{Name: "telegram_bot", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (s Server) probeScheduler(ctx context.Context) componentProbe {
start := time.Now()
staleAfter := max(1, s.App.Cfg.ScrapeIntervalHours*2) * 3600
staleModifier := "-" + strconv.Itoa(staleAfter) + " seconds"
var total, neverChecked, stale int64
err := s.App.DB.QueryRowContext(ctx, `
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN last_checked_at IS NULL THEN 1 ELSE 0 END), 0) AS never_checked,
COALESCE(SUM(CASE WHEN last_checked_at IS NOT NULL AND datetime(last_checked_at) < datetime('now', ?) THEN 1 ELSE 0 END), 0) AS stale
FROM projects`,
staleModifier,
).Scan(&total, &neverChecked, &stale)
if err != nil {
return componentProbe{Name: "scheduler", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
if total > 0 && (neverChecked > 0 || stale > 0) {
return componentProbe{
Name: "scheduler",
Status: "down",
LatencyMs: time.Since(start).Milliseconds(),
Error: "projects=" + strconv.FormatInt(total, 10) +
" never_checked=" + strconv.FormatInt(neverChecked, 10) +
" stale=" + strconv.FormatInt(stale, 10) +
" stale_after_sec=" + strconv.Itoa(staleAfter),
}
}
return componentProbe{Name: "scheduler", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (s Server) probeProjectIntegrity(ctx context.Context) componentProbe {
start := time.Now()
var projects, missingOwner, autoWithoutPermit, searchURLs int64
err := s.App.DB.QueryRowContext(ctx, `
SELECT
(SELECT COUNT(*) FROM projects),
(SELECT COUNT(*) FROM projects p LEFT JOIN employees e ON e.id = p.owner_id WHERE e.id IS NULL),
(SELECT COUNT(*) FROM competitor_listings WHERE auto_discovered = 1 AND (permit_number IS NULL OR trim(permit_number) = '')),
(SELECT COUNT(*) FROM competitor_listings WHERE url LIKE '%/search%' OR url LIKE '%?%' AND external_id = '')
`).Scan(&projects, &missingOwner, &autoWithoutPermit, &searchURLs)
if err != nil {
return componentProbe{Name: "project_integrity", Status: "down", LatencyMs: time.Since(start).Milliseconds(), Error: err.Error()}
}
if missingOwner > 0 || autoWithoutPermit > 0 || searchURLs > 0 {
return componentProbe{
Name: "project_integrity",
Status: "down",
LatencyMs: time.Since(start).Milliseconds(),
Error: "projects=" + strconv.FormatInt(projects, 10) +
" missing_owner=" + strconv.FormatInt(missingOwner, 10) +
" auto_without_permit=" + strconv.FormatInt(autoWithoutPermit, 10) +
" suspicious_search_urls=" + strconv.FormatInt(searchURLs, 10),
}
}
return componentProbe{Name: "project_integrity", Status: "ok", LatencyMs: time.Since(start).Milliseconds()}
}
func (s Server) accessMe(w http.ResponseWriter, r *http.Request) { func (s Server) accessMe(w http.ResponseWriter, r *http.Request) {
portalID := portalUserID(r) portalID := portalUserID(r)
emp, err := s.App.CurrentEmployee(r.Context(), portalID, false) emp, err := s.App.CurrentEmployee(r.Context(), portalID, false)
@@ -86,6 +208,7 @@ func (s Server) accessMe(w http.ResponseWriter, r *http.Request) {
"is_admin": isAdmin(r), "is_admin": isAdmin(r),
"portal_user_id": nullablePlain(portalID), "portal_user_id": nullablePlain(portalID),
"telegram_linked": emp != nil && emp.TGChatID != nil && *emp.TGChatID != "", "telegram_linked": emp != nil && emp.TGChatID != nil && *emp.TGChatID != "",
"can_view_team": canViewTeam(r),
"employee": emp, "employee": emp,
"telegram_bot_username": nullablePlain(botUsername), "telegram_bot_username": nullablePlain(botUsername),
"telegram_start_command": command, "telegram_start_command": command,
@@ -94,13 +217,23 @@ func (s Server) accessMe(w http.ResponseWriter, r *http.Request) {
} }
func (s Server) summary(w http.ResponseWriter, r *http.Request) { func (s Server) summary(w http.ResponseWriter, r *http.Request) {
emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), false) var emp *Employee
if err != nil { var err error
writeError(w, http.StatusInternalServerError, err.Error()) if requested := ownerPortalIDFromQuery(r); requested != nil {
return var ok bool
} emp, ok = s.resolveProjectOwnerForRead(w, r, requested)
if emp != nil && emp.TGChatID == nil { if !ok {
emp = nil return
}
} else {
emp, err = s.App.CurrentEmployee(r.Context(), portalUserID(r), false)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if emp != nil && emp.TGChatID == nil {
emp = nil
}
} }
out, err := s.App.Summary(r.Context(), emp) out, err := s.App.Summary(r.Context(), emp)
if err != nil { if err != nil {
@@ -110,6 +243,19 @@ func (s Server) summary(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
} }
func (s Server) teamOverview(w http.ResponseWriter, r *http.Request) {
if !canViewTeam(r) {
writeError(w, http.StatusNotFound, "not found")
return
}
items, err := s.App.TeamOverview(r.Context(), subordinatePortalIDs(r), isAdmin(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}
func (s Server) employees(w http.ResponseWriter, r *http.Request) { func (s Server) employees(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@@ -118,7 +264,15 @@ func (s Server) employees(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
items, err := s.App.ListEmployees(r.Context(), isAdmin(r), emp) var items []Employee
if isAdmin(r) {
items, err = s.App.ListEmployees(r.Context(), true, emp)
} else if canViewTeam(r) {
ids := append(subordinatePortalIDs(r), portalUserID(r))
items, err = s.App.ListEmployeesByPortalUserIDs(r.Context(), ids)
} else {
items, err = s.App.ListEmployees(r.Context(), false, emp)
}
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -186,12 +340,18 @@ func (s Server) employeeItem(w http.ResponseWriter, r *http.Request, path string
} }
func (s Server) projects(w http.ResponseWriter, r *http.Request) { func (s Server) projects(w http.ResponseWriter, r *http.Request) {
emp, ok := s.requireEmployee(w, r)
if !ok {
return
}
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
var emp *Employee
var ok bool
if requested := ownerPortalIDFromQuery(r); requested != nil {
emp, ok = s.resolveProjectOwnerForRead(w, r, requested)
} else {
emp, ok = s.requireEmployee(w, r)
}
if !ok {
return
}
items, err := s.App.ListProjects(r.Context(), emp.ID) items, err := s.App.ListProjects(r.Context(), emp.ID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
@@ -203,7 +363,11 @@ func (s Server) projects(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &payload) { if !decodeJSON(w, r, &payload) {
return return
} }
project, err := s.App.CreateProject(r.Context(), emp.ID, payload) owner, ok := s.resolveProjectOwner(w, r, payload.OwnerPortalUserID)
if !ok {
return
}
project, err := s.App.CreateProject(r.Context(), owner.ID, payload)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
@@ -215,10 +379,6 @@ func (s Server) projects(w http.ResponseWriter, r *http.Request) {
} }
func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string) { func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string) {
emp, ok := s.requireEmployee(w, r)
if !ok {
return
}
rest := strings.TrimPrefix(path, "/api/v1/projects/") rest := strings.TrimPrefix(path, "/api/v1/projects/")
parts := strings.Split(strings.Trim(rest, "/"), "/") parts := strings.Split(strings.Trim(rest, "/"), "/")
if len(parts) == 0 { if len(parts) == 0 {
@@ -229,13 +389,17 @@ func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string)
if !ok { if !ok {
return return
} }
ownerID, ok := s.projectOwnerIDForAccess(w, r, projectID)
if !ok {
return
}
if len(parts) == 1 { if len(parts) == 1 {
s.projectCRUD(w, r, emp.ID, projectID) s.projectCRUD(w, r, ownerID, projectID)
return return
} }
switch { switch {
case len(parts) == 2 && parts[1] == "check" && r.Method == http.MethodPost: case len(parts) == 2 && parts[1] == "check" && r.Method == http.MethodPost:
if _, err := s.App.ProjectByID(r.Context(), emp.ID, projectID, false); err != nil { if _, err := s.App.ProjectByID(r.Context(), ownerID, projectID, false); err != nil {
writeError(w, http.StatusNotFound, "project not found") writeError(w, http.StatusNotFound, "project not found")
return return
} }
@@ -246,7 +410,7 @@ func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string)
} }
writeJSON(w, http.StatusOK, map[string]int{"changes": changes}) writeJSON(w, http.StatusOK, map[string]int{"changes": changes})
case len(parts) == 2 && parts[1] == "suggest" && r.Method == http.MethodGet: case len(parts) == 2 && parts[1] == "suggest" && r.Method == http.MethodGet:
if _, err := s.App.ProjectByID(r.Context(), emp.ID, projectID, false); err != nil { if _, err := s.App.ProjectByID(r.Context(), ownerID, projectID, false); err != nil {
writeError(w, http.StatusNotFound, "project not found") writeError(w, http.StatusNotFound, "project not found")
return return
} }
@@ -257,9 +421,9 @@ func (s Server) projectItem(w http.ResponseWriter, r *http.Request, path string)
} }
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
case len(parts) == 2 && parts[1] == "listings" && r.Method == http.MethodPost: case len(parts) == 2 && parts[1] == "listings" && r.Method == http.MethodPost:
s.addListing(w, r, emp.ID, projectID) s.addListing(w, r, ownerID, projectID)
case len(parts) == 3 && parts[1] == "listings" && parts[2] == "bulk" && r.Method == http.MethodPost: case len(parts) == 3 && parts[1] == "listings" && parts[2] == "bulk" && r.Method == http.MethodPost:
s.addListings(w, r, emp.ID, projectID) s.addListings(w, r, ownerID, projectID)
default: default:
writeError(w, http.StatusNotFound, "not found") writeError(w, http.StatusNotFound, "not found")
} }
@@ -340,10 +504,6 @@ func (s Server) addListings(w http.ResponseWriter, r *http.Request, ownerID, pro
} }
func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string) { func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string) {
emp, ok := s.requireEmployee(w, r)
if !ok {
return
}
id, ok := pathID(w, strings.TrimPrefix(path, "/api/v1/listings/")) id, ok := pathID(w, strings.TrimPrefix(path, "/api/v1/listings/"))
if !ok { if !ok {
return return
@@ -352,45 +512,17 @@ func (s Server) listingItem(w http.ResponseWriter, r *http.Request, path string)
writeError(w, http.StatusMethodNotAllowed, "method not allowed") writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return return
} }
deleted, err := s.App.DeleteListing(r.Context(), emp.ID, id) ownerID, ok := s.listingOwnerIDForAccess(w, r, id)
if err != nil { if !ok {
return
}
if err := s.App.DeleteListing(r.Context(), ownerID, id); err != nil {
writeError(w, http.StatusNotFound, "listing not found") writeError(w, http.StatusNotFound, "listing not found")
return return
} }
if deleted.OwnerChatID != nil && *deleted.OwnerChatID != "" {
_ = s.App.TG.SendMessage(r.Context(), *deleted.OwnerChatID, formatDeletedListingMessage(deleted))
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func formatDeletedListingMessage(deleted *DeletedListing) string {
listing := deleted.Listing
title := "без названия"
if listing.Title != nil && *listing.Title != "" {
title = *listing.Title
}
price := "—"
if listing.CurrentPrice != nil {
currency := "AED"
if listing.Currency != nil && *listing.Currency != "" {
currency = *listing.Currency
}
price = strconv.FormatFloat(*listing.CurrentPrice, 'f', 0, 64) + " " + currency
}
permit := "—"
if listing.PermitNumber != nil && *listing.PermitNumber != "" {
permit = *listing.PermitNumber
}
return "🏠 <b>" + deleted.ProjectTitle + "</b>\n" +
"Тип: " + deleted.ProjectDeal + " · Изменений: 1\n" +
"——————————\n" +
"🗑️ <b>Удален конкурент</b> — " + listing.Source + "\n" +
title + "\n" +
"Последняя цена: " + price + "\n" +
"Permit: <code>" + permit + "</code>\n" +
listing.URL
}
func (s Server) requireEmployee(w http.ResponseWriter, r *http.Request) (*Employee, bool) { func (s Server) requireEmployee(w http.ResponseWriter, r *http.Request) (*Employee, bool) {
emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), true) emp, err := s.App.CurrentEmployee(r.Context(), portalUserID(r), true)
if errors.Is(err, ErrTelegramRequired) { if errors.Is(err, ErrTelegramRequired) {
@@ -404,12 +536,118 @@ func (s Server) requireEmployee(w http.ResponseWriter, r *http.Request) (*Employ
return emp, true return emp, true
} }
func (s Server) resolveProjectOwner(w http.ResponseWriter, r *http.Request, requested *string) (*Employee, bool) {
targetPortalID := strings.TrimSpace(portalUserID(r))
if requested != nil && strings.TrimSpace(*requested) != "" {
targetPortalID = strings.TrimSpace(*requested)
}
if targetPortalID == "" {
writeError(w, http.StatusForbidden, "Сначала авторизуйтесь в Telegram-боте Monitoring PF")
return nil, false
}
if !canManagePortalUser(r, targetPortalID) {
writeError(w, http.StatusForbidden, "Нет прав на создание проекта для этого сотрудника")
return nil, false
}
owner, err := s.App.CurrentEmployee(r.Context(), targetPortalID, true)
if errors.Is(err, ErrTelegramRequired) {
writeError(w, http.StatusBadRequest, "У владельца проекта не подключен Telegram-бот Monitoring PF")
return nil, false
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return nil, false
}
return owner, true
}
func (s Server) resolveProjectOwnerForRead(w http.ResponseWriter, r *http.Request, requested *string) (*Employee, bool) {
if requested == nil || strings.TrimSpace(*requested) == "" {
return s.requireEmployee(w, r)
}
targetPortalID := strings.TrimSpace(*requested)
if !canManagePortalUser(r, targetPortalID) {
writeError(w, http.StatusForbidden, "Нет прав на просмотр объектов этого сотрудника")
return nil, false
}
owner, err := s.App.EmployeeByPortalUserID(r.Context(), targetPortalID)
if errors.Is(err, ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "employee not found")
return nil, false
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return nil, false
}
return owner, true
}
func (s Server) projectOwnerIDForAccess(w http.ResponseWriter, r *http.Request, projectID int64) (int64, bool) {
owner, err := s.App.ProjectOwner(r.Context(), projectID)
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "project not found")
return 0, false
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return 0, false
}
if owner.PortalUserID == nil || !canManagePortalUser(r, *owner.PortalUserID) {
writeError(w, http.StatusNotFound, "project not found")
return 0, false
}
return owner.ID, true
}
func (s Server) listingOwnerIDForAccess(w http.ResponseWriter, r *http.Request, listingID int64) (int64, bool) {
var projectID int64
err := s.App.DB.QueryRowContext(r.Context(), `
SELECT project_id FROM competitor_listings WHERE id = ?`, listingID).Scan(&projectID)
if err != nil {
writeError(w, http.StatusNotFound, "listing not found")
return 0, false
}
return s.projectOwnerIDForAccess(w, r, projectID)
}
func portalUserID(r *http.Request) string { func portalUserID(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-User-Id")) return strings.TrimSpace(r.Header.Get("X-User-Id"))
} }
func isAdmin(r *http.Request) bool { func isAdmin(r *http.Request) bool {
return r.Header.Get("X-User-Is-Admin") == "1" return commonmw.HeaderBool(r, "X-User-Is-Admin")
}
func canViewTeam(r *http.Request) bool {
return isAdmin(r) || commonmw.HeaderBool(r, "X-User-Is-Department-Head")
}
func canManagePortalUser(r *http.Request, targetPortalID string) bool {
targetPortalID = strings.TrimSpace(targetPortalID)
if targetPortalID == "" {
return false
}
if targetPortalID == portalUserID(r) || isAdmin(r) {
return true
}
for _, id := range subordinatePortalIDs(r) {
if id == targetPortalID {
return true
}
}
return false
}
func subordinatePortalIDs(r *http.Request) []string {
return commonmw.HeaderCSV(r, "X-User-Subordinates")
}
func ownerPortalIDFromQuery(r *http.Request) *string {
value := strings.TrimSpace(r.URL.Query().Get("owner_portal_user_id"))
if value == "" {
return nil
}
return &value
} }
func nullablePlain(value string) *string { func nullablePlain(value string) *string {

View File

@@ -5,11 +5,15 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"regexp"
"strings"
) )
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
var ErrTelegramRequired = errors.New("telegram required") var ErrTelegramRequired = errors.New("telegram required")
var propertyFinderListingURLRe = regexp.MustCompile(`(?i)propertyfinder\.ae/.+-(\d+)\.html(?:[?#].*)?$`)
func (a *App) CurrentEmployee(ctx context.Context, portalUserID string, required bool) (*Employee, error) { func (a *App) CurrentEmployee(ctx context.Context, portalUserID string, required bool) (*Employee, error) {
if portalUserID == "" { if portalUserID == "" {
if required { if required {
@@ -56,7 +60,28 @@ func (a *App) ListEmployees(ctx context.Context, isAdmin bool, current *Employee
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer closeRows(rows)
return scanEmployees(rows)
}
func (a *App) ListEmployeesByPortalUserIDs(ctx context.Context, portalUserIDs []string) ([]Employee, error) {
ids := uniqueNonEmpty(portalUserIDs)
if len(ids) == 0 {
return []Employee{}, nil
}
args := make([]any, 0, len(ids))
placeholders := make([]string, 0, len(ids))
for _, id := range ids {
args = append(args, id)
placeholders = append(placeholders, "?")
}
rows, err := a.DB.QueryContext(ctx, employeeSelect()+`
WHERE e.portal_user_id IN (`+strings.Join(placeholders, ",")+`)
ORDER BY e.name COLLATE NOCASE`, args...)
if err != nil {
return nil, err
}
defer closeRows(rows)
return scanEmployees(rows) return scanEmployees(rows)
} }
@@ -214,16 +239,34 @@ func scanEmployees(rows *sql.Rows) ([]Employee, error) {
return items, rows.Err() return items, rows.Err()
} }
func uniqueNonEmpty(values []string) []string {
seen := map[string]struct{}{}
out := []string{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
type ProjectPayload struct { type ProjectPayload struct {
Title string `json:"title"` Title string `json:"title"`
DealType string `json:"deal_type"` DealType string `json:"deal_type"`
OurPrice *float64 `json:"our_price"` OurPrice *float64 `json:"our_price"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
DLDPermit *string `json:"dld_permit"` DLDPermit *string `json:"dld_permit"`
Building *string `json:"building"` Building *string `json:"building"`
Bedrooms *int64 `json:"bedrooms"` Bedrooms *int64 `json:"bedrooms"`
SizeSqft *float64 `json:"size_sqft"` SizeSqft *float64 `json:"size_sqft"`
OurURL *string `json:"our_url"` OurURL *string `json:"our_url"`
OwnerPortalUserID *string `json:"owner_portal_user_id"`
} }
func (a *App) Summary(ctx context.Context, emp *Employee) (map[string]any, error) { func (a *App) Summary(ctx context.Context, emp *Employee) (map[string]any, error) {
@@ -267,7 +310,7 @@ func (a *App) ListProjects(ctx context.Context, ownerID int64) ([]Project, error
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer closeRows(rows)
items := []Project{} items := []Project{}
for rows.Next() { for rows.Next() {
p, err := a.scanProject(ctx, rows, false) p, err := a.scanProject(ctx, rows, false)
@@ -291,6 +334,17 @@ func (a *App) ProjectByID(ctx context.Context, ownerID, projectID int64, detail
return p, nil return p, nil
} }
func (a *App) ProjectOwner(ctx context.Context, projectID int64) (*Employee, error) {
row := a.DB.QueryRowContext(ctx, employeeSelect()+`
JOIN projects p ON p.owner_id = e.id
WHERE p.id = ?`, projectID)
emp, err := scanEmployee(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return emp, err
}
func (a *App) CreateProject(ctx context.Context, ownerID int64, p ProjectPayload) (*Project, error) { func (a *App) CreateProject(ctx context.Context, ownerID int64, p ProjectPayload) (*Project, error) {
title := cleanString(p.Title) title := cleanString(p.Title)
if title == "" { if title == "" {
@@ -300,6 +354,15 @@ func (a *App) CreateProject(ctx context.Context, ownerID int64, p ProjectPayload
if err != nil { if err != nil {
return nil, err return nil, err
} }
p.Title = title
p.DealType = deal
p, err = a.enrichProjectPayloadFromURL(ctx, p)
if err != nil {
return nil, err
}
if err := validateProjectRequired(p); err != nil {
return nil, err
}
res, err := a.DB.ExecContext(ctx, ` res, err := a.DB.ExecContext(ctx, `
INSERT INTO projects INSERT INTO projects
(title, deal_type, owner_id, our_price, notes, dld_permit, building, bedrooms, size_sqft, our_url, created_at) (title, deal_type, owner_id, our_price, notes, dld_permit, building, bedrooms, size_sqft, our_url, created_at)
@@ -333,6 +396,16 @@ func (a *App) UpdateProject(ctx context.Context, ownerID, projectID int64, p Pro
return nil, err return nil, err
} }
} }
p = mergeProjectPayload(current, p)
p.Title = title
p.DealType = deal
p, err = a.enrichProjectPayloadFromURL(ctx, p)
if err != nil {
return nil, err
}
if err := validateProjectRequired(p); err != nil {
return nil, err
}
_, err = a.DB.ExecContext(ctx, ` _, err = a.DB.ExecContext(ctx, `
UPDATE projects UPDATE projects
SET title = ?, deal_type = ?, our_price = ?, notes = ?, dld_permit = ?, SET title = ?, deal_type = ?, our_price = ?, notes = ?, dld_permit = ?,
@@ -347,12 +420,110 @@ func (a *App) UpdateProject(ctx context.Context, ownerID, projectID int64, p Pro
return a.ProjectByID(ctx, ownerID, projectID, true) return a.ProjectByID(ctx, ownerID, projectID, true)
} }
func mergeProjectPayload(current *Project, p ProjectPayload) ProjectPayload {
if current == nil {
return p
}
if p.OurPrice == nil {
p.OurPrice = current.OurPrice
}
if cleanPtr(p.DLDPermit) == nil {
p.DLDPermit = current.DLDPermit
}
if cleanPtr(p.Building) == nil {
p.Building = current.Building
}
if p.Bedrooms == nil {
p.Bedrooms = current.Bedrooms
}
if p.SizeSqft == nil {
p.SizeSqft = current.SizeSqft
}
if cleanPtr(p.OurURL) == nil {
p.OurURL = current.OurURL
}
return p
}
func (a *App) enrichProjectPayloadFromURL(ctx context.Context, p ProjectPayload) (ProjectPayload, error) {
url := cleanPtr(p.OurURL)
if url == nil || a.Worker == nil {
return p, nil
}
parsed, err := a.Worker.ParseOwnListing(ctx, *url)
if err != nil {
if projectMissingParsedFields(p) {
return p, fmt.Errorf("parse our_url: %w", err)
}
return p, nil
}
return applyParsedOwnListing(p, parsed), nil
}
func projectMissingParsedFields(p ProjectPayload) bool {
return p.OurPrice == nil ||
cleanPtr(p.DLDPermit) == nil ||
cleanPtr(p.Building) == nil ||
p.Bedrooms == nil ||
p.SizeSqft == nil
}
func applyParsedOwnListing(p ProjectPayload, parsed *ParsedOwnListing) ProjectPayload {
if parsed == nil {
return p
}
if parsed.OurPrice != nil && *parsed.OurPrice > 0 {
p.OurPrice = parsed.OurPrice
}
if permit := cleanPtr(parsed.DLDPermit); permit != nil {
p.DLDPermit = permit
}
if building := cleanPtr(parsed.Building); building != nil {
p.Building = building
}
if parsed.Bedrooms != nil {
p.Bedrooms = parsed.Bedrooms
}
if parsed.SizeSqft != nil && *parsed.SizeSqft > 0 {
p.SizeSqft = parsed.SizeSqft
}
return p
}
func validateProjectRequired(p ProjectPayload) error {
if cleanString(p.Title) == "" {
return fmt.Errorf("title is required")
}
if p.OurPrice == nil || *p.OurPrice <= 0 {
return fmt.Errorf("our_price is required")
}
if cleanPtr(p.DLDPermit) == nil {
return fmt.Errorf("dld_permit is required")
}
if cleanPtr(p.Building) == nil {
return fmt.Errorf("building is required")
}
if p.Bedrooms == nil {
return fmt.Errorf("bedrooms is required")
}
if p.SizeSqft == nil || *p.SizeSqft <= 0 {
return fmt.Errorf("size_sqft is required")
}
if cleanPtr(p.OurURL) == nil {
return fmt.Errorf("our_url is required")
}
if !propertyFinderListingURLRe.MatchString(strings.TrimSpace(*p.OurURL)) {
return fmt.Errorf("our_url must be a concrete PropertyFinder listing URL")
}
return nil
}
func (a *App) DeleteProject(ctx context.Context, ownerID, projectID int64) error { func (a *App) DeleteProject(ctx context.Context, ownerID, projectID int64) error {
tx, err := a.DB.BeginTx(ctx, nil) tx, err := a.DB.BeginTx(ctx, nil)
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback() defer rollbackTx(tx)
listingRows, err := tx.QueryContext(ctx, `SELECT id FROM competitor_listings WHERE project_id = ?`, projectID) listingRows, err := tx.QueryContext(ctx, `SELECT id FROM competitor_listings WHERE project_id = ?`, projectID)
if err != nil { if err != nil {
return err return err
@@ -361,12 +532,12 @@ func (a *App) DeleteProject(ctx context.Context, ownerID, projectID int64) error
for listingRows.Next() { for listingRows.Next() {
var id int64 var id int64
if err := listingRows.Scan(&id); err != nil { if err := listingRows.Scan(&id); err != nil {
listingRows.Close() closeRows(listingRows)
return err return err
} }
listingIDs = append(listingIDs, id) listingIDs = append(listingIDs, id)
} }
listingRows.Close() closeRows(listingRows)
for _, id := range listingIDs { for _, id := range listingIDs {
if _, err := tx.ExecContext(ctx, `DELETE FROM price_history WHERE listing_id = ?`, id); err != nil { if _, err := tx.ExecContext(ctx, `DELETE FROM price_history WHERE listing_id = ?`, id); err != nil {
return err return err
@@ -386,53 +557,34 @@ func (a *App) DeleteProject(ctx context.Context, ownerID, projectID int64) error
return tx.Commit() return tx.Commit()
} }
type DeletedListing struct { func (a *App) DeleteListing(ctx context.Context, ownerID, listingID int64) error {
Listing *Listing
ProjectTitle string
ProjectDeal string
OwnerChatID *string
}
func (a *App) DeleteListing(ctx context.Context, ownerID, listingID int64) (*DeletedListing, error) {
row := a.DB.QueryRowContext(ctx, listingSelect()+`
JOIN projects p ON p.id = l.project_id
WHERE l.id = ? AND p.owner_id = ?`, listingID, ownerID)
listing, err := scanListing(row, false)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
deleted := &DeletedListing{Listing: listing}
var deal string
var chat sql.NullString
if err := a.DB.QueryRowContext(ctx, `
SELECT p.title, p.deal_type, e.tg_chat_id
FROM projects p
JOIN employees e ON e.id = p.owner_id
WHERE p.id = ? AND p.owner_id = ?`, listing.ProjectID, ownerID).
Scan(&deleted.ProjectTitle, &deal, &chat); err != nil {
return nil, err
}
deleted.ProjectDeal = enumDealOut(deal)
deleted.OwnerChatID = nullableString(chat)
tx, err := a.DB.BeginTx(ctx, nil) tx, err := a.DB.BeginTx(ctx, nil)
if err != nil { if err != nil {
return nil, err return err
} }
defer tx.Rollback() defer rollbackTx(tx)
if _, err := tx.ExecContext(ctx, `DELETE FROM price_history WHERE listing_id = ?`, listing.ID); err != nil { if _, err := tx.ExecContext(ctx, `
return nil, err DELETE FROM price_history
WHERE listing_id IN (
SELECT l.id
FROM competitor_listings l
JOIN projects p ON p.id = l.project_id
WHERE l.id = ? AND p.owner_id = ?
)`, listingID, ownerID); err != nil {
return err
} }
if _, err := tx.ExecContext(ctx, `DELETE FROM competitor_listings WHERE id = ?`, listing.ID); err != nil { res, err := tx.ExecContext(ctx, `
return nil, err DELETE FROM competitor_listings
WHERE id = ?
AND project_id IN (SELECT id FROM projects WHERE owner_id = ?)`, listingID, ownerID)
if err != nil {
return err
} }
if err := tx.Commit(); err != nil { affected, _ := res.RowsAffected()
return nil, err if affected == 0 {
return ErrNotFound
} }
return deleted, nil return tx.Commit()
} }
func (a *App) ListingByID(ctx context.Context, id int64, withHistory bool) (*Listing, error) { func (a *App) ListingByID(ctx context.Context, id int64, withHistory bool) (*Listing, error) {
@@ -526,10 +678,10 @@ func (a *App) ListingsForProject(ctx context.Context, projectID int64, withHisto
items = append(items, *item) items = append(items, *item)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
rows.Close() closeRows(rows)
return nil, err return nil, err
} }
rows.Close() closeRows(rows)
if withHistory { if withHistory {
for i := range items { for i := range items {
history, err := a.PriceHistory(ctx, items[i].ID) history, err := a.PriceHistory(ctx, items[i].ID)
@@ -542,6 +694,87 @@ func (a *App) ListingsForProject(ctx context.Context, projectID int64, withHisto
return items, nil return items, nil
} }
func (a *App) TeamOverview(ctx context.Context, portalUserIDs []string, all bool) ([]TeamOverviewRow, error) {
args := []any{}
where := ""
if !all {
if len(portalUserIDs) == 0 {
return []TeamOverviewRow{}, nil
}
placeholders := make([]string, 0, len(portalUserIDs))
for _, id := range portalUserIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
where = "WHERE e.portal_user_id IN (" + strings.Join(placeholders, ",") + ")"
}
rows, err := a.DB.QueryContext(ctx, `
SELECT e.id,
e.name,
e.portal_user_id,
e.tg_chat_id,
p.id,
p.title,
p.deal_type,
p.dld_permit,
p.last_checked_at,
count(l.id),
sum(CASE WHEN l.status IN ('ACTIVE','active') THEN 1 ELSE 0 END),
sum(CASE WHEN l.status IN ('REMOVED','removed') THEN 1 ELSE 0 END),
min(CASE WHEN l.status IN ('ACTIVE','active') THEN l.current_price ELSE NULL END)
FROM employees e
LEFT JOIN projects p ON p.owner_id = e.id
LEFT JOIN competitor_listings l ON l.project_id = p.id
`+where+`
GROUP BY e.id, e.name, e.portal_user_id, e.tg_chat_id,
p.id, p.title, p.deal_type, p.dld_permit, p.last_checked_at, p.created_at
ORDER BY e.name COLLATE NOCASE, p.created_at DESC`, args...)
if err != nil {
return nil, err
}
defer closeRows(rows)
items := []TeamOverviewRow{}
for rows.Next() {
var item TeamOverviewRow
var portalID, chatID, title, deal, permit, checked sql.NullString
var projectID, total, active, removed sql.NullInt64
var minPrice sql.NullFloat64
if err := rows.Scan(
&item.EmployeeID,
&item.EmployeeName,
&portalID,
&chatID,
&projectID,
&title,
&deal,
&permit,
&checked,
&total,
&active,
&removed,
&minPrice,
); err != nil {
return nil, err
}
item.PortalUserID = nullableString(portalID)
item.TelegramLinked = chatID.Valid && chatID.String != ""
item.ProjectID = nullableInt(projectID)
item.ProjectTitle = nullableString(title)
if deal.Valid {
value := enumDealOut(deal.String)
item.DealType = &value
}
item.DLDPermit = nullableString(permit)
item.LastCheckedAt = timeOut(checked)
item.ListingsTotal = nullIntValue(total)
item.ListingsActive = nullIntValue(active)
item.ListingsRemoved = nullIntValue(removed)
item.MinCompetitorPrice = nullableFloat(minPrice)
items = append(items, item)
}
return items, rows.Err()
}
func (a *App) PriceHistory(ctx context.Context, listingID int64) ([]PricePoint, error) { func (a *App) PriceHistory(ctx context.Context, listingID int64) ([]PricePoint, error) {
rows, err := a.DB.QueryContext(ctx, ` rows, err := a.DB.QueryContext(ctx, `
SELECT id, price, recorded_at SELECT id, price, recorded_at
@@ -551,7 +784,7 @@ func (a *App) PriceHistory(ctx context.Context, listingID int64) ([]PricePoint,
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer closeRows(rows)
out := []PricePoint{} out := []PricePoint{}
for rows.Next() { for rows.Next() {
var p PricePoint var p PricePoint

105
internal/pf/store_test.go Normal file
View File

@@ -0,0 +1,105 @@
package pf
import (
"strings"
"testing"
)
func strPtr(v string) *string {
return &v
}
func int64Ptr(v int64) *int64 {
return &v
}
func float64Ptr(v float64) *float64 {
return &v
}
func validProjectPayload() ProjectPayload {
return ProjectPayload{
Title: "Full Park View",
DealType: "sale",
OurPrice: float64Ptr(2500000),
DLDPermit: strPtr("7140504127"),
Building: strPtr("Harbour Gate Tower 2"),
Bedrooms: int64Ptr(2),
SizeSqft: float64Ptr(1081),
OurURL: strPtr(
"https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour-the-lagoons-harbour-gate-harbour-gate-tower-2-86176216.html",
),
}
}
func TestValidateProjectRequiredAcceptsConcretePropertyFinderListingURL(t *testing.T) {
payload := validProjectPayload()
if err := validateProjectRequired(payload); err != nil {
t.Fatalf("validateProjectRequired() returned unexpected error: %v", err)
}
}
func TestValidateProjectRequiredRejectsSearchPageAsOurURL(t *testing.T) {
payload := validProjectPayload()
payload.OurURL = strPtr("https://www.propertyfinder.ae/en/search?c=1&l=12345")
err := validateProjectRequired(payload)
if err == nil {
t.Fatal("validateProjectRequired() accepted a search page as our_url")
}
if !strings.Contains(err.Error(), "concrete PropertyFinder listing URL") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateProjectRequiredRejectsListingLikeURLWithoutID(t *testing.T) {
payload := validProjectPayload()
payload.OurURL = strPtr("https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour.html")
err := validateProjectRequired(payload)
if err == nil {
t.Fatal("validateProjectRequired() accepted a listing-like URL without listing id")
}
if !strings.Contains(err.Error(), "concrete PropertyFinder listing URL") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestApplyParsedOwnListingFillsProjectMetadata(t *testing.T) {
payload := ProjectPayload{
Title: "Full Park View",
DealType: "sale",
OurURL: strPtr(
"https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour-the-lagoons-harbour-gate-harbour-gate-tower-2-86176216.html",
),
}
parsed := &ParsedOwnListing{
OurPrice: float64Ptr(3500000),
DLDPermit: strPtr("7140504127"),
Building: strPtr("Harbour Gate Tower 2"),
Bedrooms: int64Ptr(2),
SizeSqft: float64Ptr(1081),
}
payload = applyParsedOwnListing(payload, parsed)
if err := validateProjectRequired(payload); err != nil {
t.Fatalf("validateProjectRequired() after parsed metadata returned error: %v", err)
}
if payload.OurPrice == nil || *payload.OurPrice != 3500000 {
t.Fatalf("our_price was not applied: %#v", payload.OurPrice)
}
if payload.DLDPermit == nil || *payload.DLDPermit != "7140504127" {
t.Fatalf("dld_permit was not applied: %#v", payload.DLDPermit)
}
if payload.Building == nil || *payload.Building != "Harbour Gate Tower 2" {
t.Fatalf("building was not applied: %#v", payload.Building)
}
if payload.Bedrooms == nil || *payload.Bedrooms != 2 {
t.Fatalf("bedrooms was not applied: %#v", payload.Bedrooms)
}
if payload.SizeSqft == nil || *payload.SizeSqft != 1081 {
t.Fatalf("size_sqft was not applied: %#v", payload.SizeSqft)
}
}

View File

@@ -34,6 +34,10 @@ type CheckResult struct {
Changes int `json:"changes"` Changes int `json:"changes"`
} }
type HealthResult struct {
Status string `json:"status"`
}
type Suggestion struct { type Suggestion struct {
Source string `json:"source"` Source string `json:"source"`
ExternalID string `json:"external_id"` ExternalID string `json:"external_id"`
@@ -47,6 +51,16 @@ type Suggestion struct {
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
} }
type ParsedOwnListing struct {
Title *string `json:"title"`
OurPrice *float64 `json:"our_price"`
DLDPermit *string `json:"dld_permit"`
Building *string `json:"building"`
Bedrooms *int64 `json:"bedrooms"`
SizeSqft *float64 `json:"size_sqft"`
Currency *string `json:"currency"`
}
type SuggestionsResponse struct { type SuggestionsResponse struct {
OurPermit *string `json:"our_permit"` OurPermit *string `json:"our_permit"`
BayutEnabled bool `json:"bayut_enabled"` BayutEnabled bool `json:"bayut_enabled"`
@@ -104,6 +118,25 @@ func (w *Worker) Suggest(ctx context.Context, projectID int64) (*SuggestionsResp
return &out, nil return &out, nil
} }
func (w *Worker) ParseOwnListing(ctx context.Context, url string) (*ParsedOwnListing, error) {
var out ParsedOwnListing
if err := w.call(ctx, "parse-own-listing", map[string]any{"url": url}, &out); err != nil {
return nil, err
}
return &out, nil
}
func (w *Worker) Health(ctx context.Context) error {
var out HealthResult
if err := w.call(ctx, "health", map[string]any{}, &out); err != nil {
return err
}
if out.Status != "ok" {
return fmt.Errorf("worker status=%s", out.Status)
}
return nil
}
func (w *Worker) call(ctx context.Context, command string, payload any, out any) error { func (w *Worker) call(ctx context.Context, command string, payload any, out any) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute) ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel() defer cancel()

View File

@@ -5,5 +5,6 @@ metadata:
namespace: monitoring-pf namespace: monitoring-pf
type: Opaque type: Opaque
stringData: stringData:
INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
TG_BOT_TOKEN: "8942895371:AAGCWTr8g0FeqdM3QWmbV_3PxoSb5c_urf0" TG_BOT_TOKEN: "8942895371:AAGCWTr8g0FeqdM3QWmbV_3PxoSb5c_urf0"
ADMIN_CHAT_ID: "" ADMIN_CHAT_ID: ""

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db import Base
from app.models import CompetitorListing, DealType, Employee, ListingStatus, Project, Source
from app.scrapers.base import ScrapedListing
from app.scrapers.propertyfinder import PropertyFinderScraper
from app.services import monitor
PF_OWN_URL = (
"https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour-"
"the-lagoons-harbour-gate-harbour-gate-tower-2-86176216.html"
)
PF_COMPETITOR_URL = (
"https://www.propertyfinder.ae/en/plp/buy/apartment-for-sale-dubai-dubai-creek-harbour-"
"the-lagoons-harbour-gate-harbour-gate-tower-2-86170000.html"
)
def _listing(external_id: str, permit: str | None, url: str = PF_COMPETITOR_URL) -> ScrapedListing:
return ScrapedListing(
source="propertyfinder",
external_id=external_id,
url=url,
title=f"Listing {external_id}",
price=2_500_000,
currency="AED",
permit_number=permit,
agent_name="Agent",
agency_name="Agency",
is_active=True,
)
class MonitoringRulesTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.Session = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db = self.Session()
owner = Employee(name="Agent", portal_user_id="agent-1", tg_chat_id="100")
self.db.add(owner)
self.db.flush()
self.project = Project(
title="Full Park View",
deal_type=DealType.SALE,
our_price=2_500_000,
dld_permit="7140504127",
building="Harbour Gate Tower 2",
bedrooms=2,
size_sqft=1081,
our_url=PF_OWN_URL,
owner_id=owner.id,
)
self.db.add(self.project)
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def test_propertyfinder_rejects_search_pages(self) -> None:
scraper = PropertyFinderScraper()
self.assertFalse(scraper.is_listing_url("https://www.propertyfinder.ae/en/search?c=1&l=12345"))
self.assertIsNone(scraper.fetch_listing("https://www.propertyfinder.ae/en/search?c=1&l=12345"))
@patch.object(monitor.PF, "get_permit", side_effect=["7140504127"])
@patch.object(
monitor.PF,
"search_similar",
return_value=[
_listing("86176216", None, url=PF_OWN_URL),
_listing("86170000", None, url=PF_COMPETITOR_URL),
],
)
def test_suggest_similar_excludes_own_listing(self, _search, _permit) -> None:
suggestions = monitor.suggest_similar(self.project, our_permit="7140504127")
self.assertEqual(["86170000"], [item.external_id for item in suggestions["propertyfinder"]])
@patch.object(
monitor,
"suggest_similar",
return_value={
"propertyfinder": [
_listing("86170000", "7140504127"),
_listing("86170001", "DIFFERENT"),
],
"bayut": [],
},
)
def test_sync_permit_competitors_adds_only_exact_permit_matches(self, _suggest) -> None:
changes, suggestions, permit = monitor.sync_permit_competitors(self.db, self.project)
listings = self.db.query(CompetitorListing).order_by(CompetitorListing.external_id).all()
self.assertEqual("7140504127", permit)
self.assertEqual(1, len(listings))
self.assertEqual("86170000", listings[0].external_id)
self.assertTrue(listings[0].auto_discovered)
self.assertEqual(["86170001"], [item.external_id for item in suggestions["propertyfinder"]])
self.assertEqual(1, len(changes))
@patch.object(monitor, "suggest_similar", return_value={"propertyfinder": [], "bayut": []})
def test_auto_permit_listing_is_removed_only_after_three_misses(self, _suggest) -> None:
listing = CompetitorListing(
project_id=self.project.id,
source=Source.PROPERTYFINDER,
external_id="86170000",
url=PF_COMPETITOR_URL,
title="Competitor",
permit_number="7140504127",
auto_discovered=True,
permit_missing_checks=0,
current_price=2_500_000,
currency="AED",
status=ListingStatus.ACTIVE,
)
self.db.add(listing)
self.db.commit()
changes, _, _ = monitor.sync_permit_competitors(self.db, self.project)
self.db.flush()
self.assertEqual([], changes)
self.assertEqual(1, self.db.query(CompetitorListing).count())
self.assertEqual(1, listing.permit_missing_checks)
changes, _, _ = monitor.sync_permit_competitors(self.db, self.project)
self.db.flush()
self.assertEqual([], changes)
self.assertEqual(1, self.db.query(CompetitorListing).count())
self.assertEqual(2, listing.permit_missing_checks)
changes, _, _ = monitor.sync_permit_competitors(self.db, self.project)
self.db.flush()
self.assertEqual(1, len(changes))
self.assertEqual(0, self.db.query(CompetitorListing).count())
if __name__ == "__main__":
unittest.main()