feat(markets): geo-localize article + map sorting via CF-IPCountry
- Read Cloudflare CF-IPCountry header into g.user_country (before_request) - _filter_articles() sorts user's country first, nearby countries second, then rest — falls back to published_at DESC when header is absent - map_countries sorted so user's country bubble renders on top (Leaflet z-order) - Nearby-country mapping covers DACH, Iberia, Nordics, Benelux, UK/IE, Americas Prerequisite: Nginx must forward CF-IPCountry header to Quart (same as Umami). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,11 @@ def create_app() -> Quart:
|
||||
# Per-request hooks
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@app.before_request
|
||||
async def set_user_country():
|
||||
"""Stash Cloudflare CF-IPCountry header (ISO alpha-2) in g for geo sorting."""
|
||||
g.user_country = request.headers.get("CF-IPCountry", "").upper() or ""
|
||||
|
||||
@app.before_request
|
||||
async def validate_lang():
|
||||
"""404 unsupported language prefixes (e.g. /fr/terms)."""
|
||||
|
||||
@@ -212,6 +212,13 @@ async def markets():
|
||||
FROM serving.pseo_country_overview
|
||||
ORDER BY total_venues DESC
|
||||
""")
|
||||
# Sort so user's country renders last (on top in Leaflet z-order)
|
||||
user_country = g.get("user_country", "")
|
||||
if user_country and map_countries:
|
||||
map_countries = sorted(
|
||||
map_countries,
|
||||
key=lambda c: 1 if c["country_code"] == user_country else 0,
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
"markets.html",
|
||||
@@ -237,9 +244,46 @@ async def market_results():
|
||||
return await render_template("partials/market_results.html", articles=articles)
|
||||
|
||||
|
||||
_NEARBY_COUNTRIES: dict[str, tuple[str, ...]] = {
|
||||
"DE": ("AT", "CH"), "AT": ("DE", "CH"), "CH": ("DE", "AT"),
|
||||
"ES": ("PT",), "PT": ("ES",),
|
||||
"GB": ("IE",), "IE": ("GB",),
|
||||
"US": ("CA",), "CA": ("US",),
|
||||
"IT": ("CH",), "FR": ("BE", "CH"), "BE": ("FR", "NL", "DE"),
|
||||
"NL": ("BE", "DE"), "SE": ("NO", "DK", "FI"), "NO": ("SE", "DK"),
|
||||
"DK": ("SE", "NO", "DE"), "FI": ("SE",),
|
||||
"MX": ("US",), "BR": ("AR",), "AR": ("BR",),
|
||||
}
|
||||
|
||||
|
||||
def _geo_order_clause(user_country: str) -> tuple[str, list]:
|
||||
"""Build ORDER BY clause that sorts user's country first, nearby second.
|
||||
|
||||
Returns (order_sql, params) where order_sql starts with the geo CASE
|
||||
followed by published_at DESC. Caller prepends 'ORDER BY'.
|
||||
"""
|
||||
if not user_country:
|
||||
return "published_at DESC", []
|
||||
|
||||
nearby = _NEARBY_COUNTRIES.get(user_country, ())
|
||||
if nearby:
|
||||
placeholders = ",".join("?" * len(nearby))
|
||||
geo_case = f"""CASE WHEN country = ? THEN 0
|
||||
WHEN country IN ({placeholders}) THEN 1
|
||||
ELSE 2 END,
|
||||
published_at DESC"""
|
||||
return geo_case, [user_country, *nearby]
|
||||
|
||||
return """CASE WHEN country = ? THEN 0 ELSE 1 END,
|
||||
published_at DESC""", [user_country]
|
||||
|
||||
|
||||
async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
|
||||
"""Query published articles for the current language."""
|
||||
"""Query published articles for the current language, geo-sorted."""
|
||||
lang = g.get("lang", "en")
|
||||
user_country = g.get("user_country", "")
|
||||
geo_order, geo_params = _geo_order_clause(user_country)
|
||||
|
||||
if q:
|
||||
# FTS query
|
||||
wheres = ["articles_fts MATCH ?"]
|
||||
@@ -253,14 +297,16 @@ async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
|
||||
wheres.append("a.region = ?")
|
||||
params.append(region)
|
||||
where = " AND ".join(wheres)
|
||||
# Geo-sort references a.country
|
||||
order = geo_order.replace("country", "a.country")
|
||||
return await fetch_all(
|
||||
f"""SELECT a.* FROM articles a
|
||||
JOIN articles_fts ON articles_fts.rowid = a.id
|
||||
WHERE {where}
|
||||
AND a.status = 'published' AND a.published_at <= datetime('now')
|
||||
ORDER BY a.published_at DESC
|
||||
ORDER BY {order}
|
||||
LIMIT 100""",
|
||||
tuple(params),
|
||||
tuple(params + geo_params),
|
||||
)
|
||||
else:
|
||||
wheres = ["status = 'published'", "published_at <= datetime('now')", "language = ?"]
|
||||
@@ -274,8 +320,8 @@ async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
|
||||
where = " AND ".join(wheres)
|
||||
return await fetch_all(
|
||||
f"""SELECT * FROM articles WHERE {where}
|
||||
ORDER BY published_at DESC LIMIT 100""",
|
||||
tuple(params),
|
||||
ORDER BY {geo_order} LIMIT 100""",
|
||||
tuple(params + geo_params),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user