Spaces:
Sleeping
Sleeping
| """Conversion modèles -> schémas de sortie (avec URL de photo + attributs JSON). | |
| On construit les schémas explicitement plutôt que via model_validate() sur | |
| l'ORM : la relation `photos` porte ses `attributes` sous forme de chaîne JSON, | |
| qu'il faut parser nous-mêmes (sinon Pydantic rejette une str là où il attend | |
| un dict). | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from . import models, schemas, storage | |
| # La description est saisie librement par le propriétaire et MONTRÉE au trouveur. | |
| # La promesse de confidentialité = le trouveur ne voit jamais le contact du | |
| # propriétaire. On masque donc tout courriel / numéro de téléphone qui se serait | |
| # glissé dans le texte libre (ex. « contactez-moi au 514-555-1234 »). | |
| _EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b") | |
| _PHONE_RE = re.compile(r"(?<!\d)(?:\+?\d[\s().\-]?){7,}\d(?!\d)") | |
| # Audit #14 : la description est MONTRÉE au trouveur — on masque aussi les | |
| # vecteurs de contact / localisation glissés en texte libre. | |
| _URL_RE = re.compile(r"\b(?:https?://|www\.)\S+", re.I) | |
| _HANDLE_RE = re.compile(r"(?<!\w)@[A-Za-z0-9_.]{2,}") | |
| # Adresse FR (« 123 rue Saint-Denis ») ET EN (« 12 Oak Street ») — on englobe | |
| # le NOM de voie qui suit, pas seulement le type, jusqu'à la ponctuation. | |
| _ADDR_FR_RE = re.compile( | |
| r"\b\d{1,5}\s+(?:rue|av|avenue|boul|boulevard|ch|chemin|rang|place|impasse)\b[^,.;\n]*", | |
| re.I, | |
| ) | |
| _ADDR_EN_RE = re.compile( | |
| r"\b\d{1,5}\s+[A-Za-zÀ-ÿ'’\-]+(?:\s+[A-Za-zÀ-ÿ'’\-]+)*\s+" | |
| r"(?:street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|way|court|ct|place|pl)\b\.?", | |
| re.I, | |
| ) | |
| # Coordonnées GPS décimales (« 45.531, -73.581 ») et code postal canadien (« H2X 1Y4 »). | |
| _GPS_RE = re.compile(r"[-+]?\d{1,3}\.\d{3,}\s*[,;]\s*[-+]?\d{1,3}\.\d{3,}") | |
| _POSTAL_CA_RE = re.compile(r"\b[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d\b") | |
| def redact_contact(text: str | None) -> str | None: | |
| """Masque courriels, téléphones, URLs, @identifiants, adresses civiques, | |
| coordonnées GPS et codes postaux d'un texte libre rendu PUBLIC (description | |
| montrée au trouveur). Le masquage par regex reste best-effort : l'avis invite | |
| aussi explicitement à ne pas écrire de coordonnées dans la description.""" | |
| if not text: | |
| return text | |
| t = _EMAIL_RE.sub("[masqué]", text) | |
| t = _URL_RE.sub("[masqué]", t) | |
| t = _GPS_RE.sub("[masqué]", t) | |
| t = _POSTAL_CA_RE.sub("[masqué]", t) | |
| t = _ADDR_FR_RE.sub("[masqué]", t) | |
| t = _ADDR_EN_RE.sub("[masqué]", t) | |
| t = _PHONE_RE.sub("[masqué]", t) | |
| t = _HANDLE_RE.sub("[masqué]", t) | |
| return t | |
| def clip(s: str | None, n: int) -> str | None: | |
| """Tronque une saisie libre à la longueur de sa colonne. Postgres applique | |
| les limites VARCHAR(n) (contrairement à SQLite) : une saisie trop longue | |
| lèverait sinon une 500 StringDataRightTruncation en prod. Helper partagé par | |
| tous les routers qui écrivent du texte libre (cats, sightings, training).""" | |
| if s is None: | |
| return None | |
| s = s.strip() | |
| return s[:n] if s else None | |
| def photo_summary(p: "models.Photo | None") -> str | None: | |
| """Courte description du pelage stockée dans les attributs de la photo | |
| (ex. « chat écaille de tortue ») — affichée quand l'IA a détecté le chat.""" | |
| if not p or not p.attributes: | |
| return None | |
| try: | |
| return json.loads(p.attributes).get("summary") | |
| except (json.JSONDecodeError, AttributeError): | |
| return None | |
| def photo_out(p: models.Photo) -> schemas.PhotoOut: | |
| attrs = None | |
| if p.attributes: | |
| try: | |
| attrs = json.loads(p.attributes) | |
| except json.JSONDecodeError: | |
| attrs = None | |
| return schemas.PhotoOut( | |
| id=p.id, filename=p.filename, url=storage.photo_url(p), attributes=attrs | |
| ) | |
| def photo_out_public(p: models.Photo) -> schemas.PhotoOut: | |
| """Vue photo destinée au TROUVEUR. Audit MIT #10 : on ne renvoie PAS le dict | |
| `attributes` complet (il contient la boîte de détection, la méthode et les | |
| couleurs internes des photos du propriétaire). On ne garde qu'un résumé | |
| lisible du pelage (« chat écaille de tortue »), utile pour reconnaître.""" | |
| summary = photo_summary(p) | |
| return schemas.PhotoOut( | |
| id=p.id, filename=p.filename, url=storage.photo_url(p), | |
| attributes={"summary": summary} if summary else None, | |
| ) | |
| def cat_out(cat: models.Cat) -> schemas.CatOut: | |
| return schemas.CatOut( | |
| id=cat.id, | |
| name=cat.name, | |
| description=cat.description, | |
| color=cat.color, | |
| pattern=cat.pattern, | |
| breed=cat.breed, | |
| contact_name=cat.contact_name, | |
| contact_info=cat.contact_info, | |
| last_seen_lat=cat.last_seen_lat, | |
| last_seen_lng=cat.last_seen_lng, | |
| last_seen_at=cat.last_seen_at, | |
| status=cat.status, | |
| created_at=cat.created_at, | |
| photos=[photo_out(p) for p in cat.photos], | |
| ) | |
| def sighting_out(s: models.Sighting) -> schemas.SightingOut: | |
| return schemas.SightingOut( | |
| id=s.id, | |
| description=s.description, | |
| lat=s.lat, | |
| lng=s.lng, | |
| spotted_at=s.spotted_at, | |
| reporter_contact=s.reporter_contact, | |
| status=s.status, | |
| created_at=s.created_at, | |
| photos=[photo_out(p) for p in s.photos], | |
| ) | |
| def _candidate_token(sighting_id: int, cat_id: int) -> str: | |
| """Token HMAC signé liant un candidat à un signalement précis. | |
| C4 — Remplace le cat_id brut dans la réponse publique pour empêcher | |
| l'énumération des chats perdus par GPS bombing.""" | |
| import hashlib | |
| import hmac | |
| from . import config | |
| key = (config.PHOTO_SIGN_KEY or "dev").encode() | |
| return hmac.new(key, f"candidate:{sighting_id}:{cat_id}".encode(), | |
| hashlib.sha256).hexdigest()[:24] | |
| def candidate_out(cat: models.Cat, confidence: float, | |
| sighting_id: int | None = None) -> schemas.CandidateOut: | |
| """Chat candidat montré au trouveur : photos + nom/desc + confiance, SANS | |
| contact ni position du propriétaire. | |
| `sighting_id` est requis pour générer le token signé ; si absent (tests | |
| legacy), on utilise l'id du chat (moins sécurisé mais fonctionnel).""" | |
| token = _candidate_token(sighting_id or 0, cat.id) | |
| return schemas.CandidateOut( | |
| candidate_token=token, | |
| name=redact_contact(cat.name), | |
| description=redact_contact(cat.description), | |
| # Audit MIT #11 — color/pattern sont aussi du texte libre montré au | |
| # trouveur : on y applique le même caviardage (un propriétaire peut | |
| # glisser « noir, me joindre au 514… » dans le champ couleur). | |
| color=redact_contact(cat.color), | |
| pattern=redact_contact(cat.pattern), | |
| confidence=confidence, | |
| # On ne montre que quelques photos (assez pour reconnaître) — pas tout | |
| # l'enrôlement : moins d'exposition, payload plus léger. Vue PUBLIQUE : | |
| # sans les attributs de détection internes (audit MIT #10). | |
| photos=[photo_out_public(p) for p in cat.photos[:3]], | |
| is_poster=getattr(cat, "source", "owner") == "poster", | |
| ) | |
| def lead_out(le: models.ContactLead) -> schemas.LeadOut: | |
| """Signalement tel que vu par le PROPRIÉTAIRE (photo + lieu du trouveur).""" | |
| s = le.sighting | |
| photo_url = None | |
| if s and s.photos: | |
| photo_url = storage.photo_url(s.photos[0]) | |
| return schemas.LeadOut( | |
| id=le.id, | |
| finder_contact=le.finder_contact, | |
| message=le.message, | |
| confidence=le.confidence, | |
| created_at=le.created_at, | |
| sighting_lat=s.lat if s else None, | |
| sighting_lng=s.lng if s else None, | |
| sighting_photo_url=photo_url, | |
| ) | |