Skip to content
Open
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
28 changes: 27 additions & 1 deletion backend/packages/providers/providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

from abc import ABC, abstractmethod
from datetime import date

import httpx
import structlog
Expand All @@ -13,6 +14,7 @@
from sqlalchemy.sql.functions import concat

from db.models import Campground, Provider, RecreationArea, Search
from providers.dto import CampsiteDTO

logger = structlog.getLogger()

Expand All @@ -26,14 +28,38 @@ def __init__(self) -> None:
"""
Initialize the base provider.
"""
try:
from fake_useragent import UserAgent

self.user_agent = UserAgent(browsers=["chrome"]).random
except Exception:
self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
self.async_client = httpx.AsyncClient(headers=self.headers)

@abstractmethod
async def find_availabilities(
self,
park_id: str,
start_date: date,
end_date: date,
) -> list[CampsiteDTO]:
"""
Find campsite availabilities for the target park and dates.
"""

@abstractmethod
async def sync_metadata(self) -> None:
"""
Background task to update the 'Search' and 'Campground'
tables with the latest info from the provider.
"""

@property
def headers(self) -> dict[str, str]:
"""
Headers for the provider requests.
"""
return {}
return {"User-Agent": self.user_agent}

@abstractmethod
async def populate_database(self) -> None:
Expand Down
35 changes: 35 additions & 0 deletions backend/packages/providers/providers/dto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
Unified Data Transfer Objects for Providers
"""

from datetime import date
from enum import StrEnum
from typing import Any

from pydantic import BaseModel, Field


class CampsiteType(StrEnum):
"""
Standardized Campsite Type Enum
"""

TENT = "TENT"
RV = "RV"
CABIN = "CABIN"
OTHER = "OTHER"


class CampsiteDTO(BaseModel):
"""
Standardized Campsite Data Transfer Object
"""

campsite_id: str
campsite_name: str
campsite_type: CampsiteType
capacity: int
available_dates: list[date]
is_electric: bool
is_accessible: bool
metadata: dict[str, Any] = Field(default_factory=dict)
74 changes: 74 additions & 0 deletions backend/packages/providers/providers/recreation_gov/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""
Recreation.gov Provider Configuration
"""

from datetime import timedelta
from urllib.parse import urljoin

from pydantic import BaseModel, Field


class RecreationGovConfig(BaseModel):
"""
Configuration parameters and URLs for Recreation.gov.
"""

api_scheme: str = "https"
api_netloc: str = "www.recreation.gov"

# Base URLs
base_url: str = "https://www.recreation.gov/"
ridb_base_url: str = "https://ridb.recreation.gov/"

# Referer Header Value
referer_header: str = "https://www.recreation.gov/"

# API and Web Endpoints/Prefixes
campsite_search_endpoint: str = "api/search/campsites"
campsite_availability_endpoint: str = "api/camps/availability/campground"
ridb_export_endpoint: str = "downloads/RIDBFullExport_V1_JSON.zip"
gateway_endpoint_prefix: str = "gateways"
campground_endpoint_prefix: str = "camping/campgrounds"

# Date formatting pattern for API month queries
api_month_format: str = "%Y-%m-01T00:00:00.000Z"

# Expiration time for offline cached data
offline_cache_expiration: timedelta = Field(
default_factory=lambda: timedelta(hours=12)
)

@property
def campsite_search_url(self) -> str:
"""
Full campsite search metadata URL.
"""
return urljoin(self.base_url, self.campsite_search_endpoint)

@property
def campsite_availability_url(self) -> str:
"""
Full campsite availability URL prefix.
"""
return urljoin(self.base_url, self.campsite_availability_endpoint)

@property
def ridb_export_url(self) -> str:
"""
Full RIDB export data download URL.
"""
return urljoin(self.ridb_base_url, self.ridb_export_endpoint)

@property
def gateway_url_prefix(self) -> str:
"""
Full gateway URL prefix.
"""
return urljoin(self.base_url, self.gateway_endpoint_prefix)

@property
def campground_url_prefix(self) -> str:
"""
Full campground URL prefix.
"""
return urljoin(self.base_url, self.campground_endpoint_prefix)
110 changes: 110 additions & 0 deletions backend/packages/providers/providers/recreation_gov/models/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""
Recreation.gov Raw API Pydantic Models
"""

from datetime import datetime
from typing import Any

from pydantic import BaseModel, Field


class RecDotGovEquipment(BaseModel):
"""
Permitted equipment definition for Recreation.gov campsites
"""

equipment_name: str
max_length: float


class RecDotGovAttribute(BaseModel):
"""
Campsite attributes (e.g. electric hookup, sewer, pets allowed)
"""

attribute_category: str | None = None
attribute_id: int
attribute_name: str
attribute_value: Any


class RecDotGovCampsite(BaseModel):
"""
Single campsite entry in Recreation.gov campsite search results
"""

campsite_id: int
name: str
type: str | None = None
accessible: bool = False
loop: str = ""
latitude: float | None = None
longitude: float | None = None
permitted_equipment: list[RecDotGovEquipment] = Field(default_factory=list)
attributes: list[RecDotGovAttribute] = Field(default_factory=list)


class RecDotGovCampsiteResponse(BaseModel):
"""
Response wrapper for campsite search metadata endpoint
"""

campsites: list[RecDotGovCampsite]
size: int
start: int
total: int


class CampsiteAvailabilityCampsite(BaseModel):
"""
Single campsite's monthly availability block
"""

availabilities: dict[datetime, str] = Field(default_factory=dict)
loop: str = "Default Loop"
campsite_type: str | None = None
max_num_people: int = 1
min_num_people: int = 1
type_of_use: str | None = None
site: str = "Default Site"


class CampsiteAvailabilityResponse(BaseModel):
"""
Response wrapper for monthly availability endpoint
"""

campsites: dict[int, CampsiteAvailabilityCampsite]


class RecreationGovCampsiteMetadata(BaseModel):
"""
Standardized metadata block for Recreation.gov campsites.
"""

loop: str
site: str
type_of_use: str | None = None
permitted_equipment: list[RecDotGovEquipment] = Field(default_factory=list)
attributes: list[RecDotGovAttribute] = Field(default_factory=list)
latitude: float | None = None
longitude: float | None = None


class RecDotGovCampsiteSearchParams(BaseModel):
"""
Query parameters for campsite search metadata endpoint
"""

start: int = 0
size: int = 1000
fq: list[str] = Field(default_factory=list)
include_non_site_specific_campsites: bool = True


class RecDotGovAvailabilityParams(BaseModel):
"""
Query parameters for campsite availability endpoint
"""

start_date: str
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""
Recreation.gov Specific Enums
"""

from enum import StrEnum


class RecDotGovAvailabilityStatus(StrEnum):
"""
Availability status values returned by Recreation.gov availability API.
"""

AVAILABLE = "Available"
RESERVED = "Reserved"
NOT_AVAILABLE = "Not Available"
NOT_RESERVABLE = "Not Reservable"
NOT_RESERVABLE_MANAGEMENT = "Not Reservable Management"
NOT_AVAILABLE_CUTOFF = "Not Available Cutoff"
LOTTERY = "Lottery"
OPEN = "Open"
NYR = "NYR"
CLOSED = "Closed"


class RecDotGovEquipmentType(StrEnum):
"""
Equipment type keywords used to classify RV campsites.
"""

RV = "rv"
TRAILER = "trailer"
MOTORHOME = "motorhome"
FIFTH_WHEEL = "fifth wheel"
PICKUP_CAMPER = "pickup camper"
POP_UP = "pop up"
CARAVAN = "caravan"


class TentKeywords(StrEnum):
"""
Keywords indicating tent campsites.
"""

TENT = "tent"


class RVKeywords(StrEnum):
"""
Keywords indicating RV/trailer campsites.
"""

RV = "rv"
TRAILER = "trailer"
MOTORHOME = "motorhome"


class CabinKeywords(StrEnum):
"""
Keywords indicating cabin/shelter campsites.
"""

CABIN = "cabin"
YURT = "yurt"
SHELTER = "shelter"
Loading