mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 16:51:09 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from .roadtrip import RoadTripService
|
||||
from .park_management import ParkService, LocationService
|
||||
from .park_management import ParkService
|
||||
from .location_service import ParkLocationService
|
||||
from .filter_service import ParkFilterService
|
||||
|
||||
__all__ = ["RoadTripService", "ParkService", "LocationService", "ParkFilterService"]
|
||||
from .media_service import ParkMediaService
|
||||
__all__ = ["RoadTripService", "ParkService",
|
||||
"ParkLocationService", "ParkFilterService", "ParkMediaService"]
|
||||
|
||||
492
backend/apps/parks/services/location_service.py
Normal file
492
backend/apps/parks/services/location_service.py
Normal file
@@ -0,0 +1,492 @@
|
||||
"""
|
||||
Parks-specific location services with OpenStreetMap integration.
|
||||
Handles geocoding, reverse geocoding, and location search for parks.
|
||||
"""
|
||||
|
||||
import requests
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
import logging
|
||||
|
||||
from ..models import ParkLocation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParkLocationService:
|
||||
"""
|
||||
Location service specifically for parks using OpenStreetMap Nominatim API.
|
||||
"""
|
||||
|
||||
NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org"
|
||||
USER_AGENT = "ThrillWiki/1.0 (https://thrillwiki.com)"
|
||||
|
||||
@classmethod
|
||||
def search_locations(cls, query: str, limit: int = 10) -> Dict[str, Any]:
|
||||
"""
|
||||
Search for locations using OpenStreetMap Nominatim API.
|
||||
Optimized for finding theme parks and amusement parks.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum number of results (default: 10, max: 25)
|
||||
|
||||
Returns:
|
||||
Dictionary with search results
|
||||
"""
|
||||
if not query.strip():
|
||||
return {"count": 0, "results": [], "query": query}
|
||||
|
||||
# Limit the number of results
|
||||
limit = min(limit, 25)
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"park_location_search:{query.lower()}:{limit}"
|
||||
cached_result = cache.get(cache_key)
|
||||
if cached_result:
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
params = {
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"limit": limit,
|
||||
"addressdetails": 1,
|
||||
"extratags": 1,
|
||||
"namedetails": 1,
|
||||
"accept-language": "en",
|
||||
# Prioritize places that might be parks or entertainment venues
|
||||
"featuretype": "settlement,leisure,tourism",
|
||||
}
|
||||
|
||||
headers = {
|
||||
"User-Agent": cls.USER_AGENT,
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
f"{cls.NOMINATIM_BASE_URL}/search",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
osm_results = response.json()
|
||||
|
||||
# Transform OSM results to our format
|
||||
results = []
|
||||
for item in osm_results:
|
||||
result = cls._transform_osm_result(item)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
result_data = {"count": len(results), "results": results, "query": query}
|
||||
|
||||
# Cache for 1 hour
|
||||
cache.set(cache_key, result_data, 3600)
|
||||
|
||||
return result_data
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Error searching park locations: {str(e)}")
|
||||
return {
|
||||
"count": 0,
|
||||
"results": [],
|
||||
"query": query,
|
||||
"error": "Location search service temporarily unavailable",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def reverse_geocode(cls, latitude: float, longitude: float) -> Dict[str, Any]:
|
||||
"""
|
||||
Reverse geocode coordinates to get location information using OSM.
|
||||
|
||||
Args:
|
||||
latitude: Latitude coordinate
|
||||
longitude: Longitude coordinate
|
||||
|
||||
Returns:
|
||||
Dictionary with location information
|
||||
"""
|
||||
# Validate coordinates
|
||||
if not (-90 <= latitude <= 90) or not (-180 <= longitude <= 180):
|
||||
return {"error": "Invalid coordinates"}
|
||||
|
||||
# Check cache first
|
||||
cache_key = f"park_reverse_geocode:{latitude:.6f}:{longitude:.6f}"
|
||||
cached_result = cache.get(cache_key)
|
||||
if cached_result:
|
||||
return cached_result
|
||||
|
||||
try:
|
||||
params = {
|
||||
"lat": latitude,
|
||||
"lon": longitude,
|
||||
"format": "json",
|
||||
"addressdetails": 1,
|
||||
"extratags": 1,
|
||||
"namedetails": 1,
|
||||
"accept-language": "en",
|
||||
}
|
||||
|
||||
headers = {
|
||||
"User-Agent": cls.USER_AGENT,
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
f"{cls.NOMINATIM_BASE_URL}/reverse",
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
osm_result = response.json()
|
||||
|
||||
if "error" in osm_result:
|
||||
return {"error": "Location not found"}
|
||||
|
||||
result = cls._transform_osm_reverse_result(osm_result)
|
||||
|
||||
# Cache for 24 hours
|
||||
cache.set(cache_key, result, 86400)
|
||||
|
||||
return result
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Error reverse geocoding park location: {str(e)}")
|
||||
return {"error": "Reverse geocoding service temporarily unavailable"}
|
||||
|
||||
@classmethod
|
||||
def geocode_address(cls, address: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Geocode an address to get coordinates using OSM.
|
||||
|
||||
Args:
|
||||
address: Address string to geocode
|
||||
|
||||
Returns:
|
||||
Dictionary with coordinates and location information
|
||||
"""
|
||||
if not address.strip():
|
||||
return {"error": "Address is required"}
|
||||
|
||||
# Use search_locations for geocoding
|
||||
results = cls.search_locations(address, limit=1)
|
||||
|
||||
if results["count"] > 0:
|
||||
return results["results"][0]
|
||||
else:
|
||||
return {"error": "Address not found"}
|
||||
|
||||
@classmethod
|
||||
def create_park_location(
|
||||
cls,
|
||||
*,
|
||||
park,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
street_address: str = "",
|
||||
city: str = "",
|
||||
state: str = "",
|
||||
country: str = "USA",
|
||||
postal_code: str = "",
|
||||
highway_exit: str = "",
|
||||
parking_notes: str = "",
|
||||
seasonal_notes: str = "",
|
||||
osm_id: Optional[int] = None,
|
||||
osm_type: str = "",
|
||||
) -> ParkLocation:
|
||||
"""
|
||||
Create a location for a park with OSM integration.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
latitude: Latitude coordinate
|
||||
longitude: Longitude coordinate
|
||||
street_address: Street address
|
||||
city: City name
|
||||
state: State/region name
|
||||
country: Country name (default: USA)
|
||||
postal_code: Postal/ZIP code
|
||||
highway_exit: Highway exit information
|
||||
parking_notes: Parking information
|
||||
seasonal_notes: Seasonal access notes
|
||||
osm_id: OpenStreetMap ID
|
||||
osm_type: OpenStreetMap type (node, way, relation)
|
||||
|
||||
Returns:
|
||||
Created ParkLocation instance
|
||||
"""
|
||||
with transaction.atomic():
|
||||
park_location = ParkLocation(
|
||||
park=park,
|
||||
street_address=street_address,
|
||||
city=city,
|
||||
state=state,
|
||||
country=country,
|
||||
postal_code=postal_code,
|
||||
highway_exit=highway_exit,
|
||||
parking_notes=parking_notes,
|
||||
seasonal_notes=seasonal_notes,
|
||||
osm_id=osm_id,
|
||||
osm_type=osm_type,
|
||||
)
|
||||
|
||||
# Set coordinates if provided
|
||||
if latitude is not None and longitude is not None:
|
||||
park_location.set_coordinates(latitude, longitude)
|
||||
|
||||
park_location.full_clean()
|
||||
park_location.save()
|
||||
|
||||
return park_location
|
||||
|
||||
@classmethod
|
||||
def update_park_location(
|
||||
cls, park_location: ParkLocation, **updates
|
||||
) -> ParkLocation:
|
||||
"""
|
||||
Update park location with validation.
|
||||
|
||||
Args:
|
||||
park_location: ParkLocation instance to update
|
||||
**updates: Fields to update
|
||||
|
||||
Returns:
|
||||
Updated ParkLocation instance
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Handle coordinates separately
|
||||
latitude = updates.pop("latitude", None)
|
||||
longitude = updates.pop("longitude", None)
|
||||
|
||||
# Update regular fields
|
||||
for field, value in updates.items():
|
||||
if hasattr(park_location, field):
|
||||
setattr(park_location, field, value)
|
||||
|
||||
# Update coordinates if provided
|
||||
if latitude is not None and longitude is not None:
|
||||
park_location.set_coordinates(latitude, longitude)
|
||||
|
||||
park_location.full_clean()
|
||||
park_location.save()
|
||||
|
||||
return park_location
|
||||
|
||||
@classmethod
|
||||
def find_nearby_parks(
|
||||
cls, latitude: float, longitude: float, radius_km: float = 50
|
||||
) -> List[ParkLocation]:
|
||||
"""
|
||||
Find parks near given coordinates using PostGIS.
|
||||
|
||||
Args:
|
||||
latitude: Center latitude
|
||||
longitude: Center longitude
|
||||
radius_km: Search radius in kilometers
|
||||
|
||||
Returns:
|
||||
List of nearby ParkLocation instances
|
||||
"""
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.measure import Distance
|
||||
|
||||
center_point = Point(longitude, latitude, srid=4326)
|
||||
|
||||
return list(
|
||||
ParkLocation.objects.filter(
|
||||
point__distance_lte=(center_point, Distance(km=radius_km))
|
||||
)
|
||||
.select_related("park", "park__operator")
|
||||
.order_by("point__distance")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def enrich_location_from_osm(cls, park_location: ParkLocation) -> ParkLocation:
|
||||
"""
|
||||
Enrich park location data using OSM reverse geocoding.
|
||||
|
||||
Args:
|
||||
park_location: ParkLocation instance to enrich
|
||||
|
||||
Returns:
|
||||
Updated ParkLocation instance
|
||||
"""
|
||||
if not park_location.point:
|
||||
return park_location
|
||||
|
||||
# Get detailed location info from OSM
|
||||
osm_data = cls.reverse_geocode(park_location.latitude, park_location.longitude)
|
||||
|
||||
if "error" not in osm_data:
|
||||
updates = {}
|
||||
|
||||
# Update missing address components
|
||||
if not park_location.street_address and osm_data.get("street_address"):
|
||||
updates["street_address"] = osm_data["street_address"]
|
||||
if not park_location.city and osm_data.get("city"):
|
||||
updates["city"] = osm_data["city"]
|
||||
if not park_location.state and osm_data.get("state"):
|
||||
updates["state"] = osm_data["state"]
|
||||
if not park_location.country and osm_data.get("country"):
|
||||
updates["country"] = osm_data["country"]
|
||||
if not park_location.postal_code and osm_data.get("postal_code"):
|
||||
updates["postal_code"] = osm_data["postal_code"]
|
||||
|
||||
# Update OSM metadata
|
||||
if osm_data.get("osm_id"):
|
||||
updates["osm_id"] = osm_data["osm_id"]
|
||||
if osm_data.get("osm_type"):
|
||||
updates["osm_type"] = osm_data["osm_type"]
|
||||
|
||||
if updates:
|
||||
return cls.update_park_location(park_location, **updates)
|
||||
|
||||
return park_location
|
||||
|
||||
@classmethod
|
||||
def _transform_osm_result(
|
||||
cls, osm_item: Dict[str, Any]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Transform OSM search result to our standard format."""
|
||||
try:
|
||||
address = osm_item.get("address", {})
|
||||
|
||||
# Extract address components
|
||||
street_number = address.get("house_number", "")
|
||||
street_name = address.get("road", "")
|
||||
street_address = f"{street_number} {street_name}".strip()
|
||||
|
||||
city = (
|
||||
address.get("city")
|
||||
or address.get("town")
|
||||
or address.get("village")
|
||||
or address.get("municipality")
|
||||
or ""
|
||||
)
|
||||
|
||||
state = (
|
||||
address.get("state")
|
||||
or address.get("province")
|
||||
or address.get("region")
|
||||
or ""
|
||||
)
|
||||
|
||||
country = address.get("country", "")
|
||||
postal_code = address.get("postcode", "")
|
||||
|
||||
# Build formatted address
|
||||
address_parts = []
|
||||
if street_address:
|
||||
address_parts.append(street_address)
|
||||
if city:
|
||||
address_parts.append(city)
|
||||
if state:
|
||||
address_parts.append(state)
|
||||
if postal_code:
|
||||
address_parts.append(postal_code)
|
||||
if country:
|
||||
address_parts.append(country)
|
||||
|
||||
formatted_address = ", ".join(address_parts)
|
||||
|
||||
# Check if this might be a theme park or entertainment venue
|
||||
place_type = osm_item.get("type", "").lower()
|
||||
extratags = osm_item.get("extratags", {})
|
||||
|
||||
is_park_related = any(
|
||||
[
|
||||
"park" in place_type,
|
||||
"theme" in place_type,
|
||||
"amusement" in place_type,
|
||||
"attraction" in place_type,
|
||||
extratags.get("tourism") == "theme_park",
|
||||
extratags.get("leisure") == "amusement_arcade",
|
||||
extratags.get("amenity") == "amusement_arcade",
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
"name": osm_item.get("display_name", ""),
|
||||
"latitude": float(osm_item["lat"]),
|
||||
"longitude": float(osm_item["lon"]),
|
||||
"formatted_address": formatted_address,
|
||||
"street_address": street_address,
|
||||
"city": city,
|
||||
"state": state,
|
||||
"country": country,
|
||||
"postal_code": postal_code,
|
||||
"osm_id": osm_item.get("osm_id"),
|
||||
"osm_type": osm_item.get("osm_type"),
|
||||
"place_type": place_type,
|
||||
"importance": osm_item.get("importance", 0),
|
||||
"is_park_related": is_park_related,
|
||||
}
|
||||
|
||||
except (KeyError, ValueError, TypeError) as e:
|
||||
logger.warning(f"Error transforming OSM result: {str(e)}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _transform_osm_reverse_result(
|
||||
cls, osm_result: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Transform OSM reverse geocoding result to our standard format."""
|
||||
address = osm_result.get("address", {})
|
||||
|
||||
# Extract address components
|
||||
street_number = address.get("house_number", "")
|
||||
street_name = address.get("road", "")
|
||||
street_address = f"{street_number} {street_name}".strip()
|
||||
|
||||
city = (
|
||||
address.get("city")
|
||||
or address.get("town")
|
||||
or address.get("village")
|
||||
or address.get("municipality")
|
||||
or ""
|
||||
)
|
||||
|
||||
state = (
|
||||
address.get("state")
|
||||
or address.get("province")
|
||||
or address.get("region")
|
||||
or ""
|
||||
)
|
||||
|
||||
country = address.get("country", "")
|
||||
postal_code = address.get("postcode", "")
|
||||
|
||||
# Build formatted address
|
||||
address_parts = []
|
||||
if street_address:
|
||||
address_parts.append(street_address)
|
||||
if city:
|
||||
address_parts.append(city)
|
||||
if state:
|
||||
address_parts.append(state)
|
||||
if postal_code:
|
||||
address_parts.append(postal_code)
|
||||
if country:
|
||||
address_parts.append(country)
|
||||
|
||||
formatted_address = ", ".join(address_parts)
|
||||
|
||||
return {
|
||||
"name": osm_result.get("display_name", ""),
|
||||
"latitude": float(osm_result["lat"]),
|
||||
"longitude": float(osm_result["lon"]),
|
||||
"formatted_address": formatted_address,
|
||||
"street_address": street_address,
|
||||
"city": city,
|
||||
"state": state,
|
||||
"country": country,
|
||||
"postal_code": postal_code,
|
||||
"osm_id": osm_result.get("osm_id"),
|
||||
"osm_type": osm_result.get("osm_type"),
|
||||
"place_type": osm_result.get("type", ""),
|
||||
}
|
||||
241
backend/apps/parks/services/media_service.py
Normal file
241
backend/apps/parks/services/media_service.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Park-specific media service for ThrillWiki.
|
||||
|
||||
This module provides media management functionality specific to parks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import transaction
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.core.services.media_service import MediaService
|
||||
from ..models import Park, ParkPhoto
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParkMediaService:
|
||||
"""Service for managing park-specific media operations."""
|
||||
|
||||
@staticmethod
|
||||
def upload_photo(
|
||||
park: Park,
|
||||
image_file: UploadedFile,
|
||||
user: User,
|
||||
caption: str = "",
|
||||
alt_text: str = "",
|
||||
is_primary: bool = False,
|
||||
auto_approve: bool = False
|
||||
) -> ParkPhoto:
|
||||
"""
|
||||
Upload a photo for a park.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
image_file: Uploaded image file
|
||||
user: User uploading the photo
|
||||
caption: Photo caption
|
||||
alt_text: Alt text for accessibility
|
||||
is_primary: Whether this should be the primary photo
|
||||
auto_approve: Whether to auto-approve the photo
|
||||
|
||||
Returns:
|
||||
Created ParkPhoto instance
|
||||
|
||||
Raises:
|
||||
ValueError: If image validation fails
|
||||
"""
|
||||
# Validate image file
|
||||
is_valid, error_message = MediaService.validate_image_file(image_file)
|
||||
if not is_valid:
|
||||
raise ValueError(error_message)
|
||||
|
||||
# Process image
|
||||
processed_image = MediaService.process_image(image_file)
|
||||
|
||||
with transaction.atomic():
|
||||
# Create photo instance
|
||||
photo = ParkPhoto(
|
||||
park=park,
|
||||
image=processed_image,
|
||||
caption=caption or MediaService.generate_default_caption(user.username),
|
||||
alt_text=alt_text,
|
||||
is_primary=is_primary,
|
||||
is_approved=auto_approve,
|
||||
uploaded_by=user
|
||||
)
|
||||
|
||||
# Extract EXIF date
|
||||
photo.date_taken = MediaService.extract_exif_date(processed_image)
|
||||
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Photo uploaded for park {park.slug} by user {user.username}")
|
||||
return photo
|
||||
|
||||
@staticmethod
|
||||
def get_park_photos(
|
||||
park: Park,
|
||||
approved_only: bool = True,
|
||||
primary_first: bool = True
|
||||
) -> List[ParkPhoto]:
|
||||
"""
|
||||
Get photos for a park.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
approved_only: Whether to only return approved photos
|
||||
primary_first: Whether to order primary photos first
|
||||
|
||||
Returns:
|
||||
List of ParkPhoto instances
|
||||
"""
|
||||
queryset = park.photos.all()
|
||||
|
||||
if approved_only:
|
||||
queryset = queryset.filter(is_approved=True)
|
||||
|
||||
if primary_first:
|
||||
queryset = queryset.order_by('-is_primary', '-created_at')
|
||||
else:
|
||||
queryset = queryset.order_by('-created_at')
|
||||
|
||||
return list(queryset)
|
||||
|
||||
@staticmethod
|
||||
def get_primary_photo(park: Park) -> Optional[ParkPhoto]:
|
||||
"""
|
||||
Get the primary photo for a park.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
|
||||
Returns:
|
||||
Primary ParkPhoto instance or None
|
||||
"""
|
||||
try:
|
||||
return park.photos.filter(is_primary=True, is_approved=True).first()
|
||||
except ParkPhoto.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def set_primary_photo(park: Park, photo: ParkPhoto) -> bool:
|
||||
"""
|
||||
Set a photo as the primary photo for a park.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
photo: ParkPhoto to set as primary
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if photo.park != park:
|
||||
return False
|
||||
|
||||
with transaction.atomic():
|
||||
# Unset current primary
|
||||
park.photos.filter(is_primary=True).update(is_primary=False)
|
||||
|
||||
# Set new primary
|
||||
photo.is_primary = True
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Set photo {photo.pk} as primary for park {park.slug}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def approve_photo(photo: ParkPhoto, approved_by: User) -> bool:
|
||||
"""
|
||||
Approve a park photo.
|
||||
|
||||
Args:
|
||||
photo: ParkPhoto to approve
|
||||
approved_by: User approving the photo
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
photo.is_approved = True
|
||||
photo.save()
|
||||
|
||||
logger.info(f"Photo {photo.pk} approved by user {approved_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to approve photo {photo.pk}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_photo(photo: ParkPhoto, deleted_by: User) -> bool:
|
||||
"""
|
||||
Delete a park photo.
|
||||
|
||||
Args:
|
||||
photo: ParkPhoto to delete
|
||||
deleted_by: User deleting the photo
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
park_slug = photo.park.slug
|
||||
photo_id = photo.pk
|
||||
|
||||
# Delete the file and database record
|
||||
if photo.image:
|
||||
photo.image.delete(save=False)
|
||||
photo.delete()
|
||||
|
||||
logger.info(
|
||||
f"Photo {photo_id} deleted from park {park_slug} by user {deleted_by.username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete photo {photo.pk}: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_photo_stats(park: Park) -> Dict[str, Any]:
|
||||
"""
|
||||
Get photo statistics for a park.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
|
||||
Returns:
|
||||
Dictionary with photo statistics
|
||||
"""
|
||||
photos = park.photos.all()
|
||||
|
||||
return {
|
||||
"total_photos": photos.count(),
|
||||
"approved_photos": photos.filter(is_approved=True).count(),
|
||||
"pending_photos": photos.filter(is_approved=False).count(),
|
||||
"has_primary": photos.filter(is_primary=True).exists(),
|
||||
"recent_uploads": photos.order_by('-created_at')[:5].count()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def bulk_approve_photos(photos: List[ParkPhoto], approved_by: User) -> int:
|
||||
"""
|
||||
Bulk approve multiple photos.
|
||||
|
||||
Args:
|
||||
photos: List of ParkPhoto instances to approve
|
||||
approved_by: User approving the photos
|
||||
|
||||
Returns:
|
||||
Number of photos successfully approved
|
||||
"""
|
||||
approved_count = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for photo in photos:
|
||||
if ParkMediaService.approve_photo(photo, approved_by):
|
||||
approved_count += 1
|
||||
|
||||
logger.info(
|
||||
f"Bulk approved {approved_count} photos by user {approved_by.username}")
|
||||
return approved_count
|
||||
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
from ..models import Park, ParkArea
|
||||
from apps.location.models import Location
|
||||
from .location_service import ParkLocationService
|
||||
|
||||
|
||||
class ParkService:
|
||||
@@ -86,7 +86,7 @@ class ParkService:
|
||||
|
||||
# Handle location if provided
|
||||
if location_data:
|
||||
LocationService.create_park_location(park=park, **location_data)
|
||||
ParkLocationService.create_park_location(park=park, **location_data)
|
||||
|
||||
return park
|
||||
|
||||
@@ -226,97 +226,3 @@ class ParkService:
|
||||
park.save()
|
||||
|
||||
return park
|
||||
|
||||
|
||||
class LocationService:
|
||||
"""Service for managing location operations."""
|
||||
|
||||
@staticmethod
|
||||
def create_park_location(
|
||||
*,
|
||||
park: Park,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
street_address: str = "",
|
||||
city: str = "",
|
||||
state: str = "",
|
||||
country: str = "",
|
||||
postal_code: str = "",
|
||||
) -> Location:
|
||||
"""
|
||||
Create a location for a park.
|
||||
|
||||
Args:
|
||||
park: Park instance
|
||||
latitude: Latitude coordinate
|
||||
longitude: Longitude coordinate
|
||||
street_address: Street address
|
||||
city: City name
|
||||
state: State/region name
|
||||
country: Country name
|
||||
postal_code: Postal/ZIP code
|
||||
|
||||
Returns:
|
||||
Created Location instance
|
||||
|
||||
Raises:
|
||||
ValidationError: If location data is invalid
|
||||
"""
|
||||
location = Location(
|
||||
content_object=park,
|
||||
name=park.name,
|
||||
location_type="park",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
street_address=street_address,
|
||||
city=city,
|
||||
state=state,
|
||||
country=country,
|
||||
postal_code=postal_code,
|
||||
)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
location.full_clean()
|
||||
location.save()
|
||||
|
||||
return location
|
||||
|
||||
@staticmethod
|
||||
def update_park_location(
|
||||
*, park_id: int, location_updates: Dict[str, Any]
|
||||
) -> Location:
|
||||
"""
|
||||
Update location information for a park.
|
||||
|
||||
Args:
|
||||
park_id: ID of the park
|
||||
location_updates: Dictionary of location field updates
|
||||
|
||||
Returns:
|
||||
Updated Location instance
|
||||
|
||||
Raises:
|
||||
Location.DoesNotExist: If location doesn't exist
|
||||
ValidationError: If location data is invalid
|
||||
"""
|
||||
with transaction.atomic():
|
||||
park = Park.objects.get(id=park_id)
|
||||
|
||||
try:
|
||||
location = park.location
|
||||
except Location.DoesNotExist:
|
||||
# Create location if it doesn't exist
|
||||
return LocationService.create_park_location(
|
||||
park=park, **location_updates
|
||||
)
|
||||
|
||||
# Apply updates
|
||||
for field, value in location_updates.items():
|
||||
if hasattr(location, field):
|
||||
setattr(location, field, value)
|
||||
|
||||
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
|
||||
location.full_clean()
|
||||
location.save()
|
||||
|
||||
return location
|
||||
|
||||
Reference in New Issue
Block a user