Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| Feed-personalization API: a user's saved locations, lenses, and signals. | |
| Backs the /feed-setup screen. Reads/writes three operational ORM tables | |
| (user_locations / user_lens_prefs / user_signal_prefs — see api/models.py), | |
| keyed to the authenticated user. NO transformation/SQL logic lives here beyond | |
| straight ORM CRUD; these are operational user-preference tables, not dbt marts. | |
| Endpoints: | |
| - GET /api/feed/config (auth) -> assembled config for the current user | |
| - PUT /api/feed/config (auth) -> full-replace the config in one txn | |
| - GET /api/feed/places (no auth) -> place typeahead (Nominatim proxy reuse) | |
| Lens/signal slugs are validated against server-side allow-sets so junk can't be | |
| stored. Place results carry only real geocoder hits — no fabricated/placeholder | |
| data (CLAUDE.md). | |
| """ | |
| from __future__ import annotations | |
| from typing import List, Optional | |
| from fastapi import APIRouter, Depends, HTTPException, Query, status | |
| from loguru import logger | |
| from opentelemetry import trace | |
| from pydantic import BaseModel, field_validator | |
| from sqlalchemy.orm import Session | |
| from api.auth import get_current_user | |
| from api.database import get_db | |
| from api.models import User, UserLocation, UserLensPref, UserSignalPref | |
| from api.routes.geocode import _throttled_get | |
| router = APIRouter(prefix="/feed", tags=["feed"]) | |
| tracer = trace.get_tracer(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Server-side allow-sets. Labels/copy live in the frontend; only slugs here. | |
| # --------------------------------------------------------------------------- | |
| ALLOWED_LENSES = frozenset({ | |
| "family-first", | |
| "faith-community", | |
| "charitable-impact", | |
| "neighborhood-life", | |
| "education", | |
| "local-economy", | |
| }) | |
| ALLOWED_SIGNALS = frozenset({ | |
| "contested", | |
| "money-moves", | |
| "raised-eyebrows", | |
| "moving-fast", | |
| "slipped-through", | |
| "helping-hands", | |
| "watch-next", | |
| }) | |
| ALLOWED_SHARED_LEVELS = frozenset({"street", "district", "city", "county", "state"}) | |
| # --------------------------------------------------------------------------- | |
| # Pydantic models (inline, mirroring lenses.py convention). | |
| # --------------------------------------------------------------------------- | |
| class LocationIn(BaseModel): | |
| """A location as submitted on save (resolved geo all optional).""" | |
| name: str | |
| shared_level: str = "city" | |
| is_primary: bool = False | |
| state_code: Optional[str] = None | |
| state: Optional[str] = None | |
| county: Optional[str] = None | |
| place_fips: Optional[str] = None | |
| county_fips: Optional[str] = None | |
| latitude: Optional[float] = None | |
| longitude: Optional[float] = None | |
| jurisdiction_id: Optional[str] = None | |
| def _check_shared_level(cls, v: str) -> str: | |
| if v not in ALLOWED_SHARED_LEVELS: | |
| raise ValueError( | |
| f"shared_level must be one of {sorted(ALLOWED_SHARED_LEVELS)} (got '{v}')" | |
| ) | |
| return v | |
| class LocationOut(BaseModel): | |
| """A saved location as returned to the client.""" | |
| id: int | |
| name: str | |
| shared_level: str | |
| is_primary: bool | |
| state_code: Optional[str] = None | |
| state: Optional[str] = None | |
| county: Optional[str] = None | |
| latitude: Optional[float] = None | |
| longitude: Optional[float] = None | |
| jurisdiction_id: Optional[str] = None | |
| class FeedConfigIn(BaseModel): | |
| """Full feed config submitted on save (full replace).""" | |
| locations: List[LocationIn] = [] | |
| lenses: List[str] = [] | |
| signals: List[str] = [] | |
| def _check_lenses(cls, v: List[str]) -> List[str]: | |
| unknown = [s for s in v if s not in ALLOWED_LENSES] | |
| if unknown: | |
| raise ValueError(f"unknown lens slug(s): {unknown}") | |
| # De-dupe while preserving order. | |
| return list(dict.fromkeys(v)) | |
| def _check_signals(cls, v: List[str]) -> List[str]: | |
| unknown = [s for s in v if s not in ALLOWED_SIGNALS] | |
| if unknown: | |
| raise ValueError(f"unknown signal slug(s): {unknown}") | |
| return list(dict.fromkeys(v)) | |
| class FeedConfigOut(BaseModel): | |
| """Assembled feed config returned by GET/PUT.""" | |
| locations: List[LocationOut] | |
| lenses: List[str] | |
| signals: List[str] | |
| profile_completed: bool | |
| class PlaceHit(BaseModel): | |
| """One real geocoder hit for the place typeahead.""" | |
| name: str | |
| city: Optional[str] = None | |
| county: Optional[str] = None | |
| state: Optional[str] = None | |
| state_code: Optional[str] = None | |
| latitude: float | |
| longitude: float | |
| class PlacesResponse(BaseModel): | |
| results: List[PlaceHit] | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _require_user(user: Optional[User]) -> User: | |
| """get_current_user returns Optional[User]; enforce auth here.""" | |
| if user is None: | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Authentication required", | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| return user | |
| def _assemble_config(db: Session, user: User) -> FeedConfigOut: | |
| """Read the three preference tables and shape the config response.""" | |
| locations = ( | |
| db.query(UserLocation) | |
| .filter(UserLocation.user_id == user.user_id) | |
| .order_by(UserLocation.is_primary.desc(), UserLocation.id.asc()) | |
| .all() | |
| ) | |
| lenses = ( | |
| db.query(UserLensPref.lens_slug) | |
| .filter(UserLensPref.user_id == user.user_id) | |
| .order_by(UserLensPref.lens_slug.asc()) | |
| .all() | |
| ) | |
| signals = ( | |
| db.query(UserSignalPref.signal_slug) | |
| .filter(UserSignalPref.user_id == user.user_id) | |
| .order_by(UserSignalPref.signal_slug.asc()) | |
| .all() | |
| ) | |
| return FeedConfigOut( | |
| locations=[ | |
| LocationOut( | |
| id=loc.id, | |
| name=loc.name, | |
| shared_level=loc.shared_level, | |
| is_primary=bool(loc.is_primary), | |
| state_code=loc.state_code, | |
| state=loc.state, | |
| county=loc.county, | |
| latitude=loc.latitude, | |
| longitude=loc.longitude, | |
| jurisdiction_id=loc.jurisdiction_id, | |
| ) | |
| for loc in locations | |
| ], | |
| lenses=[row[0] for row in lenses], | |
| signals=[row[0] for row in signals], | |
| profile_completed=bool(user.profile_completed), | |
| ) | |
| # 2-letter -> full state name, for syncing the primary location into | |
| # User.state when only a state_code came back from the geocoder. | |
| _STATE_CODE_TO_NAME = { | |
| "AL": "Alabama", "AK": "Alaska", "AZ": "Arizona", "AR": "Arkansas", | |
| "CA": "California", "CO": "Colorado", "CT": "Connecticut", "DE": "Delaware", | |
| "FL": "Florida", "GA": "Georgia", "HI": "Hawaii", "ID": "Idaho", | |
| "IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas", | |
| "KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland", | |
| "MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota", "MS": "Mississippi", | |
| "MO": "Missouri", "MT": "Montana", "NE": "Nebraska", "NV": "Nevada", | |
| "NH": "New Hampshire", "NJ": "New Jersey", "NM": "New Mexico", "NY": "New York", | |
| "NC": "North Carolina", "ND": "North Dakota", "OH": "Ohio", "OK": "Oklahoma", | |
| "OR": "Oregon", "PA": "Pennsylvania", "RI": "Rhode Island", "SC": "South Carolina", | |
| "SD": "South Dakota", "TN": "Tennessee", "TX": "Texas", "UT": "Utah", | |
| "VT": "Vermont", "VA": "Virginia", "WA": "Washington", "WV": "West Virginia", | |
| "WI": "Wisconsin", "WY": "Wyoming", "DC": "District of Columbia", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Endpoints | |
| # --------------------------------------------------------------------------- | |
| def get_feed_config( | |
| user: Optional[User] = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ) -> FeedConfigOut: | |
| """Return the current user's saved feed config (locations/lenses/signals).""" | |
| user = _require_user(user) | |
| with tracer.start_as_current_span("feed.get_config") as span: | |
| span.set_attribute("feed.user_id", user.user_id) | |
| return _assemble_config(db, user) | |
| def put_feed_config( | |
| body: FeedConfigIn, | |
| user: Optional[User] = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ) -> FeedConfigOut: | |
| """Full-replace the current user's feed config in a single transaction. | |
| Deletes all existing rows for the user across the three tables, inserts the | |
| submitted ones, marks the profile complete, and syncs the primary location's | |
| city/county/state back into User.city/county/state so Profile.tsx keeps | |
| rendering the "city, state" header. | |
| """ | |
| user = _require_user(user) | |
| with tracer.start_as_current_span("feed.put_config") as span: | |
| span.set_attribute("feed.user_id", user.user_id) | |
| span.set_attribute("feed.locations", len(body.locations)) | |
| span.set_attribute("feed.lenses", len(body.lenses)) | |
| span.set_attribute("feed.signals", len(body.signals)) | |
| try: | |
| # Full replace: clear the user's existing rows in all three tables. | |
| db.query(UserLocation).filter(UserLocation.user_id == user.user_id).delete( | |
| synchronize_session=False | |
| ) | |
| db.query(UserLensPref).filter(UserLensPref.user_id == user.user_id).delete( | |
| synchronize_session=False | |
| ) | |
| db.query(UserSignalPref).filter(UserSignalPref.user_id == user.user_id).delete( | |
| synchronize_session=False | |
| ) | |
| # If nothing is flagged primary, treat the first location as primary. | |
| has_primary = any(loc.is_primary for loc in body.locations) | |
| primary_loc: Optional[LocationIn] = None | |
| for idx, loc in enumerate(body.locations): | |
| is_primary = loc.is_primary or (not has_primary and idx == 0) | |
| if is_primary and primary_loc is None: | |
| primary_loc = loc | |
| db.add(UserLocation( | |
| user_id=user.user_id, | |
| name=loc.name, | |
| shared_level=loc.shared_level, | |
| is_primary=is_primary, | |
| state_code=loc.state_code, | |
| state=loc.state, | |
| county=loc.county, | |
| place_fips=loc.place_fips, | |
| county_fips=loc.county_fips, | |
| latitude=loc.latitude, | |
| longitude=loc.longitude, | |
| jurisdiction_id=loc.jurisdiction_id, | |
| )) | |
| for slug in body.lenses: | |
| db.add(UserLensPref(user_id=user.user_id, lens_slug=slug)) | |
| for slug in body.signals: | |
| db.add(UserSignalPref(user_id=user.user_id, signal_slug=slug)) | |
| # Sync the primary location into the existing User.* columns so the | |
| # legacy Profile.tsx "city, state" header keeps working. | |
| if primary_loc is not None: | |
| resolved_state = primary_loc.state | |
| if not resolved_state and primary_loc.state_code: | |
| resolved_state = _STATE_CODE_TO_NAME.get( | |
| primary_loc.state_code.upper(), primary_loc.state_code | |
| ) | |
| user.county = primary_loc.county | |
| user.state = resolved_state | |
| # The primary's "city" — prefer an explicit city-ish name. The | |
| # location `name` is the display label (e.g. "Tuscaloosa, AL"); | |
| # strip a trailing ", ST" so the header reads cleanly. | |
| city = primary_loc.name | |
| if primary_loc.state_code and city.endswith(f", {primary_loc.state_code}"): | |
| city = city[: -len(f", {primary_loc.state_code}")] | |
| user.city = city.strip() or None | |
| user.profile_completed = True | |
| db.commit() | |
| except Exception as exc: # noqa: BLE001 — roll back, surface a clean 500 | |
| db.rollback() | |
| span.record_exception(exc) | |
| logger.error("Feed config save failed for user {}: {}", user.user_id, exc) | |
| raise HTTPException(status_code=500, detail="Failed to save feed config") | |
| db.refresh(user) | |
| return _assemble_config(db, user) | |
| def _derive_state_code(address: dict) -> Optional[str]: | |
| """Pull a 2-letter state code from a Nominatim address block. | |
| Prefers ISO3166-2-lvl4 ("US-AL" -> "AL"); falls back to a bare `state` only | |
| when it is already a 2-letter code (never invents one). | |
| """ | |
| iso = address.get("ISO3166-2-lvl4") | |
| if iso and "-" in iso: | |
| code = iso.split("-", 1)[1].strip().upper() | |
| if len(code) == 2: | |
| return code | |
| state = address.get("state") | |
| if isinstance(state, str) and len(state) == 2: | |
| return state.upper() | |
| return None | |
| async def feed_places( | |
| q: str = Query("", description="Free-text place query (min 3 chars)."), | |
| ) -> PlacesResponse: | |
| """Place typeahead backed by the Nominatim proxy — real hits only. | |
| Maps each geocoder result to a clean {name, city, county, state, | |
| state_code, latitude, longitude}. Short/empty queries (<3 chars) return an | |
| empty result set; no fabricated/placeholder suggestions (CLAUDE.md). | |
| """ | |
| query = (q or "").strip() | |
| if len(query) < 3: | |
| return PlacesResponse(results=[]) | |
| with tracer.start_as_current_span("feed.places") as span: | |
| span.set_attribute("feed.places.q_len", len(query)) | |
| try: | |
| # Reuse the geocode proxy's throttled+cached Nominatim call directly | |
| # (geocode_search enforces min_length=3 at the param layer, which we | |
| # already handle above). | |
| payload = await _throttled_get( | |
| "/search", | |
| { | |
| "q": query, | |
| "format": "json", | |
| "addressdetails": "1", | |
| "countrycodes": "us", | |
| "limit": "6", | |
| }, | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as exc: # noqa: BLE001 | |
| span.record_exception(exc) | |
| logger.error("Place typeahead failed for '{}': {}", query, exc) | |
| raise HTTPException(status_code=502, detail="Geocoder error.") | |
| results: List[PlaceHit] = [] | |
| for item in payload or []: | |
| address = item.get("address") or {} | |
| city = ( | |
| address.get("city") | |
| or address.get("town") | |
| or address.get("village") | |
| or address.get("hamlet") | |
| or address.get("municipality") | |
| ) | |
| try: | |
| lat = float(item["lat"]) | |
| lon = float(item["lon"]) | |
| except (KeyError, TypeError, ValueError): | |
| continue # no usable coordinates -> skip (no fabricated geo) | |
| results.append(PlaceHit( | |
| name=item.get("display_name") or city or query, | |
| city=city, | |
| county=address.get("county"), | |
| state=address.get("state"), | |
| state_code=_derive_state_code(address), | |
| latitude=lat, | |
| longitude=lon, | |
| )) | |
| span.set_attribute("feed.places.hits", len(results)) | |
| return PlacesResponse(results=results) | |