# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # accounts.py : fonctionnalités de compte au-delà de l'authentification # · Rôle : « locataire » (cherche un logement) ou « gestionnaire » (gère des # logements affichés) — choisi à l'accueil après la création du compte. # · Locataire : favoris (POST/DELETE /api/favorites/{uid}, GET /api/favorites). # · Gestionnaire : réclame la page de SA source (première arrivée, une source # par compte, un compte par source) puis la personnalise (accroche, # description, coordonnées, logo) — affichée publiquement sur /g/{source}. # ----------------------------------------------------------------------------- from __future__ import annotations import json import time from pathlib import Path from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from . import db, hubfav, kaid from .auth import current_user SOURCES_PATH = Path(__file__).resolve().parent.parent / "data" / "sources.json" router = APIRouter(prefix="/api") ROLES = ("locataire", "gestionnaire") def _require(request: Request) -> dict: user = current_user(request) if user is None: raise HTTPException(401, "non connecté") return user def _row_listing(row) -> dict: d = dict(row) d["amenities"] = json.loads(d.get("amenities") or "[]") d["images"] = json.loads(d.get("images") or "[]") d["details"] = json.loads(d.get("details") or "{}") if d.get("furnished") is not None: d["furnished"] = bool(d["furnished"]) return d # -- rôle ----------------------------------------------------------------------- @router.post("/me/role") def set_role(request: Request, role: str): user = _require(request) if role not in ROLES: raise HTTPException(400, f"rôle invalide (choix : {', '.join(ROLES)})") con = db.connect() con.execute("UPDATE users SET role=? WHERE id=?", (role, user["uid"])) con.commit() con.close() return {"role": role} # -- favoris (locataire) --------------------------------------------------------- @router.get("/favorites") def favorites(request: Request): user = _require(request) con = db.connect() rows = con.execute( """SELECT l.* FROM favorites f JOIN listings l ON l.uid = f.uid WHERE f.user_id=? ORDER BY f.ts DESC""", (user["uid"],)).fetchall() uids = [r["uid"] for r in con.execute( "SELECT uid FROM favorites WHERE user_id=?", (user["uid"],))] con.close() return {"uids": uids, "listings": [_row_listing(r) for r in rows]} def _ka_id_of(con, user_id: int) -> str | None: row = con.execute("SELECT ka_id FROM users WHERE id=?", (user_id,)).fetchone() return row["ka_id"] if row else None @router.post("/favorites/{uid}") def add_favorite(request: Request, uid: str): user = _require(request) con = db.connect() listing = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() if listing is None: con.close() raise HTTPException(404, "annonce introuvable") con.execute("INSERT OR IGNORE INTO favorites (user_id, uid, ts) VALUES (?,?,?)", (user["uid"], uid, time.time())) con.commit() ka_id = _ka_id_of(con, user["uid"]) con.close() # miroir « Mon univers Ka » sur le hub (arrière-plan, jamais bloquant) hubfav.push_favorite(ka_id, "add", hubfav.listing_item(listing)) d = dict(listing) kaid.track({"ka_id": ka_id}, "favorite", entity_type="listing", entity_id=uid, features={k: v for k, v in { "city": d.get("city"), "sector": d.get("sector"), "unit_type": d.get("unit_type"), "price": d.get("price"), "pets": d.get("pets")}.items() if v not in (None, "")}) return {"ok": True} @router.delete("/favorites/{uid}") def remove_favorite(request: Request, uid: str): user = _require(request) con = db.connect() con.execute("DELETE FROM favorites WHERE user_id=? AND uid=?", (user["uid"], uid)) con.commit() ka_id = _ka_id_of(con, user["uid"]) con.close() hubfav.push_favorite(ka_id, "remove", {"item_id": uid}) kaid.track({"ka_id": ka_id}, "unfavorite", entity_type="listing", entity_id=uid) return {"ok": True} # -- page gestionnaire ----------------------------------------------------------- def _source_name(source_id: str) -> str | None: try: registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] except Exception: return None for s in registry: if s["id"] == source_id: return s.get("name") or source_id return None class OrgUpdate(BaseModel): tagline: str = "" description: str = "" website: str = "" phone: str = "" email: str = "" logo_url: str = "" @router.post("/org/claim") def claim_org(request: Request, source: str): """Réclame la page d'une source (gestionnaire) — 1 source/compte, 1 compte/source.""" user = _require(request) name = _source_name(source) if name is None: raise HTTPException(404, "source inconnue") con = db.connect() me_row = con.execute("SELECT role, org_source FROM users WHERE id=?", (user["uid"],)).fetchone() if me_row["role"] != "gestionnaire": con.close() raise HTTPException(403, "réservé aux comptes gestionnaires") if me_row["org_source"] and me_row["org_source"] != source: con.close() raise HTTPException(409, "votre compte gère déjà une autre page") owner = con.execute( "SELECT owner_user_id FROM source_profiles WHERE source_id=?", (source,)).fetchone() if owner and owner["owner_user_id"] not in (None, user["uid"]): con.close() raise HTTPException(409, "cette page est déjà réclamée par un autre compte") con.execute( """INSERT INTO source_profiles (source_id, owner_user_id, updated_at) VALUES (?,?,?) ON CONFLICT(source_id) DO UPDATE SET owner_user_id=excluded.owner_user_id, updated_at=excluded.updated_at""", (source, user["uid"], time.time())) con.execute("UPDATE users SET org_source=? WHERE id=?", (source, user["uid"])) con.commit() con.close() return {"source": source, "name": name} @router.get("/org/mine") def my_org(request: Request): user = _require(request) con = db.connect() me_row = con.execute("SELECT org_source FROM users WHERE id=?", (user["uid"],)).fetchone() source = me_row["org_source"] if me_row else None if not source: con.close() return {"source": None} prof = con.execute("SELECT * FROM source_profiles WHERE source_id=?", (source,)).fetchone() n = con.execute( "SELECT COUNT(*) c FROM listings WHERE source=? AND active=1", (source,)).fetchone()["c"] con.close() return { "source": source, "name": _source_name(source) or source, "active_listings": n, "profile": dict(prof) if prof else {}, } @router.put("/org") def update_org(request: Request, body: OrgUpdate): user = _require(request) con = db.connect() me_row = con.execute("SELECT org_source FROM users WHERE id=?", (user["uid"],)).fetchone() source = me_row["org_source"] if me_row else None if not source: con.close() raise HTTPException(409, "aucune page réclamée") owner = con.execute( "SELECT owner_user_id FROM source_profiles WHERE source_id=?", (source,)).fetchone() if not owner or owner["owner_user_id"] != user["uid"]: con.close() raise HTTPException(403, "vous n'êtes pas propriétaire de cette page") con.execute( """UPDATE source_profiles SET tagline=?, description=?, website=?, phone=?, email=?, logo_url=?, updated_at=? WHERE source_id=?""", (body.tagline.strip()[:140], body.description.strip()[:2000], body.website.strip()[:300], body.phone.strip()[:40], body.email.strip()[:120], body.logo_url.strip()[:500], time.time(), source)) con.commit() con.close() return {"ok": True} @router.get("/org/{source_id}") def public_org(source_id: str): """Page publique d'un gestionnaire : nom + personnalisation + inventaire.""" name = _source_name(source_id) if name is None: raise HTTPException(404, "source inconnue") con = db.connect() prof = con.execute("SELECT * FROM source_profiles WHERE source_id=?", (source_id,)).fetchone() n = con.execute( "SELECT COUNT(*) c FROM listings WHERE source=? AND active=1", (source_id,)).fetchone()["c"] con.close() out = {"source": source_id, "name": name, "active_listings": n, "claimed": bool(prof and prof["owner_user_id"])} if prof: for f in ("tagline", "description", "website", "phone", "email", "logo_url"): out[f] = prof[f] or "" return out