Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions backend/app/routers/dive_sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from app.schemas import (
DiveSiteCreate, DiveSiteUpdate, DiveSiteResponse, DiveSiteListResponse,
DiveSiteRegionOption,
SiteRatingCreate, SiteRatingResponse,
SiteCommentCreate, SiteCommentUpdate, SiteCommentResponse,
SiteMediaCreate, SiteMediaUpdate, SiteMediaResponse, DiveSiteMediaOrderRequest,
Expand Down Expand Up @@ -2168,21 +2169,30 @@ async def get_unique_countries(request: Request, search: Optional[str] = Query(N
countries = query.distinct().order_by(DiveSite.country).all()
return [c[0] for c in countries]

@router.get("/regions", response_model=List[str])
@router.get("/regions", response_model=List[DiveSiteRegionOption])
@skip_rate_limit_for_admin("100/minute")
@cache(expire=3600)
async def get_unique_regions(request: Request, country: Optional[str] = Query(None, max_length=100), search: Optional[str] = Query(None, max_length=100), db: Session = Depends(get_db)):
"""Get unique regions from dive sites with optional country and search filtering"""
query = db.query(DiveSite.region).filter(DiveSite.region.isnot(None))
async def get_unique_region_options(
request: Request,
country: Optional[str] = Query(None, max_length=100),
search: Optional[str] = Query(None, max_length=100),
db: Session = Depends(get_db),
):
"""Get unique regions (with country) from dive sites with optional country and search filtering.

Function renamed from get_unique_regions so rolling deploys do not serve the
old string[] response shape from the previous 1h cache key.
"""
query = db.query(DiveSite.region, DiveSite.country).filter(DiveSite.region.isnot(None))

if country:
query = query.filter(DiveSite.country == country)

if search:
query = query.filter(DiveSite.region.ilike(f"%{search}%"))

regions = query.distinct().order_by(DiveSite.region).all()
return [r[0] for r in regions]
rows = query.distinct().order_by(DiveSite.country, DiveSite.region).all()
return [{"region": region, "country": country_name} for region, country_name in rows]

@router.get("/{dive_site_id}", response_model=DiveSiteResponse)
@skip_rate_limit_for_admin("300/minute")
Expand Down
103 changes: 93 additions & 10 deletions backend/app/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,25 @@ def get_image_mime_type(url: str) -> str:
dive_site_schema,
diving_center_meta_description,
diving_center_schema,
geo_hub_schema,
render_dive_route_main,
render_dive_site_main,
render_diving_center_main,
render_geo_hub_main,
render_homepage_main,
render_listing_main,
render_map_main,
render_seo_page,
resolve_html_template,
escape_text,
format_depth,
)
from app.seo_geo import (
distinct_approved_countries,
distinct_approved_regions,
geo_hub_path,
resolve_label_from_slug,
)

logger = logging.getLogger("divemap.seo")

Expand Down Expand Up @@ -259,11 +268,10 @@ async def get_prerendered_page(request: Request, path: str, db: Session = Depend
"Comprehensive registry of dive sites including coordinates, depth profiles, difficulty, and marine life.",
site_links,
)
else:
# Dive Site Detail
elif parts[1].isdigit():
# Dive Site Detail (numeric id)
try:
site_id_str = re.sub(r"\D", "", parts[1])
site_id = int(site_id_str)
site_id = int(parts[1])
except ValueError:
raise HTTPException(status_code=404, detail="Invalid Dive Site ID")

Expand Down Expand Up @@ -329,6 +337,82 @@ async def get_prerendered_page(request: Request, path: str, db: Session = Depend
description = dive_site_meta_description(site, avg, total)
json_ld = dive_site_schema(base_url, detail_path, site, avg, total)
canonical = f"{base_url}{detail_path}"
else:
# Geo hub: /dive-sites/{country-slug}[/region-slug]
countries = distinct_approved_countries(db)
country = resolve_label_from_slug(countries, parts[1])
if not country:
raise HTTPException(status_code=404, detail="Country not found")

region = None
if len(parts) >= 3 and parts[2]:
regions = distinct_approved_regions(db, country)
region = resolve_label_from_slug(regions, parts[2])
if not region:
raise HTTPException(status_code=404, detail="Region not found")
# Canonicalize slug spelling
expected = geo_hub_path(country, region)
actual = f"/dive-sites/{parts[1]}/{parts[2]}"
if expected and actual != expected:
return RedirectResponse(url=f"{base_url}{expected}", status_code=301)
else:
expected = geo_hub_path(country)
actual = f"/dive-sites/{parts[1]}"
if expected and actual != expected:
return RedirectResponse(url=f"{base_url}{expected}", status_code=301)

q = db.query(DiveSite).filter(
DiveSite.status == "approved",
DiveSite.deleted_at.is_(None),
DiveSite.country == country,
)
if region:
q = q.filter(DiveSite.region == region)
sites = q.limit(100).all()
site_links = []
for s in sites:
slug = get_dive_site_slug(s)
link_path = f"/dive-sites/{s.id}/{slug}" if slug else f"/dive-sites/{s.id}"
site_links.append((s.name, link_path))

region_links = None
if not region:
region_links = [
(r, geo_hub_path(country, r))
for r in distinct_approved_regions(db, country)
if geo_hub_path(country, r)
]

hub_path = geo_hub_path(country, region)
heading = f"Dive Sites in {region}, {country}" if region else f"Dive Sites in {country}"
description = (
f"Browse scuba dive sites in {heading.replace('Dive Sites in ', '')}. "
"Depths, difficulty ratings, and community reviews on Divemap."
)
page_title = f"{heading} | Divemap"
canonical = f"{base_url}{hub_path}"
main_content = render_geo_hub_main(country, region, site_links, region_links)
json_ld = geo_hub_schema(base_url, hub_path, heading, description, site_links)

elif parts[0] == "map":
countries = distinct_approved_countries(db)
country_links = [
(c, geo_hub_path(c)) for c in countries[:40] if geo_hub_path(c)
]
page_title = "Global Interactive Dive Map | Divemap"
description = (
"Explore scuba dive sites and diving centers on an interactive world map. "
"Browse by country or open the full map experience."
)
canonical = f"{base_url}/map"
main_content = render_map_main(country_links)
json_ld = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": page_title,
"description": description,
"url": canonical,
}

elif parts[0] == "diving-centers":
if len(parts) == 1:
Expand Down Expand Up @@ -444,8 +528,7 @@ async def get_prerendered_page(request: Request, path: str, db: Session = Depend

elif parts[0] == "dives":
if len(parts) == 1:
# Public Dives Directory Listing
# Fetch dives only associated with active, non-deleted users
# Dive Log directory listing
dives = (
db.query(Dive)
.join(User, Dive.user_id == User.id)
Expand All @@ -468,11 +551,11 @@ async def get_prerendered_page(request: Request, path: str, db: Session = Depend
link_path = f"/dives/{d.id}/{slug}" if slug else f"/dives/{d.id}"
dive_links.append((label, link_path))

page_title = "Divemap - Public Dives"
page_title = "Divemap - Dive Log"
description = "Browse public scuba diving logs, profiles, and dive activities shared by the Divemap community."
canonical = f"{base_url}/dives"
main_content = render_listing_main(
"Public Dives",
"Dive Log",
"Explore recent diving activities and public logbooks shared by the community.",
dive_links,
)
Expand Down Expand Up @@ -538,8 +621,8 @@ async def get_prerendered_page(request: Request, path: str, db: Session = Depend
main_content = f"""<main class="seo-prerender">
<nav class="breadcrumbs">
<a href="/">Home</a> &rsaquo;
<a href="/dives">Dives</a> &rsaquo;
<span>{escape_text(diver)}'s Log</span>
<a href="/dives">Dive Log</a> &rsaquo;
<span>{escape_text(dive.name or f"{diver}'s dive at {site_name}")}</span>
</nav>
<h1>{escape_text(diver)}'s dive at {escape_text(site_name)}</h1>
<p><strong>Title:</strong> {escape_text(dive.name or 'Unnamed Dive')}</p>
Expand Down
5 changes: 5 additions & 0 deletions backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,11 @@ class DiveSiteListResponse(BaseModel):
has_next_page: bool
has_prev_page: bool

class DiveSiteRegionOption(BaseModel):
"""Unique region with its country (for geo-hub URL resolution)."""
region: str
country: Optional[str] = None

# Site Rating Schemas
class SiteRatingCreate(BaseModel):
score: float = Field(..., ge=1, le=10)
Expand Down
Loading
Loading