diff --git a/location/migrations/0004_add_point_field.py b/location/migrations/0004_add_point_field.py new file mode 100644 index 00000000..1d98f195 --- /dev/null +++ b/location/migrations/0004_add_point_field.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.2 on 2024-11-04 22:30 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("location", "0003_alter_historicallocation_city_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="location", + name="point", + field=django.contrib.gis.db.models.fields.PointField( + blank=True, + help_text="Geographic coordinates as a Point", + null=True, + srid=4326, + ), + ), + migrations.DeleteModel( + name="HistoricalLocation", + ), + ] diff --git a/location/migrations/0005_convert_coordinates_to_points.py b/location/migrations/0005_convert_coordinates_to_points.py new file mode 100644 index 00000000..0648cb35 --- /dev/null +++ b/location/migrations/0005_convert_coordinates_to_points.py @@ -0,0 +1,52 @@ +# Generated by Django 5.1.2 on 2024-11-04 22:21 + +from django.db import migrations, transaction +from django.contrib.gis.geos import Point + +def forwards_func(apps, schema_editor): + """Convert existing lat/lon coordinates to points""" + Location = apps.get_model("location", "Location") + db_alias = schema_editor.connection.alias + + # Update all locations with points based on existing lat/lon + with transaction.atomic(): + for location in Location.objects.using(db_alias).all(): + if location.latitude is not None and location.longitude is not None: + try: + location.point = Point( + float(location.longitude), # x coordinate (longitude) + float(location.latitude), # y coordinate (latitude) + srid=4326 # WGS84 coordinate system + ) + location.save(update_fields=['point']) + except (ValueError, TypeError): + print(f"Warning: Could not convert coordinates for location {location.id}") + continue + +def reverse_func(apps, schema_editor): + """Convert points back to lat/lon coordinates""" + Location = apps.get_model("location", "Location") + db_alias = schema_editor.connection.alias + + # Update all locations with lat/lon based on points + with transaction.atomic(): + for location in Location.objects.using(db_alias).all(): + if location.point: + try: + location.latitude = location.point.y + location.longitude = location.point.x + location.point = None + location.save(update_fields=['latitude', 'longitude', 'point']) + except (ValueError, TypeError, AttributeError): + print(f"Warning: Could not convert point back to coordinates for location {location.id}") + continue + +class Migration(migrations.Migration): + + dependencies = [ + ('location', '0004_add_point_field'), + ] + + operations = [ + migrations.RunPython(forwards_func, reverse_func, atomic=True), + ] diff --git a/location/migrations/0006_readd_historical_records.py b/location/migrations/0006_readd_historical_records.py new file mode 100644 index 00000000..3bc51178 --- /dev/null +++ b/location/migrations/0006_readd_historical_records.py @@ -0,0 +1,174 @@ +# Generated by Django 5.1.2 on 2024-11-04 22:32 + +import django.contrib.gis.db.models.fields +import django.core.validators +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("location", "0005_convert_coordinates_to_points"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="HistoricalLocation", + fields=[ + ( + "id", + models.BigIntegerField( + auto_created=True, blank=True, db_index=True, verbose_name="ID" + ), + ), + ("object_id", models.PositiveIntegerField()), + ( + "name", + models.CharField( + help_text="Name of the location (e.g. business name, landmark)", + max_length=255, + ), + ), + ( + "location_type", + models.CharField( + help_text="Type of location (e.g. business, landmark, address)", + max_length=50, + ), + ), + ( + "latitude", + models.DecimalField( + blank=True, + decimal_places=6, + help_text="Latitude coordinate (legacy field)", + max_digits=9, + null=True, + validators=[ + django.core.validators.MinValueValidator(-90), + django.core.validators.MaxValueValidator(90), + ], + ), + ), + ( + "longitude", + models.DecimalField( + blank=True, + decimal_places=6, + help_text="Longitude coordinate (legacy field)", + max_digits=9, + null=True, + validators=[ + django.core.validators.MinValueValidator(-180), + django.core.validators.MaxValueValidator(180), + ], + ), + ), + ( + "point", + django.contrib.gis.db.models.fields.PointField( + blank=True, + help_text="Geographic coordinates as a Point", + null=True, + srid=4326, + ), + ), + ( + "street_address", + models.CharField(blank=True, max_length=255, null=True), + ), + ("city", models.CharField(blank=True, max_length=100, null=True)), + ( + "state", + models.CharField( + blank=True, + help_text="State/Region/Province", + max_length=100, + null=True, + ), + ), + ("country", models.CharField(blank=True, max_length=100, null=True)), + ("postal_code", models.CharField(blank=True, max_length=20, null=True)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ], + options={ + "verbose_name": "historical location", + "verbose_name_plural": "historical locations", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.RemoveIndex( + model_name="location", + name="location_lo_latitud_7045c4_idx", + ), + migrations.AlterField( + model_name="location", + name="latitude", + field=models.DecimalField( + blank=True, + decimal_places=6, + help_text="Latitude coordinate (legacy field)", + max_digits=9, + null=True, + validators=[ + django.core.validators.MinValueValidator(-90), + django.core.validators.MaxValueValidator(90), + ], + ), + ), + migrations.AlterField( + model_name="location", + name="longitude", + field=models.DecimalField( + blank=True, + decimal_places=6, + help_text="Longitude coordinate (legacy field)", + max_digits=9, + null=True, + validators=[ + django.core.validators.MinValueValidator(-180), + django.core.validators.MaxValueValidator(180), + ], + ), + ), + migrations.AddField( + model_name="historicallocation", + name="content_type", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="contenttypes.contenttype", + ), + ), + migrations.AddField( + model_name="historicallocation", + name="history_user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/location/models.py b/location/models.py index 84f2136a..b758aa78 100644 --- a/location/models.py +++ b/location/models.py @@ -1,8 +1,10 @@ +from django.contrib.gis.db import models as gis_models from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.validators import MinValueValidator, MaxValueValidator from simple_history.models import HistoricalRecords +from django.contrib.gis.geos import Point class Location(models.Model): """ @@ -27,7 +29,7 @@ class Location(models.Model): MinValueValidator(-90), MaxValueValidator(90) ], - help_text="Latitude coordinate", + help_text="Latitude coordinate (legacy field)", null=True, blank=True ) @@ -38,12 +40,20 @@ class Location(models.Model): MinValueValidator(-180), MaxValueValidator(180) ], - help_text="Longitude coordinate", + help_text="Longitude coordinate (legacy field)", null=True, blank=True ) - # Address components - all made nullable + # GeoDjango point field + point = gis_models.PointField( + srid=4326, # WGS84 coordinate system + null=True, + blank=True, + help_text="Geographic coordinates as a Point" + ) + + # Address components street_address = models.CharField(max_length=255, blank=True, null=True) city = models.CharField(max_length=100, blank=True, null=True) state = models.CharField(max_length=100, blank=True, null=True, help_text="State/Region/Province") @@ -58,7 +68,6 @@ class Location(models.Model): class Meta: indexes = [ models.Index(fields=['content_type', 'object_id']), - models.Index(fields=['latitude', 'longitude']), models.Index(fields=['city']), models.Index(fields=['country']), ] @@ -73,6 +82,15 @@ class Location(models.Model): location_str = ", ".join(location_parts) if location_parts else "Unknown location" return f"{self.name} ({location_str})" + def save(self, *args, **kwargs): + # Sync point field with lat/lon fields for backward compatibility + if self.latitude is not None and self.longitude is not None and not self.point: + self.point = Point(float(self.longitude), float(self.latitude)) + elif self.point and (self.latitude is None or self.longitude is None): + self.longitude = self.point.x + self.latitude = self.point.y + super().save(*args, **kwargs) + def get_formatted_address(self): """Returns a formatted address string""" components = [] @@ -91,6 +109,29 @@ class Location(models.Model): @property def coordinates(self): """Returns coordinates as a tuple""" - if self.latitude is not None and self.longitude is not None: + if self.point: + return (self.point.y, self.point.x) # Returns (latitude, longitude) + elif self.latitude is not None and self.longitude is not None: return (float(self.latitude), float(self.longitude)) return None + + def distance_to(self, other_location): + """ + Calculate the distance to another location in meters. + Returns None if either location is missing coordinates. + """ + if not self.point or not other_location.point: + return None + return self.point.distance(other_location.point) * 100000 # Convert to meters + + def nearby_locations(self, distance_km=10): + """ + Find locations within specified distance in kilometers. + Returns a queryset of nearby Location objects. + """ + if not self.point: + return Location.objects.none() + + return Location.objects.filter( + point__distance_lte=(self.point, distance_km * 1000) # Convert km to meters + ).exclude(pk=self.pk) diff --git a/media/__pycache__/models.cpython-312.pyc b/media/__pycache__/models.cpython-312.pyc index f9ed7f9a..22ec7541 100644 Binary files a/media/__pycache__/models.cpython-312.pyc and b/media/__pycache__/models.cpython-312.pyc differ diff --git a/media/migrations/0006_photo_is_approved.py b/media/migrations/0006_photo_is_approved.py new file mode 100644 index 00000000..b04a82fd --- /dev/null +++ b/media/migrations/0006_photo_is_approved.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-11-05 03:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("media", "0005_alter_photo_image"), + ] + + operations = [ + migrations.AddField( + model_name="photo", + name="is_approved", + field=models.BooleanField(default=False), + ), + ] diff --git a/media/models.py b/media/models.py index 129e008e..86f94b3a 100644 --- a/media/models.py +++ b/media/models.py @@ -53,6 +53,7 @@ class Photo(models.Model): 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) # New field for approval status created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) uploaded_by = models.ForeignKey( diff --git a/media/views.py b/media/views.py index 79e3efab..99a7ec9a 100644 --- a/media/views.py +++ b/media/views.py @@ -75,16 +75,19 @@ def upload_photo(request): {"error": "You do not have permission to upload photos"}, status=403 ) + # Determine if the photo should be auto-approved + is_approved = request.user.is_superuser or request.user.is_staff or request.user.groups.filter(name='Moderators').exists() + # Create the photo photo = Photo.objects.create( image=request.FILES["image"], content_type=content_type, object_id=obj.id, uploaded_by=request.user, # Add the user who uploaded the photo - # Set as primary if it's the first photo is_primary=not Photo.objects.filter( content_type=content_type, object_id=obj.id ).exists(), + is_approved=is_approved # Auto-approve if the user is a moderator, admin, or superuser ) return JsonResponse( @@ -93,6 +96,7 @@ def upload_photo(request): "url": photo.image.url, "caption": photo.caption, "is_primary": photo.is_primary, + "is_approved": photo.is_approved, } ) diff --git a/parks/__pycache__/models.cpython-312.pyc b/parks/__pycache__/models.cpython-312.pyc index be7cd022..b42662cb 100644 Binary files a/parks/__pycache__/models.cpython-312.pyc and b/parks/__pycache__/models.cpython-312.pyc differ diff --git a/parks/__pycache__/views.cpython-312.pyc b/parks/__pycache__/views.cpython-312.pyc index 3013a2df..c24ba5f6 100644 Binary files a/parks/__pycache__/views.cpython-312.pyc and b/parks/__pycache__/views.cpython-312.pyc differ diff --git a/parks/forms.py b/parks/forms.py index 2885988e..869fb3b0 100644 --- a/parks/forms.py +++ b/parks/forms.py @@ -5,6 +5,64 @@ from .models import Park class ParkForm(forms.ModelForm): """Form for creating and updating Park objects with location support""" + # Location fields + latitude = forms.DecimalField( + max_digits=9, + decimal_places=6, + required=False, + widget=forms.HiddenInput() + ) + longitude = forms.DecimalField( + max_digits=10, + decimal_places=6, + required=False, + widget=forms.HiddenInput() + ) + street_address = forms.CharField( + max_length=255, + required=False, + widget=forms.TextInput( + attrs={ + "class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" + } + ) + ) + city = forms.CharField( + max_length=255, + required=False, + widget=forms.TextInput( + attrs={ + "class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" + } + ) + ) + state = forms.CharField( + max_length=255, + required=False, + widget=forms.TextInput( + attrs={ + "class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" + } + ) + ) + country = forms.CharField( + max_length=255, + required=False, + widget=forms.TextInput( + attrs={ + "class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" + } + ) + ) + postal_code = forms.CharField( + max_length=20, + required=False, + widget=forms.TextInput( + attrs={ + "class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" + } + ) + ) class Meta: model = Park @@ -18,6 +76,7 @@ class ParkForm(forms.ModelForm): "operating_season", "size_acres", "website", + # Location fields handled separately "latitude", "longitude", "street_address", @@ -79,36 +138,21 @@ class ParkForm(forms.ModelForm): "placeholder": "https://example.com", } ), - # Location fields - "latitude": forms.HiddenInput(), - "longitude": forms.HiddenInput(), - "street_address": forms.TextInput( - attrs={ - "class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" - } - ), - "city": forms.TextInput( - attrs={ - "class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" - } - ), - "state": forms.TextInput( - attrs={ - "class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" - } - ), - "country": forms.TextInput( - attrs={ - "class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" - } - ), - "postal_code": forms.TextInput( - attrs={ - "class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white" - } - ), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Pre-fill location fields if editing existing park + if self.instance and self.instance.pk and self.instance.location.exists(): + location = self.instance.location.first() + self.fields['latitude'].initial = location.latitude + self.fields['longitude'].initial = location.longitude + self.fields['street_address'].initial = location.street_address + self.fields['city'].initial = location.city + self.fields['state'].initial = location.state + self.fields['country'].initial = location.country + self.fields['postal_code'].initial = location.postal_code + def clean_latitude(self): latitude = self.cleaned_data.get('latitude') if latitude is not None: @@ -146,3 +190,27 @@ class ParkForm(forms.ModelForm): except (InvalidOperation, TypeError): raise forms.ValidationError("Invalid longitude value.") return longitude + + def save(self, commit=True): + park = super().save(commit=False) + + # Prepare location data + location_data = { + 'name': park.name, + 'location_type': 'park', + 'latitude': self.cleaned_data.get('latitude'), + 'longitude': self.cleaned_data.get('longitude'), + 'street_address': self.cleaned_data.get('street_address'), + 'city': self.cleaned_data.get('city'), + 'state': self.cleaned_data.get('state'), + 'country': self.cleaned_data.get('country'), + 'postal_code': self.cleaned_data.get('postal_code'), + } + + # Set location data to be saved with the park + park.set_location(**location_data) + + if commit: + park.save() + + return park diff --git a/parks/migrations/0006_alter_historicalpark_latitude_and_more.py b/parks/migrations/0006_alter_historicalpark_latitude_and_more.py index cbd59494..e6b76a8b 100644 --- a/parks/migrations/0006_alter_historicalpark_latitude_and_more.py +++ b/parks/migrations/0006_alter_historicalpark_latitude_and_more.py @@ -1,7 +1,6 @@ # Generated by Django 5.1.2 on 2024-11-03 19:59 import django.core.validators -import parks.models from decimal import Decimal from django.db import migrations, models @@ -25,7 +24,6 @@ class Migration(migrations.Migration): validators=[ django.core.validators.MinValueValidator(Decimal("-90")), django.core.validators.MaxValueValidator(Decimal("90")), - parks.models.validate_latitude_digits, ], ), ), @@ -41,7 +39,6 @@ class Migration(migrations.Migration): validators=[ django.core.validators.MinValueValidator(Decimal("-180")), django.core.validators.MaxValueValidator(Decimal("180")), - parks.models.validate_longitude_digits, ], ), ), @@ -57,7 +54,6 @@ class Migration(migrations.Migration): validators=[ django.core.validators.MinValueValidator(Decimal("-90")), django.core.validators.MaxValueValidator(Decimal("90")), - parks.models.validate_latitude_digits, ], ), ), @@ -73,7 +69,6 @@ class Migration(migrations.Migration): validators=[ django.core.validators.MinValueValidator(Decimal("-180")), django.core.validators.MaxValueValidator(Decimal("180")), - parks.models.validate_longitude_digits, ], ), ), diff --git a/parks/migrations/0008_historicalpark_historicalparkarea.py b/parks/migrations/0008_historicalpark_historicalparkarea.py index ccbaa5ca..61826d1a 100644 --- a/parks/migrations/0008_historicalpark_historicalparkarea.py +++ b/parks/migrations/0008_historicalpark_historicalparkarea.py @@ -3,7 +3,6 @@ import django.core.validators import django.db.models.deletion import history_tracking.mixins -import parks.models import simple_history.models from decimal import Decimal from django.conf import settings @@ -57,7 +56,6 @@ class Migration(migrations.Migration): validators=[ django.core.validators.MinValueValidator(Decimal("-90")), django.core.validators.MaxValueValidator(Decimal("90")), - parks.models.validate_latitude_digits, ], ), ), @@ -72,7 +70,6 @@ class Migration(migrations.Migration): validators=[ django.core.validators.MinValueValidator(Decimal("-180")), django.core.validators.MaxValueValidator(Decimal("180")), - parks.models.validate_longitude_digits, ], ), ), diff --git a/parks/migrations/0009_migrate_to_location_model.py b/parks/migrations/0009_migrate_to_location_model.py new file mode 100644 index 00000000..1ed68939 --- /dev/null +++ b/parks/migrations/0009_migrate_to_location_model.py @@ -0,0 +1,83 @@ +# Generated by Django 5.1.2 on 2024-11-04 22:21 + +from django.db import migrations, transaction +from django.contrib.contenttypes.models import ContentType + +def forwards_func(apps, schema_editor): + """Move park location data to Location model""" + Park = apps.get_model("parks", "Park") + Location = apps.get_model("location", "Location") + ContentType = apps.get_model("contenttypes", "ContentType") + db_alias = schema_editor.connection.alias + + # Get content type for Park model + park_content_type = ContentType.objects.db_manager(db_alias).get( + app_label='parks', + model='park' + ) + + # Move location data for each park + with transaction.atomic(): + for park in Park.objects.using(db_alias).all(): + # Only create Location if park has coordinate data + if park.latitude is not None and park.longitude is not None: + Location.objects.using(db_alias).create( + content_type=park_content_type, + object_id=park.id, + name=park.name, + location_type='park', + latitude=park.latitude, + longitude=park.longitude, + street_address=park.street_address, + city=park.city, + state=park.state, + country=park.country, + postal_code=park.postal_code + ) + +def reverse_func(apps, schema_editor): + """Move location data back to Park model""" + Park = apps.get_model("parks", "Park") + Location = apps.get_model("location", "Location") + ContentType = apps.get_model("contenttypes", "ContentType") + db_alias = schema_editor.connection.alias + + # Get content type for Park model + park_content_type = ContentType.objects.db_manager(db_alias).get( + app_label='parks', + model='park' + ) + + # Move location data back to each park + with transaction.atomic(): + locations = Location.objects.using(db_alias).filter( + content_type=park_content_type + ) + for location in locations: + try: + park = Park.objects.using(db_alias).get(id=location.object_id) + park.latitude = location.latitude + park.longitude = location.longitude + park.street_address = location.street_address + park.city = location.city + park.state = location.state + park.country = location.country + park.postal_code = location.postal_code + park.save() + except Park.DoesNotExist: + continue + + # Delete all park locations + locations.delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('parks', '0008_historicalpark_historicalparkarea'), + ('location', '0005_convert_coordinates_to_points'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.RunPython(forwards_func, reverse_func, atomic=True), + ] diff --git a/parks/migrations/0010_remove_legacy_location_fields.py b/parks/migrations/0010_remove_legacy_location_fields.py new file mode 100644 index 00000000..9e42c036 --- /dev/null +++ b/parks/migrations/0010_remove_legacy_location_fields.py @@ -0,0 +1,69 @@ +# Generated by Django 5.1.2 on 2024-11-04 22:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("parks", "0009_migrate_to_location_model"), + ] + + operations = [ + migrations.RemoveField( + model_name="historicalpark", + name="latitude", + ), + migrations.RemoveField( + model_name="historicalpark", + name="longitude", + ), + migrations.RemoveField( + model_name="historicalpark", + name="street_address", + ), + migrations.RemoveField( + model_name="historicalpark", + name="city", + ), + migrations.RemoveField( + model_name="historicalpark", + name="state", + ), + migrations.RemoveField( + model_name="historicalpark", + name="country", + ), + migrations.RemoveField( + model_name="historicalpark", + name="postal_code", + ), + migrations.RemoveField( + model_name="park", + name="latitude", + ), + migrations.RemoveField( + model_name="park", + name="longitude", + ), + migrations.RemoveField( + model_name="park", + name="street_address", + ), + migrations.RemoveField( + model_name="park", + name="city", + ), + migrations.RemoveField( + model_name="park", + name="state", + ), + migrations.RemoveField( + model_name="park", + name="country", + ), + migrations.RemoveField( + model_name="park", + name="postal_code", + ), + ] diff --git a/parks/models.py b/parks/models.py index ef4396e5..dd38e8c4 100644 --- a/parks/models.py +++ b/parks/models.py @@ -2,7 +2,6 @@ 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.validators import MinValueValidator, MaxValueValidator from django.core.exceptions import ValidationError from decimal import Decimal, ROUND_DOWN, InvalidOperation from simple_history.models import HistoricalRecords @@ -10,48 +9,7 @@ from simple_history.models import HistoricalRecords from companies.models import Company from media.models import Photo from history_tracking.models import HistoricalModel - - -def normalize_coordinate(value, max_digits, decimal_places): - """Normalize coordinate to have at most max_digits total digits and decimal_places decimal places""" - try: - if value is None: - return None - - # Convert to Decimal for precise handling - value = Decimal(str(value)) - # Round to specified decimal places - value = Decimal( - value.quantize(Decimal("0." + "0" * decimal_places), rounding=ROUND_DOWN) - ) - - return value - except (TypeError, ValueError, InvalidOperation): - return None - - -def validate_coordinate_digits(value, max_digits, decimal_places): - """Validate total number of digits in a coordinate value""" - if value is not None: - try: - # Convert to Decimal for precise handling - value = Decimal(str(value)) - # Round to exactly 6 decimal places - value = value.quantize(Decimal("0.000001"), rounding=ROUND_DOWN) - return value - except (InvalidOperation, TypeError): - raise ValidationError("Invalid coordinate value.") - return value - - -def validate_latitude_digits(value): - """Validate total number of digits in latitude""" - return validate_coordinate_digits(value, 9, 6) - - -def validate_longitude_digits(value): - """Validate total number of digits in longitude""" - return validate_coordinate_digits(value, 10, 6) +from location.models import Location class Park(HistoricalModel): @@ -71,36 +29,8 @@ class Park(HistoricalModel): max_length=20, choices=STATUS_CHOICES, default="OPERATING" ) - # Location fields - latitude = models.DecimalField( - max_digits=9, - decimal_places=6, - null=True, - blank=True, - help_text="Latitude coordinate (-90 to 90)", - validators=[ - MinValueValidator(Decimal("-90")), - MaxValueValidator(Decimal("90")), - validate_latitude_digits, - ], - ) - longitude = models.DecimalField( - max_digits=10, - decimal_places=6, - null=True, - blank=True, - help_text="Longitude coordinate (-180 to 180)", - validators=[ - MinValueValidator(Decimal("-180")), - MaxValueValidator(Decimal("180")), - validate_longitude_digits, - ], - ) - street_address = models.CharField(max_length=255, blank=True) - city = models.CharField(max_length=255, blank=True) - state = models.CharField(max_length=255, blank=True) - country = models.CharField(max_length=255, blank=True) - postal_code = models.CharField(max_length=20, blank=True) + # Location fields using GenericRelation + location = GenericRelation(Location, related_query_name='park') # Details opening_date = models.DateField(null=True, blank=True) @@ -138,13 +68,6 @@ class Park(HistoricalModel): def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) - - # Normalize coordinates before saving - if self.latitude is not None: - self.latitude = normalize_coordinate(self.latitude, 9, 6) - if self.longitude is not None: - self.longitude = normalize_coordinate(self.longitude, 10, 6) - super().save(*args, **kwargs) def get_absolute_url(self): @@ -152,14 +75,18 @@ class Park(HistoricalModel): @property def formatted_location(self): - parts = [] - if self.city: - parts.append(self.city) - if self.state: - parts.append(self.state) - if self.country: - parts.append(self.country) - return ", ".join(parts) + if self.location.exists(): + location = self.location.first() + return location.get_formatted_address() + return "" + + @property + def coordinates(self): + """Returns coordinates as a tuple (latitude, longitude)""" + if self.location.exists(): + location = self.location.first() + return location.coordinates + return None @classmethod def get_by_slug(cls, slug): diff --git a/parks/views.py b/parks/views.py index 572ca76d..987cedda 100644 --- a/parks/views.py +++ b/parks/views.py @@ -1,4 +1,4 @@ -from decimal import Decimal, ROUND_DOWN +from decimal import Decimal, ROUND_DOWN, InvalidOperation from django.views.generic import DetailView, ListView, CreateView, UpdateView from django.shortcuts import get_object_or_404, render from django.core.serializers.json import DjangoJSONEncoder @@ -16,6 +16,7 @@ from core.views import SlugRedirectMixin from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin from moderation.models import EditSubmission from media.models import Photo +from location.models import Location def location_search(request): @@ -101,7 +102,7 @@ class ParkListView(ListView): context_object_name = "parks" def get_queryset(self): - queryset = Park.objects.select_related("owner").prefetch_related("photos") + queryset = Park.objects.select_related("owner").prefetch_related("photos", "location") search = self.request.GET.get("search", "").strip() country = self.request.GET.get("country", "").strip() @@ -111,25 +112,25 @@ class ParkListView(ListView): if search: queryset = queryset.filter( - Q(name__icontains=search) - | Q(city__icontains=search) - | Q(state__icontains=search) - | Q(country__icontains=search) + Q(name__icontains=search) | + Q(location__city__icontains=search) | + Q(location__state__icontains=search) | + Q(location__country__icontains=search) ) if country: - queryset = queryset.filter(country__icontains=country) + queryset = queryset.filter(location__country__icontains=country) if region: - queryset = queryset.filter(state__icontains=region) + queryset = queryset.filter(location__state__icontains=region) if city: - queryset = queryset.filter(city__icontains=city) + queryset = queryset.filter(location__city__icontains=city) if statuses: queryset = queryset.filter(status__in=statuses) - return queryset + return queryset.distinct() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -173,7 +174,8 @@ class ParkDetailView( 'rides', 'rides__manufacturer', 'photos', - 'areas' + 'areas', + 'location' ) def get_context_data(self, **kwargs): @@ -242,6 +244,22 @@ class ParkCreateView(LoginRequiredMixin, CreateView): submission.handled_by = self.request.user submission.save() + # Create Location record + if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"): + Location.objects.create( + content_type=ContentType.objects.get_for_model(Park), + object_id=self.object.id, + name=self.object.name, + location_type='park', + latitude=form.cleaned_data["latitude"], + longitude=form.cleaned_data["longitude"], + street_address=form.cleaned_data.get("street_address", ""), + city=form.cleaned_data.get("city", ""), + state=form.cleaned_data.get("state", ""), + country=form.cleaned_data.get("country", ""), + postal_code=form.cleaned_data.get("postal_code", "") + ) + # Handle photo uploads photos = self.request.FILES.getlist("photos") for photo_file in photos: @@ -349,6 +367,31 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView): submission.handled_by = self.request.user submission.save() + # Update or create Location record + location_data = { + 'name': self.object.name, + 'location_type': 'park', + 'latitude': form.cleaned_data.get("latitude"), + 'longitude': form.cleaned_data.get("longitude"), + 'street_address': form.cleaned_data.get("street_address", ""), + 'city': form.cleaned_data.get("city", ""), + 'state': form.cleaned_data.get("state", ""), + 'country': form.cleaned_data.get("country", ""), + 'postal_code': form.cleaned_data.get("postal_code", "") + } + + if self.object.location.exists(): + location = self.object.location.first() + for key, value in location_data.items(): + setattr(location, key, value) + location.save() + else: + Location.objects.create( + content_type=ContentType.objects.get_for_model(Park), + object_id=self.object.id, + **location_data + ) + # Handle photo uploads photos = self.request.FILES.getlist("photos") uploaded_count = 0 diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 4f32e735..e886bbe3 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -2317,10 +2317,6 @@ select { margin-left: 1.5rem; } -.mr-0\.5 { - margin-right: 0.125rem; -} - .mr-1 { margin-right: 0.25rem; } @@ -3001,11 +2997,6 @@ select { padding-right: 2rem; } -.py-0\.5 { - padding-top: 0.125rem; - padding-bottom: 0.125rem; -} - .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; @@ -3881,10 +3872,6 @@ select { margin-bottom: 4rem; } - .sm\:grid-cols-1 { - grid-template-columns: repeat(1, minmax(0, 1fr)); - } - .sm\:grid-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); } @@ -3905,6 +3892,11 @@ select { margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))); } + .sm\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + .sm\:text-2xl { font-size: 1.5rem; line-height: 2rem; @@ -3925,13 +3917,18 @@ select { line-height: 1.25rem; } + .sm\:text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } + .sm\:text-xs { font-size: 0.75rem; line-height: 1rem; } - .sm\:text-xl { - font-size: 1.25rem; + .sm\:text-lg { + font-size: 1.125rem; line-height: 1.75rem; } } @@ -3974,6 +3971,16 @@ select { font-size: 3rem; line-height: 1; } + + .md\:text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } + + .md\:text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } } @media (min-width: 1024px) { @@ -4001,8 +4008,38 @@ select { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .lg\:px-8 { + padding-left: 2rem; + padding-right: 2rem; + } + + .lg\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + + .lg\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + + .lg\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + .lg\:text-6xl { font-size: 3.75rem; line-height: 1; } + + .lg\:text-base { + font-size: 1rem; + line-height: 1.5rem; + } + + .lg\:text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } } diff --git a/templates/parks/park_detail.html b/templates/parks/park_detail.html index 9f7627f9..5510e04c 100644 --- a/templates/parks/park_detail.html +++ b/templates/parks/park_detail.html @@ -4,7 +4,7 @@ {% block title %}{{ park.name }} - ThrillWiki{% endblock %} {% block extra_head %} -{% if park.latitude and park.longitude %} +{% if park.location.exists %} {% endif %} {% endblock %} @@ -19,7 +19,7 @@ }) -
+
{% if user.is_authenticated %}
@@ -37,18 +37,16 @@ {% endif %} -
+
-
-

{{ park.name }}

- +
+

{{ park.name }}

{% if park.formatted_location %} -
- -

{{ park.formatted_location }}

-
- {% endif %} - +
+ +

{{ park.formatted_location }}

+
+ {% endif %}
+
- {% if park.total_rides %} -
Total Rides
-
{{ park.total_rides }}
+
Total Rides
+
+ {{ park.total_rides|default:"N/A" }} +
- {% endif %} - {% if park.total_roller_coasters %}
-
Roller Coasters
-
{{ park.total_roller_coasters }}
+
Roller Coasters
+
+ {{ park.total_roller_coasters|default:"N/A" }} +
- {% endif %}
{% if park.owner %}
- -
Owner
+ +
Owner
+ class="text-xs text-blue-600 sm:text-sm lg:text-base hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"> {{ park.owner.name }}
@@ -105,19 +103,19 @@ {% if park.opening_date %}
- -
Opened
-
{{ park.opening_date }}
+ +
Opened
+
{{ park.opening_date }}
{% endif %} {% if park.website %} {% endif %} {% if coaster_stats.speed_mph %}
-
Speed
-
{{ coaster_stats.speed_mph }} mph
+
Speed
+
{{ coaster_stats.speed_mph }} mph
{% endif %} {% if coaster_stats.inversions %}
-
Inversions
-
{{ coaster_stats.inversions }}
+
Inversions
+
{{ coaster_stats.inversions }}
{% endif %} {% if coaster_stats.length_ft %}
-
Length
-
{{ coaster_stats.length_ft }} ft
+
Length
+
{{ coaster_stats.length_ft }} ft
+
+ {% else %} +
+
Length
+
N/A
- {% endif %} {% endif %}
@@ -92,24 +96,20 @@
{% if ride.manufacturer %} + class="text-xs text-blue-600 sm:text-sm lg:text-base hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"> {% endif %} {% if ride.designer %}
- -
Designer
+ +
Designer
+ class="text-xs text-blue-600 sm:text-sm lg:text-base hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"> {{ ride.designer.name }}
@@ -118,41 +118,41 @@ {% if coaster_stats.roller_coaster_type %}
- -
Coaster Type
-
{{ coaster_stats.get_roller_coaster_type_display }}
+ +
Coaster Type
+
{{ coaster_stats.get_roller_coaster_type_display }}
{% endif %} {% if coaster_stats.track_material %}
- -
Track Material
-
{{ coaster_stats.get_track_material_display }}
+ +
Track Material
+
{{ coaster_stats.get_track_material_display }}
{% endif %} {% if ride.opening_date %}
- -
Opened
-
{{ ride.opening_date }}
+ +
Opened
+
{{ ride.opening_date }}
{% endif %} {% if ride.capacity_per_hour %}
- -
Capacity
-
{{ ride.capacity_per_hour }}/hr
+ +
Capacity
+
{{ ride.capacity_per_hour }}/hr
{% endif %} {% if coaster_stats.launch_type %}
- -
Launch Type
-
{{ coaster_stats.get_launch_type_display }}
+ +
Launch Type
+
{{ coaster_stats.get_launch_type_display }}
{% endif %}
diff --git a/thrillwiki/__pycache__/settings.cpython-312.pyc b/thrillwiki/__pycache__/settings.cpython-312.pyc index 67b8dc4f..2da92e9b 100644 Binary files a/thrillwiki/__pycache__/settings.cpython-312.pyc and b/thrillwiki/__pycache__/settings.cpython-312.pyc differ diff --git a/thrillwiki/settings.py b/thrillwiki/settings.py index 07963d12..80e76d9f 100644 --- a/thrillwiki/settings.py +++ b/thrillwiki/settings.py @@ -24,6 +24,7 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.sites", + "django.contrib.gis", # Add GeoDjango "allauth", "allauth.account", "allauth.socialaccount", @@ -47,6 +48,7 @@ INSTALLED_APPS = [ "history_tracking", "designers", "analytics", + "location", ] MIDDLEWARE = [ @@ -90,7 +92,7 @@ WSGI_APPLICATION = "thrillwiki.wsgi.application" # Database DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql", + "ENGINE": "django.contrib.gis.db.backends.postgis", # Update to use PostGIS "NAME": "thrillwiki", "USER": "wiki", "PASSWORD": "thrillwiki",