Fix require_cap TypeError, rewrite /analysis/comparison, add room category sync

- require_cap was a Depends-factory but every call site uses it inline;
  make it an inline checker (fixes 500 on /analysis/hotels, /direct/*)
- /analysis/comparison returned a per-date matrix the frontend never read;
  return per-hotel aggregates (our/their avg, price index) and default to
  all active competitors so the Market Comparison table works without params
- Room categories were never populated (lost in port): add sites_list fetch
  to the Newbook client, categories list/sync/toggle endpoints, and a
  Settings card — without included categories every rates sync exits early

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-05 13:47:10 +00:00
parent c9f2dc804c
commit 69c9b16a2b
6 changed files with 5787 additions and 52 deletions

View file

@ -200,72 +200,81 @@ async def rate_timeline(
@router.get("/comparison")
async def rate_comparison(
competitor_ids: str = Query(..., description="Comma-separated hotel IDs to compare"),
from_date: date = Query(default_factory=lambda: date.today()),
to_date: date = Query(default_factory=lambda: date.today() + timedelta(days=29)),
competitor_ids: Optional[str] = Query(None, description="Comma-separated hotel IDs; defaults to all active competitors"),
user=Depends(get_current_user)
):
"""Per-hotel market comparison: avg own vs competitor rate over the range."""
require_cap(user, "rate_analysis")
try:
comp_ids = [int(x.strip()) for x in competitor_ids.split(",") if x.strip()]
except ValueError:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="competitor_ids must be comma-separated integers")
comp_ids = None
if competitor_ids:
try:
comp_ids = [int(x.strip()) for x in competitor_ids.split(",") if x.strip()]
except ValueError:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="competitor_ids must be comma-separated integers")
async with AsyncSessionLocal() as db:
# Own hotel latest rates
# Own hotel avg rate per date (across included categories)
own_result = await db.execute(
text("""
SELECT
ncr.rate_date,
ncr.gross_rate AS own_rate
FROM newbook_current_rates ncr
WHERE ncr.rate_date BETWEEN :from_date AND :to_date
ORDER BY ncr.rate_date
SELECT rate_date, AVG(gross_rate) AS own_rate
FROM newbook_current_rates
WHERE rate_date BETWEEN :from_date AND :to_date
GROUP BY rate_date
"""),
{"from_date": from_date, "to_date": to_date}
)
own_rates = {str(r.rate_date): float(r.own_rate) for r in own_result if r.own_rate}
own_rates = {r.rate_date: float(r.own_rate) for r in own_result if r.own_rate}
# Competitor latest rates per date
# Competitor latest rate per date
hotel_filter = "h.id = ANY(:comp_ids)" if comp_ids else "h.tier = 'competitor'"
comp_result = await db.execute(
text("""
SELECT
r.hotel_id,
h.name AS hotel_name,
r.rate_date,
r.rate_gross,
r.availability_status
text(f"""
SELECT r.hotel_id, h.name AS hotel_name, r.rate_date, r.rate_gross
FROM (
SELECT DISTINCT ON (hotel_id, rate_date)
hotel_id, rate_date, rate_gross, availability_status
hotel_id, rate_date, rate_gross
FROM booking_com_rates
WHERE hotel_id = ANY(:comp_ids)
AND rate_date BETWEEN :from_date AND :to_date
WHERE rate_date BETWEEN :from_date AND :to_date
ORDER BY hotel_id, rate_date, scraped_at DESC
) r
JOIN booking_com_hotels h ON h.id = r.hotel_id
ORDER BY r.rate_date, h.display_order
WHERE h.is_active = true AND {hotel_filter}
ORDER BY h.display_order, h.name
"""),
{"comp_ids": comp_ids, "from_date": from_date, "to_date": to_date}
{"from_date": from_date, "to_date": to_date,
**({"comp_ids": comp_ids} if comp_ids else {})}
)
comp_rows = comp_result.mappings().all()
# Build per-date rows
date_map: dict = {}
hotel_names: dict = {}
for row in comp_rows:
d = str(row["rate_date"])
if d not in date_map:
date_map[d] = {"date": d, "own_rate": own_rates.get(d)}
date_map[d][f"h{row['hotel_id']}"] = float(row["rate_gross"]) if row["rate_gross"] else None
date_map[d][f"h{row['hotel_id']}_status"] = row["availability_status"]
hotel_names[row["hotel_id"]] = row["hotel_name"]
# Aggregate per hotel, comparing own rates over the same dates
by_hotel: dict = {}
for row in comp_result.mappings().all():
entry = by_hotel.setdefault(row["hotel_id"], {
"hotel_id": row["hotel_id"],
"hotel_name": row["hotel_name"],
"their": [], "ours": [],
})
if row["rate_gross"]:
entry["their"].append(float(row["rate_gross"]))
if row["rate_date"] in own_rates:
entry["ours"].append(own_rates[row["rate_date"]])
return {
"hotel_names": hotel_names,
"rows": sorted(date_map.values(), key=lambda x: x["date"]),
}
rows = []
for entry in by_hotel.values():
their_rate = round(sum(entry["their"]) / len(entry["their"]), 2) if entry["their"] else None
our_rate = round(sum(entry["ours"]) / len(entry["ours"]), 2) if entry["ours"] else None
price_index = round(their_rate / our_rate * 100, 1) if their_rate and our_rate else None
rows.append({
"hotel_id": entry["hotel_id"],
"hotel_name": entry["hotel_name"],
"our_rate": our_rate,
"their_rate": their_rate,
"price_index": price_index,
"days_checked": len(entry["their"]),
})
return rows
# ─── Strategy computation helper ─────────────────────────────────────────────

View file

@ -62,6 +62,95 @@ class RateMatrixResponse(BaseModel):
date_last_updated: Dict[str, Optional[str]] = {}
# ============================================
# ROOM CATEGORIES
# ============================================
class RoomCategory(BaseModel):
id: int
site_id: str
site_name: str
room_count: int
is_included: bool
display_order: int
class CategoryUpdate(BaseModel):
is_included: bool
@router.get("/categories", response_model=List[RoomCategory])
async def list_categories(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""List Newbook room categories and their include-in-sync status."""
result = await db.execute(
text("""
SELECT id, site_id, site_name, room_count, is_included, display_order
FROM newbook_room_categories
ORDER BY display_order, site_name
""")
)
return [dict(r) for r in result.mappings().all()]
@router.post("/categories/sync")
async def sync_categories(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Fetch room categories from the Newbook API (sites_list) and upsert them.
New categories default to included; existing include flags are preserved.
"""
from services.newbook_rates_client import NewbookRatesClient
client = await NewbookRatesClient.from_db(db)
try:
async with client:
categories = await client.get_room_categories()
except Exception as e:
logger.error(f"Category sync failed: {e}")
raise HTTPException(status_code=502, detail=f"Newbook category fetch failed: {str(e)}")
if not categories:
raise HTTPException(status_code=502, detail="Newbook returned no room categories — check credentials in Settings")
for cat in categories:
await db.execute(
text("""
INSERT INTO newbook_room_categories (site_id, site_name, room_count, fetched_at)
VALUES (:site_id, :site_name, :room_count, NOW())
ON CONFLICT (site_id) DO UPDATE SET
site_name = EXCLUDED.site_name,
room_count = EXCLUDED.room_count,
fetched_at = NOW()
"""),
{"site_id": cat["category_id"], "site_name": cat["category_name"], "room_count": cat["room_count"]}
)
await db.commit()
return {"status": "success", "count": len(categories)}
@router.patch("/categories/{category_id}")
async def update_category(
category_id: int,
body: CategoryUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Toggle whether a category is included in rate syncs."""
result = await db.execute(
text("UPDATE newbook_room_categories SET is_included = :inc WHERE id = :id RETURNING id"),
{"inc": body.is_included, "id": category_id}
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Category not found")
await db.commit()
return {"status": "success"}
# ============================================
# RATE MATRIX ENDPOINT
# ============================================

View file

@ -52,15 +52,12 @@ def has_cap(user: dict, cap: str) -> bool:
return user.get("is_admin", False) or cap in user.get("caps", [])
def require_cap(cap: str):
async def checker(user: dict = Depends(get_current_user)):
if not has_cap(user, cap):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing capability: {cap}",
)
return user
return checker
def require_cap(user: dict, cap: str) -> None:
if not has_cap(user, cap):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing capability: {cap}",
)
async def get_admin_user(user: dict = Depends(get_current_user)) -> dict:

View file

@ -93,6 +93,39 @@ class NewbookRatesClient:
"region": self.region
}
async def get_room_categories(self) -> List[Dict]:
"""
Fetch room categories by grouping the sites_list endpoint.
Each site includes site_category_id and site_category_name; categories
are derived by grouping sites and counting rooms per category.
Returns:
List of dicts with {category_id, category_name, room_count}
"""
payload = self._get_auth_payload()
response = await self.client.post(
self._get_url("sites_list"),
json=payload,
auth=(self.username, self.password)
)
response.raise_for_status()
sites = response.json().get("data") or []
categories: Dict[str, Dict] = {}
for site in sites:
cat_id = str(site.get("site_category_id") or "")
if not cat_id:
continue
entry = categories.setdefault(cat_id, {
"category_id": cat_id,
"category_name": site.get("site_category_name") or f"Category {cat_id}",
"room_count": 0,
})
entry["room_count"] += 1
return sorted(categories.values(), key=lambda c: c["category_name"])
async def get_category_rates(
self,
category_id: str,