Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-08-26 13:19:04 -04:00
parent bf7e0c0f40
commit 831be6a2ee
151 changed files with 16260 additions and 9137 deletions

View File

@@ -13,7 +13,7 @@ from .areas import ParkArea
from .location import ParkLocation
from .reviews import ParkReview
from .companies import Company, CompanyHeadquarters
from .media import ParkPhoto
# Alias Company as Operator for clarity
Operator = Company
@@ -23,6 +23,7 @@ __all__ = [
"ParkArea",
"ParkLocation",
"ParkReview",
"ParkPhoto",
# Company models with clear naming
"Operator",
"CompanyHeadquarters",

View File

@@ -0,0 +1,122 @@
"""
Park-specific media models for ThrillWiki.
This module contains media models specific to parks domain.
"""
from typing import Any, Optional, cast
from django.db import models
from django.conf import settings
from django.utils import timezone
from apps.core.history import TrackedModel
from apps.core.services.media_service import MediaService
import pghistory
def park_photo_upload_path(instance: models.Model, filename: str) -> str:
"""Generate upload path for park photos."""
photo = cast('ParkPhoto', instance)
park = photo.park
if park is None:
raise ValueError("Park cannot be None")
return MediaService.generate_upload_path(
domain="park",
identifier=park.slug,
filename=filename
)
@pghistory.track()
class ParkPhoto(TrackedModel):
"""Photo model specific to parks."""
park = models.ForeignKey(
'parks.Park',
on_delete=models.CASCADE,
related_name='photos'
)
image = models.ImageField(
upload_to=park_photo_upload_path,
max_length=255,
)
caption = models.CharField(max_length=255, blank=True)
alt_text = models.CharField(max_length=255, blank=True)
is_primary = models.BooleanField(default=False)
is_approved = models.BooleanField(default=False)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
date_taken = models.DateTimeField(null=True, blank=True)
# User who uploaded the photo
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="uploaded_park_photos",
)
class Meta:
app_label = "parks"
ordering = ["-is_primary", "-created_at"]
indexes = [
models.Index(fields=["park", "is_primary"]),
models.Index(fields=["park", "is_approved"]),
models.Index(fields=["created_at"]),
]
constraints = [
# Only one primary photo per park
models.UniqueConstraint(
fields=['park'],
condition=models.Q(is_primary=True),
name='unique_primary_park_photo'
)
]
def __str__(self) -> str:
return f"Photo of {self.park.name} - {self.caption or 'No caption'}"
def save(self, *args: Any, **kwargs: Any) -> None:
# Extract EXIF date if this is a new photo
if not self.pk and not self.date_taken and self.image:
self.date_taken = MediaService.extract_exif_date(self.image)
# Set default caption if not provided
if not self.caption and self.uploaded_by:
self.caption = MediaService.generate_default_caption(
self.uploaded_by.username
)
# If this is marked as primary, unmark other primary photos for this park
if self.is_primary:
ParkPhoto.objects.filter(
park=self.park,
is_primary=True,
).exclude(pk=self.pk).update(is_primary=False)
super().save(*args, **kwargs)
@property
def file_size(self) -> Optional[int]:
"""Get file size in bytes."""
try:
return self.image.size
except (ValueError, OSError):
return None
@property
def dimensions(self) -> Optional[tuple]:
"""Get image dimensions as (width, height)."""
try:
return (self.image.width, self.image.height)
except (ValueError, OSError):
return None
def get_absolute_url(self) -> str:
"""Get absolute URL for this photo."""
return f"/parks/{self.park.slug}/photos/{self.pk}/"

View File

@@ -1,11 +1,9 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from typing import Tuple, Optional, Any, TYPE_CHECKING
import pghistory
from apps.media.models import Photo
from apps.core.history import TrackedModel
if TYPE_CHECKING:
@@ -74,7 +72,6 @@ class Park(TrackedModel):
help_text="Company that owns the property (if different from operator)",
limit_choices_to={"roles__contains": ["PROPERTY_OWNER"]},
)
photos = GenericRelation(Photo, related_query_name="park")
areas: models.Manager["ParkArea"] # Type hint for reverse relation
# Type hint for reverse relation from rides app
rides: models.Manager["Ride"]

View File

@@ -10,7 +10,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from .models import Park, ParkArea
from apps.location.models import Location
from .services.location_service import ParkLocationService
# Use AbstractBaseUser for type hinting
UserType = AbstractBaseUser
@@ -89,7 +89,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
@@ -227,97 +227,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

View File

@@ -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"]

View 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", ""),
}

View 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

View File

@@ -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

View File

@@ -1,7 +1,7 @@
from .querysets import get_base_park_queryset
from apps.core.mixins import HTMXFilterableMixin
from .models.location import ParkLocation
from apps.media.models import Photo
from .models.media import ParkPhoto
from apps.moderation.models import EditSubmission
from apps.moderation.mixins import (
EditSubmissionMixin,
@@ -547,12 +547,11 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
uploaded_count = 0
for photo_file in photos:
try:
Photo.objects.create(
ParkPhoto.objects.create(
image=photo_file,
uploaded_by=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
)
park=self.object,
) )
uploaded_count += 1
except Exception as e:
messages.error(
@@ -718,7 +717,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
uploaded_count = 0
for photo_file in photos:
try:
Photo.objects.create(
ParkPhoto.objects.create(
image=photo_file,
uploaded_by=self.request.user,
content_type=ContentType.objects.get_for_model(Park),