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
23 changes: 14 additions & 9 deletions backend/app/routers/dive_sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -3680,12 +3680,12 @@ async def get_dive_site_dives(
request: Request,
dive_site_id: int,
limit: int = Query(10, ge=1, le=50),
sort_by: str = Query("recent", description="Sort order: 'recent' (dive date desc) or 'rating' (rating desc, then date desc)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_optional)
):
"""
Get top dives for a specific dive site, ordered by rating (descending).
If no rating is available, returns the first 10 dives.
Get dives for a specific dive site, ordered by recent date (descending) or rating (descending).
"""
# Check if dive site exists
dive_site = db.query(DiveSite).filter(DiveSite.id == dive_site_id).first()
Expand All @@ -3710,13 +3710,18 @@ async def get_dive_site_dives(
)
)

# Order by rating (descending) first, then by dive date (descending)
# Dives with no rating will be ordered by dive date
query = query.order_by(
desc(Dive.user_rating), # Highest rating first
desc(Dive.dive_date), # Most recent first
desc(Dive.dive_time) # Most recent time first
)
# Order by rating or recent date
if sort_by == "rating":
query = query.order_by(
desc(Dive.user_rating), # Highest rating first
desc(Dive.dive_date), # Most recent first
desc(Dive.dive_time) # Most recent time first
)
else:
query = query.order_by(
desc(Dive.dive_date), # Most recent first
desc(Dive.dive_time) # Most recent time first
)

# Limit results
dives = query.limit(limit).all()
Expand Down
24 changes: 21 additions & 3 deletions backend/app/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,20 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
import httpx
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session, joinedload, selectinload

from app.database import get_db
from app.models import Dive, DiveRoute, DiveSite, DivingCenter, DivingOrganization, User, MediaType
from app.models import (
Dive,
DiveRoute,
DiveSite,
DivingCenter,
DivingOrganization,
User,
MediaType,
DiveSiteTag,
CenterDiveSite,
)

def make_absolute_url(url: str, base_url: str) -> str:
if not url:
Expand Down Expand Up @@ -278,7 +288,15 @@ async def get_prerendered_page(request: Request, path: str, db: Session = Depend
site = (
db.query(DiveSite)
.filter(DiveSite.id == site_id, DiveSite.status == "approved", DiveSite.deleted_at.is_(None))
.options(joinedload(DiveSite.difficulty), joinedload(DiveSite.ratings), joinedload(DiveSite.media))
.options(
joinedload(DiveSite.difficulty),
joinedload(DiveSite.ratings),
joinedload(DiveSite.media),
selectinload(DiveSite.aliases),
selectinload(DiveSite.tags).joinedload(DiveSiteTag.tag),
selectinload(DiveSite.routes),
selectinload(DiveSite.center_relationships).joinedload(CenterDiveSite.diving_center),
)
.first()
)
if not site:
Expand Down
61 changes: 58 additions & 3 deletions backend/generate_static_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,20 @@
if current_dir not in sys.path:
sys.path.append(current_dir)

from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session, joinedload, selectinload
from app.database import SessionLocal
from app.models import DiveSite, DiveRoute, DivingCenter, Dive, ParsedDiveTrip, User, DivingOrganization, CertificationLevel
from app.models import (
DiveSite,
DiveRoute,
DivingCenter,
Dive,
ParsedDiveTrip,
User,
DivingOrganization,
CertificationLevel,
DiveSiteTag,
CenterDiveSite,
)
from app.seo_geo import (
distinct_approved_countries,
distinct_approved_regions_by_country,
Expand Down Expand Up @@ -192,7 +203,19 @@ def generate_content(db: Session, r2_client=None):
BASE_URL = CANONICAL_BASE_URL.rstrip("/")

# Data gathering
sites = db.query(DiveSite).filter(DiveSite.status == 'approved').all()
sites = (
db.query(DiveSite)
.filter(DiveSite.status == 'approved', DiveSite.deleted_at.is_(None))
.options(
joinedload(DiveSite.difficulty),
joinedload(DiveSite.ratings),
selectinload(DiveSite.aliases),
selectinload(DiveSite.tags).joinedload(DiveSiteTag.tag),
selectinload(DiveSite.routes),
selectinload(DiveSite.center_relationships).joinedload(CenterDiveSite.diving_center),
)
.all()
)
routes = db.query(DiveRoute).filter(DiveRoute.deleted_at == None).all()
centers = db.query(DivingCenter).all()
# LLM markdown: all public logs; sitemap uses the substantial subset below
Expand Down Expand Up @@ -222,6 +245,17 @@ def generate_content(db: Session, r2_client=None):
content_sites.append(f"- **Max Depth**: {site.max_depth}m\n")
if site.difficulty:
content_sites.append(f"- **Difficulty**: {site.difficulty.label}\n")
if site.ratings:
avg_score = sum(r.score for r in site.ratings) / len(site.ratings)
content_sites.append(f"- **Rating**: {avg_score:.1f}/10 ({len(site.ratings)} reviews)\n")
if site.aliases:
alias_list = [a.alias for a in site.aliases if getattr(a, "alias", None)]
if alias_list:
content_sites.append(f"- **Also Known As**: {', '.join(alias_list)}\n")
if site.tags:
tag_list = [t.tag.name for t in site.tags if getattr(t, "tag", None) and getattr(t.tag, "name", None)]
if tag_list:
content_sites.append(f"- **Tags**: {', '.join(tag_list)}\n")

# Description & Details
if site.description:
Expand All @@ -236,6 +270,27 @@ def generate_content(db: Session, r2_client=None):
if site.access_instructions:
content_sites.append(f"\n**Access**:\n{site.access_instructions}\n")

if site.center_relationships:
centers_list = [rel.diving_center for rel in site.center_relationships if getattr(rel, "diving_center", None)]
if centers_list:
content_sites.append("\n**Associated Diving Centers**:\n")
for center in centers_list:
c_slug = get_diving_center_slug(center)
c_url = f"{BASE_URL}/diving-centers/{center.id}/{c_slug}" if c_slug else f"{BASE_URL}/diving-centers/{center.id}"
c_loc = ", ".join(filter(None, [getattr(center, "city", None), getattr(center, "country", None)]))
extra = f" ({c_loc})" if c_loc else ""
content_sites.append(f"- [{center.name}]({c_url}){extra}\n")

if site.routes:
active_routes = [r for r in site.routes if not getattr(r, "deleted_at", None)]
if active_routes:
content_sites.append("\n**Dive Routes**:\n")
for route in active_routes:
r_slug = slugify(route.name) if getattr(route, "name", None) else ""
r_url = f"{BASE_URL}/dive-routes/{route.id}/{r_slug}" if r_slug else f"{BASE_URL}/dive-routes/{route.id}"
r_type = f" ({route.route_type.name})" if getattr(route, "route_type", None) and getattr(route.route_type, "name", None) else ""
content_sites.append(f"- [{route.name or f'Route #{route.id}'}]({r_url}){r_type}\n")

content_sites.append("\n---\n\n")

# 2. Dive Routes
Expand Down
46 changes: 46 additions & 0 deletions backend/static_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ def _paragraph_block(label: str, value: str) -> str:

def render_dive_site_main(site: DiveSite, avg_rating: Optional[float], total_ratings: int) -> str:
from app.seo_geo import geo_hub_path
from generate_static_content import get_diving_center_slug, slugify

crumbs = [("Home", "/"), ("Dive Sites", "/dive-sites")]
if site.country:
Expand All @@ -305,6 +306,12 @@ def render_dive_site_main(site: DiveSite, avg_rating: Optional[float], total_rat
if location:
lines.append(f"<p><strong>Location:</strong> {escape_text(location)}</p>")

aliases = getattr(site, "aliases", None) or []
if aliases:
alias_names = [getattr(a, "alias", str(a)) for a in aliases if getattr(a, "alias", str(a))]
if alias_names:
lines.append(f"<p><strong>Also known as:</strong> {escape_text(', '.join(alias_names))}</p>")

meta_bits = []
if site.max_depth:
meta_bits.append(f"Max depth: {format_depth(site.max_depth)}m")
Expand All @@ -315,6 +322,18 @@ def render_dive_site_main(site: DiveSite, avg_rating: Optional[float], total_rat
if meta_bits:
lines.append(f"<p>{escape_text(' · '.join(meta_bits))}</p>")

tags = getattr(site, "tags", None) or []
if tags:
tag_names = []
for t in tags:
tag_obj = getattr(t, "tag", None)
if tag_obj and getattr(tag_obj, "name", None):
tag_names.append(tag_obj.name)
elif isinstance(t, str):
tag_names.append(t)
if tag_names:
lines.append(f"<p><strong>Tags:</strong> {escape_text(', '.join(tag_names))}</p>")

if total_ratings > 0 and avg_rating is not None:
lines.append(
f"<p><strong>Rating:</strong> {escape_text(f'{avg_rating:.1f}/10')} "
Expand All @@ -330,6 +349,33 @@ def render_dive_site_main(site: DiveSite, avg_rating: Optional[float], total_rat
if site.safety_information:
lines.append(_paragraph_block("Safety", site.safety_information))

center_rels = getattr(site, "center_relationships", None) or []
if center_rels:
centers = [getattr(rel, "diving_center", None) for rel in center_rels if getattr(rel, "diving_center", None)]
if centers:
lines.append("<h2>Associated Diving Centers</h2><ul>")
for center in centers:
c_slug = get_diving_center_slug(center)
c_href = f"/diving-centers/{center.id}/{c_slug}" if c_slug else f"/diving-centers/{center.id}"
c_name = escape_text(center.name)
c_location = ", ".join(filter(None, [getattr(center, "city", None), getattr(center, "country", None)]))
extra = f" ({escape_text(c_location)})" if c_location else ""
lines.append(f'<li><a href="{escape_text(c_href)}">{c_name}</a>{extra}</li>')
lines.append("</ul>")

routes = getattr(site, "routes", None) or []
active_routes = [r for r in routes if not getattr(r, "deleted_at", None)]
if active_routes:
lines.append("<h2>Dive Routes</h2><ul>")
for route in active_routes:
r_slug = slugify(route.name) if getattr(route, "name", None) else ""
r_href = f"/dive-routes/{route.id}/{r_slug}" if r_slug else f"/dive-routes/{route.id}"
r_name = escape_text(route.name or f"Route #{route.id}")
r_type_obj = getattr(route, "route_type", None)
r_type = f" ({escape_text(r_type_obj.name)})" if r_type_obj and getattr(r_type_obj, "name", None) else ""
lines.append(f'<li><a href="{escape_text(r_href)}">{r_name}</a>{r_type}</li>')
lines.append("</ul>")

lines.extend([
'<nav aria-label="Related pages">',
'<a href="/dive-sites">All Dive Sites</a>',
Expand Down
37 changes: 37 additions & 0 deletions backend/tests/test_dive_sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,43 @@ def test_get_dive_site_dives_empty(self, client, db_session, test_dive_site):
data = response.json()
assert len(data) == 0

def test_get_dive_site_dives_sorting(self, client, db_session, test_user, test_dive_site):
"""Test getting dives sorted by recent (default) vs rating."""
from app.models import Dive
from datetime import date

dive_older_high_rated = Dive(
name="Older Top Rated Dive",
user_id=test_user.id,
dive_site_id=test_dive_site.id,
dive_date=date(2023, 5, 1),
user_rating=10
)
dive_newer_low_rated = Dive(
name="Newer Low Rated Dive",
user_id=test_user.id,
dive_site_id=test_dive_site.id,
dive_date=date(2024, 6, 1),
user_rating=5
)

db_session.add_all([dive_older_high_rated, dive_newer_low_rated])
db_session.commit()

# Default / recent sort: Newer dive comes first
response_recent = client.get(f"/api/v1/dive-sites/{test_dive_site.id}/dives?sort_by=recent")
assert response_recent.status_code == status.HTTP_200_OK
data_recent = response_recent.json()
assert len(data_recent) >= 2
assert data_recent[0]["name"] == "Newer Low Rated Dive"

# Rating sort: Higher rated dive comes first
response_rating = client.get(f"/api/v1/dive-sites/{test_dive_site.id}/dives?sort_by=rating")
assert response_rating.status_code == status.HTTP_200_OK
data_rating = response_rating.json()
assert len(data_rating) >= 2
assert data_rating[0]["name"] == "Older Top Rated Dive"

class TestDiveSitesAdvancedFeatures:
"""Test advanced dive site features and edge cases."""

Expand Down
66 changes: 66 additions & 0 deletions backend/tests/test_static_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,47 @@ def __init__(self, score):
self.score = score


class MockAlias:
def __init__(self, alias):
self.alias = alias


class MockAvailableTag:
def __init__(self, name):
self.name = name


class MockSiteTag:
def __init__(self, name):
self.tag = MockAvailableTag(name)


class MockCenter:
def __init__(self, id, name, city="Anavissos", country="Greece"):
self.id = id
self.name = name
self.city = city
self.country = country


class MockCenterRel:
def __init__(self, center):
self.diving_center = center


class MockRouteType:
def __init__(self, name):
self.name = name


class MockRoute:
def __init__(self, id, name, route_type_name="Scenic", deleted_at=None):
self.id = id
self.name = name
self.route_type = MockRouteType(route_type_name)
self.deleted_at = deleted_at


class MockSite:
def __init__(self, **kwargs):
self.name = kwargs.get("name", "Agia Anna")
Expand All @@ -57,6 +98,10 @@ def __init__(self, **kwargs):
self.safety_information = kwargs.get("safety_information")
self.difficulty = kwargs.get("difficulty", MockDifficulty())
self.ratings = kwargs.get("ratings", [MockRating(9), MockRating(8)])
self.aliases = kwargs.get("aliases", [])
self.tags = kwargs.get("tags", [])
self.center_relationships = kwargs.get("center_relationships", [])
self.routes = kwargs.get("routes", [])


def test_strip_html_tags():
Expand Down Expand Up @@ -166,3 +211,24 @@ def test_dive_site_schema_omits_aggregate_rating_when_unrated():
)
assert schema["@type"] == "TouristAttraction"
assert "aggregateRating" not in schema


def test_render_dive_site_main_includes_aliases_tags_centers_routes():
site = MockSite(
aliases=[MockAlias("Fish Farm"), MockAlias("Ιχθυοτροφείο")],
tags=[MockSiteTag("Deep"), MockSiteTag("Wall")],
center_relationships=[MockCenterRel(MockCenter(54, "Aqualized", city="Anavissos", country="Greece"))],
routes=[MockRoute(12, "Canyon Traverse", "Wall Dive")],
)
html = render_dive_site_main(site, 8.5, 2)
assert "Also known as:" in html
assert "Fish Farm, Ιχθυοτροφείο" in html
assert "Tags:" in html
assert "Deep, Wall" in html
assert "Associated Diving Centers" in html
assert "/diving-centers/54/" in html
assert "Aqualized" in html
assert "Dive Routes" in html
assert "/dive-routes/12/" in html
assert "Canyon Traverse" in html

Loading
Loading