mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:11:08 -05:00
initial geodjango implementation
This commit is contained in:
27
location/migrations/0004_add_point_field.py
Normal file
27
location/migrations/0004_add_point_field.py
Normal file
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
52
location/migrations/0005_convert_coordinates_to_points.py
Normal file
52
location/migrations/0005_convert_coordinates_to_points.py
Normal file
@@ -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),
|
||||||
|
]
|
||||||
174
location/migrations/0006_readd_historical_records.py
Normal file
174
location/migrations/0006_readd_historical_records.py
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
from django.contrib.gis.db import models as gis_models
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
from simple_history.models import HistoricalRecords
|
from simple_history.models import HistoricalRecords
|
||||||
|
from django.contrib.gis.geos import Point
|
||||||
|
|
||||||
class Location(models.Model):
|
class Location(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -27,7 +29,7 @@ class Location(models.Model):
|
|||||||
MinValueValidator(-90),
|
MinValueValidator(-90),
|
||||||
MaxValueValidator(90)
|
MaxValueValidator(90)
|
||||||
],
|
],
|
||||||
help_text="Latitude coordinate",
|
help_text="Latitude coordinate (legacy field)",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
@@ -38,12 +40,20 @@ class Location(models.Model):
|
|||||||
MinValueValidator(-180),
|
MinValueValidator(-180),
|
||||||
MaxValueValidator(180)
|
MaxValueValidator(180)
|
||||||
],
|
],
|
||||||
help_text="Longitude coordinate",
|
help_text="Longitude coordinate (legacy field)",
|
||||||
null=True,
|
null=True,
|
||||||
blank=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)
|
street_address = models.CharField(max_length=255, blank=True, null=True)
|
||||||
city = models.CharField(max_length=100, 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")
|
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:
|
class Meta:
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['content_type', 'object_id']),
|
models.Index(fields=['content_type', 'object_id']),
|
||||||
models.Index(fields=['latitude', 'longitude']),
|
|
||||||
models.Index(fields=['city']),
|
models.Index(fields=['city']),
|
||||||
models.Index(fields=['country']),
|
models.Index(fields=['country']),
|
||||||
]
|
]
|
||||||
@@ -73,6 +82,15 @@ class Location(models.Model):
|
|||||||
location_str = ", ".join(location_parts) if location_parts else "Unknown location"
|
location_str = ", ".join(location_parts) if location_parts else "Unknown location"
|
||||||
return f"{self.name} ({location_str})"
|
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):
|
def get_formatted_address(self):
|
||||||
"""Returns a formatted address string"""
|
"""Returns a formatted address string"""
|
||||||
components = []
|
components = []
|
||||||
@@ -91,6 +109,29 @@ class Location(models.Model):
|
|||||||
@property
|
@property
|
||||||
def coordinates(self):
|
def coordinates(self):
|
||||||
"""Returns coordinates as a tuple"""
|
"""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 (float(self.latitude), float(self.longitude))
|
||||||
return None
|
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)
|
||||||
|
|||||||
Binary file not shown.
18
media/migrations/0006_photo_is_approved.py
Normal file
18
media/migrations/0006_photo_is_approved.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -53,6 +53,7 @@ class Photo(models.Model):
|
|||||||
caption = models.CharField(max_length=255, blank=True)
|
caption = models.CharField(max_length=255, blank=True)
|
||||||
alt_text = models.CharField(max_length=255, blank=True)
|
alt_text = models.CharField(max_length=255, blank=True)
|
||||||
is_primary = models.BooleanField(default=False)
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
uploaded_by = models.ForeignKey(
|
uploaded_by = models.ForeignKey(
|
||||||
|
|||||||
@@ -75,16 +75,19 @@ def upload_photo(request):
|
|||||||
{"error": "You do not have permission to upload photos"}, status=403
|
{"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
|
# Create the photo
|
||||||
photo = Photo.objects.create(
|
photo = Photo.objects.create(
|
||||||
image=request.FILES["image"],
|
image=request.FILES["image"],
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
object_id=obj.id,
|
object_id=obj.id,
|
||||||
uploaded_by=request.user, # Add the user who uploaded the photo
|
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(
|
is_primary=not Photo.objects.filter(
|
||||||
content_type=content_type, object_id=obj.id
|
content_type=content_type, object_id=obj.id
|
||||||
).exists(),
|
).exists(),
|
||||||
|
is_approved=is_approved # Auto-approve if the user is a moderator, admin, or superuser
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
@@ -93,6 +96,7 @@ def upload_photo(request):
|
|||||||
"url": photo.image.url,
|
"url": photo.image.url,
|
||||||
"caption": photo.caption,
|
"caption": photo.caption,
|
||||||
"is_primary": photo.is_primary,
|
"is_primary": photo.is_primary,
|
||||||
|
"is_approved": photo.is_approved,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
124
parks/forms.py
124
parks/forms.py
@@ -5,6 +5,64 @@ from .models import Park
|
|||||||
|
|
||||||
class ParkForm(forms.ModelForm):
|
class ParkForm(forms.ModelForm):
|
||||||
"""Form for creating and updating Park objects with location support"""
|
"""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:
|
class Meta:
|
||||||
model = Park
|
model = Park
|
||||||
@@ -18,6 +76,7 @@ class ParkForm(forms.ModelForm):
|
|||||||
"operating_season",
|
"operating_season",
|
||||||
"size_acres",
|
"size_acres",
|
||||||
"website",
|
"website",
|
||||||
|
# Location fields handled separately
|
||||||
"latitude",
|
"latitude",
|
||||||
"longitude",
|
"longitude",
|
||||||
"street_address",
|
"street_address",
|
||||||
@@ -79,36 +138,21 @@ class ParkForm(forms.ModelForm):
|
|||||||
"placeholder": "https://example.com",
|
"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):
|
def clean_latitude(self):
|
||||||
latitude = self.cleaned_data.get('latitude')
|
latitude = self.cleaned_data.get('latitude')
|
||||||
if latitude is not None:
|
if latitude is not None:
|
||||||
@@ -146,3 +190,27 @@ class ParkForm(forms.ModelForm):
|
|||||||
except (InvalidOperation, TypeError):
|
except (InvalidOperation, TypeError):
|
||||||
raise forms.ValidationError("Invalid longitude value.")
|
raise forms.ValidationError("Invalid longitude value.")
|
||||||
return longitude
|
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
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Generated by Django 5.1.2 on 2024-11-03 19:59
|
# Generated by Django 5.1.2 on 2024-11-03 19:59
|
||||||
|
|
||||||
import django.core.validators
|
import django.core.validators
|
||||||
import parks.models
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ class Migration(migrations.Migration):
|
|||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(Decimal("-90")),
|
django.core.validators.MinValueValidator(Decimal("-90")),
|
||||||
django.core.validators.MaxValueValidator(Decimal("90")),
|
django.core.validators.MaxValueValidator(Decimal("90")),
|
||||||
parks.models.validate_latitude_digits,
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -41,7 +39,6 @@ class Migration(migrations.Migration):
|
|||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(Decimal("-180")),
|
django.core.validators.MinValueValidator(Decimal("-180")),
|
||||||
django.core.validators.MaxValueValidator(Decimal("180")),
|
django.core.validators.MaxValueValidator(Decimal("180")),
|
||||||
parks.models.validate_longitude_digits,
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -57,7 +54,6 @@ class Migration(migrations.Migration):
|
|||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(Decimal("-90")),
|
django.core.validators.MinValueValidator(Decimal("-90")),
|
||||||
django.core.validators.MaxValueValidator(Decimal("90")),
|
django.core.validators.MaxValueValidator(Decimal("90")),
|
||||||
parks.models.validate_latitude_digits,
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -73,7 +69,6 @@ class Migration(migrations.Migration):
|
|||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(Decimal("-180")),
|
django.core.validators.MinValueValidator(Decimal("-180")),
|
||||||
django.core.validators.MaxValueValidator(Decimal("180")),
|
django.core.validators.MaxValueValidator(Decimal("180")),
|
||||||
parks.models.validate_longitude_digits,
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import django.core.validators
|
import django.core.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import history_tracking.mixins
|
import history_tracking.mixins
|
||||||
import parks.models
|
|
||||||
import simple_history.models
|
import simple_history.models
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -57,7 +56,6 @@ class Migration(migrations.Migration):
|
|||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(Decimal("-90")),
|
django.core.validators.MinValueValidator(Decimal("-90")),
|
||||||
django.core.validators.MaxValueValidator(Decimal("90")),
|
django.core.validators.MaxValueValidator(Decimal("90")),
|
||||||
parks.models.validate_latitude_digits,
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -72,7 +70,6 @@ class Migration(migrations.Migration):
|
|||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(Decimal("-180")),
|
django.core.validators.MinValueValidator(Decimal("-180")),
|
||||||
django.core.validators.MaxValueValidator(Decimal("180")),
|
django.core.validators.MaxValueValidator(Decimal("180")),
|
||||||
parks.models.validate_longitude_digits,
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
83
parks/migrations/0009_migrate_to_location_model.py
Normal file
83
parks/migrations/0009_migrate_to_location_model.py
Normal file
@@ -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),
|
||||||
|
]
|
||||||
69
parks/migrations/0010_remove_legacy_location_fields.py
Normal file
69
parks/migrations/0010_remove_legacy_location_fields.py
Normal file
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
103
parks/models.py
103
parks/models.py
@@ -2,7 +2,6 @@ from django.db import models
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
||||||
from simple_history.models import HistoricalRecords
|
from simple_history.models import HistoricalRecords
|
||||||
@@ -10,48 +9,7 @@ from simple_history.models import HistoricalRecords
|
|||||||
from companies.models import Company
|
from companies.models import Company
|
||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
from history_tracking.models import HistoricalModel
|
from history_tracking.models import HistoricalModel
|
||||||
|
from location.models import Location
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class Park(HistoricalModel):
|
class Park(HistoricalModel):
|
||||||
@@ -71,36 +29,8 @@ class Park(HistoricalModel):
|
|||||||
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
|
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Location fields
|
# Location fields using GenericRelation
|
||||||
latitude = models.DecimalField(
|
location = GenericRelation(Location, related_query_name='park')
|
||||||
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)
|
|
||||||
|
|
||||||
# Details
|
# Details
|
||||||
opening_date = models.DateField(null=True, blank=True)
|
opening_date = models.DateField(null=True, blank=True)
|
||||||
@@ -138,13 +68,6 @@ class Park(HistoricalModel):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
@@ -152,14 +75,18 @@ class Park(HistoricalModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def formatted_location(self):
|
def formatted_location(self):
|
||||||
parts = []
|
if self.location.exists():
|
||||||
if self.city:
|
location = self.location.first()
|
||||||
parts.append(self.city)
|
return location.get_formatted_address()
|
||||||
if self.state:
|
return ""
|
||||||
parts.append(self.state)
|
|
||||||
if self.country:
|
@property
|
||||||
parts.append(self.country)
|
def coordinates(self):
|
||||||
return ", ".join(parts)
|
"""Returns coordinates as a tuple (latitude, longitude)"""
|
||||||
|
if self.location.exists():
|
||||||
|
location = self.location.first()
|
||||||
|
return location.coordinates
|
||||||
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug):
|
def get_by_slug(cls, slug):
|
||||||
|
|||||||
@@ -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.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
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.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
|
||||||
from moderation.models import EditSubmission
|
from moderation.models import EditSubmission
|
||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
|
from location.models import Location
|
||||||
|
|
||||||
|
|
||||||
def location_search(request):
|
def location_search(request):
|
||||||
@@ -101,7 +102,7 @@ class ParkListView(ListView):
|
|||||||
context_object_name = "parks"
|
context_object_name = "parks"
|
||||||
|
|
||||||
def get_queryset(self):
|
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()
|
search = self.request.GET.get("search", "").strip()
|
||||||
country = self.request.GET.get("country", "").strip()
|
country = self.request.GET.get("country", "").strip()
|
||||||
@@ -111,25 +112,25 @@ class ParkListView(ListView):
|
|||||||
|
|
||||||
if search:
|
if search:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(name__icontains=search)
|
Q(name__icontains=search) |
|
||||||
| Q(city__icontains=search)
|
Q(location__city__icontains=search) |
|
||||||
| Q(state__icontains=search)
|
Q(location__state__icontains=search) |
|
||||||
| Q(country__icontains=search)
|
Q(location__country__icontains=search)
|
||||||
)
|
)
|
||||||
|
|
||||||
if country:
|
if country:
|
||||||
queryset = queryset.filter(country__icontains=country)
|
queryset = queryset.filter(location__country__icontains=country)
|
||||||
|
|
||||||
if region:
|
if region:
|
||||||
queryset = queryset.filter(state__icontains=region)
|
queryset = queryset.filter(location__state__icontains=region)
|
||||||
|
|
||||||
if city:
|
if city:
|
||||||
queryset = queryset.filter(city__icontains=city)
|
queryset = queryset.filter(location__city__icontains=city)
|
||||||
|
|
||||||
if statuses:
|
if statuses:
|
||||||
queryset = queryset.filter(status__in=statuses)
|
queryset = queryset.filter(status__in=statuses)
|
||||||
|
|
||||||
return queryset
|
return queryset.distinct()
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
@@ -173,7 +174,8 @@ class ParkDetailView(
|
|||||||
'rides',
|
'rides',
|
||||||
'rides__manufacturer',
|
'rides__manufacturer',
|
||||||
'photos',
|
'photos',
|
||||||
'areas'
|
'areas',
|
||||||
|
'location'
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
@@ -242,6 +244,22 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
submission.handled_by = self.request.user
|
submission.handled_by = self.request.user
|
||||||
submission.save()
|
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
|
# Handle photo uploads
|
||||||
photos = self.request.FILES.getlist("photos")
|
photos = self.request.FILES.getlist("photos")
|
||||||
for photo_file in photos:
|
for photo_file in photos:
|
||||||
@@ -349,6 +367,31 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
submission.handled_by = self.request.user
|
submission.handled_by = self.request.user
|
||||||
submission.save()
|
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
|
# Handle photo uploads
|
||||||
photos = self.request.FILES.getlist("photos")
|
photos = self.request.FILES.getlist("photos")
|
||||||
uploaded_count = 0
|
uploaded_count = 0
|
||||||
|
|||||||
@@ -2317,10 +2317,6 @@ select {
|
|||||||
margin-left: 1.5rem;
|
margin-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mr-0\.5 {
|
|
||||||
margin-right: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mr-1 {
|
.mr-1 {
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
@@ -3001,11 +2997,6 @@ select {
|
|||||||
padding-right: 2rem;
|
padding-right: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.py-0\.5 {
|
|
||||||
padding-top: 0.125rem;
|
|
||||||
padding-bottom: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.py-1 {
|
.py-1 {
|
||||||
padding-top: 0.25rem;
|
padding-top: 0.25rem;
|
||||||
padding-bottom: 0.25rem;
|
padding-bottom: 0.25rem;
|
||||||
@@ -3881,10 +3872,6 @@ select {
|
|||||||
margin-bottom: 4rem;
|
margin-bottom: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sm\:grid-cols-1 {
|
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.sm\:grid-cols-12 {
|
.sm\:grid-cols-12 {
|
||||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
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)));
|
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 {
|
.sm\:text-2xl {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
line-height: 2rem;
|
line-height: 2rem;
|
||||||
@@ -3925,13 +3917,18 @@ select {
|
|||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sm\:text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sm\:text-xs {
|
.sm\:text-xs {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1rem;
|
line-height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sm\:text-xl {
|
.sm\:text-lg {
|
||||||
font-size: 1.25rem;
|
font-size: 1.125rem;
|
||||||
line-height: 1.75rem;
|
line-height: 1.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3974,6 +3971,16 @@ select {
|
|||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
line-height: 1;
|
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) {
|
@media (min-width: 1024px) {
|
||||||
@@ -4001,8 +4008,38 @@ select {
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
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 {
|
.lg\:text-6xl {
|
||||||
font-size: 3.75rem;
|
font-size: 3.75rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lg\:text-base {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg\:text-lg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{% if park.latitude and park.longitude %}
|
{% if park.location.exists %}
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container px-4 mx-auto">
|
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||||
<!-- Action Buttons - Above header -->
|
<!-- Action Buttons - Above header -->
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div class="flex justify-end gap-2 mb-2">
|
<div class="flex justify-end gap-2 mb-2">
|
||||||
@@ -37,18 +37,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Header Grid -->
|
<!-- Header Grid -->
|
||||||
<div class="grid grid-cols-1 gap-4 mb-8 sm:grid-cols-12">
|
<div class="grid grid-cols-2 gap-4 mb-8 sm:grid-cols-12">
|
||||||
<!-- Park Info Card -->
|
<!-- Park Info Card -->
|
||||||
<div class="flex flex-col items-center justify-center h-full col-span-1 p-4 text-center bg-white rounded-lg shadow-lg sm:col-span-3 dark:bg-gray-800">
|
<div class="flex flex-col items-center justify-center h-full col-span-2 p-4 text-center bg-white rounded-lg shadow-lg sm:col-span-3 dark:bg-gray-800">
|
||||||
<h1 class="text-2xl font-bold leading-tight text-gray-900 sm:text-3xl dark:text-white">{{ park.name }}</h1>
|
<h1 class="text-2xl font-bold leading-tight text-gray-900 sm:text-lg lg:text-4xl md:text-lg dark:text-white">{{ park.name }}</h1>
|
||||||
|
|
||||||
{% if park.formatted_location %}
|
{% if park.formatted_location %}
|
||||||
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<i class="mr-1 fas fa-map-marker-alt"></i>
|
<i class="mr-1 fas fa-map-marker-alt"></i>
|
||||||
<p>{{ park.formatted_location }}</p>
|
<p>{{ park.formatted_location }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
||||||
<span class="status-badge text-xs sm:text-sm font-medium py-1 px-3 {% if park.status == 'OPERATING' %}status-operating
|
<span class="status-badge text-xs sm:text-sm font-medium py-1 px-3 {% if park.status == 'OPERATING' %}status-operating
|
||||||
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
{% elif park.status == 'CLOSED_TEMP' or park.status == 'CLOSED_PERM' %}status-closed
|
||||||
@@ -67,36 +65,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats and Quick Facts -->
|
<!-- Stats and Quick Facts -->
|
||||||
<div class="grid h-full grid-cols-12 col-span-1 gap-4 sm:col-span-9">
|
<div class="grid h-full grid-cols-2 col-span-2 gap-4 sm:col-span-9">
|
||||||
<!-- Stats Column -->
|
<!-- Stats Column -->
|
||||||
<div class="grid grid-cols-2 col-span-12 gap-4 sm:col-span-4">
|
<div class="grid grid-cols-2 col-span-12 gap-4 sm:col-span-4">
|
||||||
<!-- Total Rides Card -->
|
<!-- Total Rides Card -->
|
||||||
{% if park.total_rides %}
|
|
||||||
<a href="{% url 'parks:rides:ride_list' park.slug %}"
|
<a href="{% url 'parks:rides:ride_list' park.slug %}"
|
||||||
class="flex flex-col items-center justify-center p-4 text-center transition-transform bg-white rounded-lg shadow-lg hover:scale-[1.02] dark:bg-gray-800">
|
class="flex flex-col items-center justify-center p-4 text-center transition-transform bg-white rounded-lg shadow-lg hover:scale-[1.02] dark:bg-gray-800">
|
||||||
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Total Rides</dt>
|
<dt class="text-sm font-semibold text-gray-900 sm:text-base lg:text-lg dark:text-white">Total Rides</dt>
|
||||||
<dd class="mt-2 text-xl font-bold text-sky-900 hover:text-sky-800 sm:text-2xl dark:text-sky-400 dark:hover:text-sky-300">{{ park.total_rides }}</dd>
|
<dd class="mt-2 text-xl font-bold text-sky-900 hover:text-sky-800 sm:text-2xl lg:text-3xl dark:text-sky-400 dark:hover:text-sky-300">
|
||||||
|
{{ park.total_rides|default:"N/A" }}
|
||||||
|
</dd>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Total Roller Coasters Card -->
|
<!-- Total Roller Coasters Card -->
|
||||||
{% if park.total_roller_coasters %}
|
|
||||||
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||||
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Roller Coasters</dt>
|
<dt class="text-sm font-semibold text-gray-900 sm:text-base lg:text-lg dark:text-white">Roller Coasters</dt>
|
||||||
<dd class="mt-2 text-xl font-bold text-sky-900 hover:text-sky-800 sm:text-2xl dark:text-sky-400 dark:hover:text-sky-300">{{ park.total_roller_coasters }}</dd>
|
<dd class="mt-2 text-xl font-bold text-sky-900 hover:text-sky-800 sm:text-2xl lg:text-3xl dark:text-sky-400 dark:hover:text-sky-300">
|
||||||
|
{{ park.total_roller_coasters|default:"N/A" }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Facts Grid -->
|
<!-- Quick Facts Grid -->
|
||||||
<div class="grid h-full grid-cols-3 col-span-12 gap-2 p-4 bg-white rounded-lg shadow-lg sm:col-span-8 dark:bg-gray-800">
|
<div class="grid h-full grid-cols-3 col-span-12 gap-2 p-4 bg-white rounded-lg shadow-lg sm:col-span-8 dark:bg-gray-800">
|
||||||
{% if park.owner %}
|
{% if park.owner %}
|
||||||
<div class="flex flex-col items-center justify-center p-2 text-center">
|
<div class="flex flex-col items-center justify-center p-2 text-center">
|
||||||
<i class="text-lg text-blue-600 sm:text-xl fas fa-building dark:text-blue-400"></i>
|
<i class="text-lg text-blue-600 sm:text-xl lg:text-2xl fas fa-building dark:text-blue-400"></i>
|
||||||
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Owner</dt>
|
<dt class="mt-1 text-xs font-medium text-gray-500 sm:text-sm lg:text-base dark:text-gray-400">Owner</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<a href="{% url 'companies:company_detail' park.owner.slug %}"
|
<a href="{% url 'companies:company_detail' park.owner.slug %}"
|
||||||
class="text-xs text-blue-600 sm:text-sm hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
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 }}
|
{{ park.owner.name }}
|
||||||
</a>
|
</a>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -105,19 +103,19 @@
|
|||||||
|
|
||||||
{% if park.opening_date %}
|
{% if park.opening_date %}
|
||||||
<div class="flex flex-col items-center justify-center p-2 text-center">
|
<div class="flex flex-col items-center justify-center p-2 text-center">
|
||||||
<i class="text-lg text-blue-600 sm:text-xl fas fa-calendar-alt dark:text-blue-400"></i>
|
<i class="text-lg text-blue-600 sm:text-xl lg:text-2xl fas fa-calendar-alt dark:text-blue-400"></i>
|
||||||
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Opened</dt>
|
<dt class="mt-1 text-xs font-medium text-gray-500 sm:text-sm lg:text-base dark:text-gray-400">Opened</dt>
|
||||||
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ park.opening_date }}</dd>
|
<dd class="text-xs text-gray-900 sm:text-sm lg:text-base dark:text-white">{{ park.opening_date }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if park.website %}
|
{% if park.website %}
|
||||||
<div class="flex flex-col items-center justify-center p-2 text-center">
|
<div class="flex flex-col items-center justify-center p-2 text-center">
|
||||||
<i class="text-lg text-blue-600 sm:text-xl fas fa-globe dark:text-blue-400"></i>
|
<i class="text-lg text-blue-600 sm:text-xl lg:text-2xl fas fa-globe dark:text-blue-400"></i>
|
||||||
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Website</dt>
|
<dt class="mt-1 text-xs font-medium text-gray-500 sm:text-sm lg:text-base dark:text-gray-400">Website</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<a href="{{ park.website }}"
|
<a href="{{ park.website }}"
|
||||||
class="inline-flex items-center text-xs text-blue-600 sm:text-sm hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
class="inline-flex items-center text-xs text-blue-600 sm:text-sm lg:text-base hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
target="_blank" rel="noopener noreferrer">
|
target="_blank" rel="noopener noreferrer">
|
||||||
Visit
|
Visit
|
||||||
<i class="ml-1 text-xs fas fa-external-link-alt"></i>
|
<i class="ml-1 text-xs fas fa-external-link-alt"></i>
|
||||||
@@ -186,7 +184,7 @@
|
|||||||
<!-- Right Column - Map and Additional Info -->
|
<!-- Right Column - Map and Additional Info -->
|
||||||
<div class="lg:col-span-1">
|
<div class="lg:col-span-1">
|
||||||
<!-- Location Map -->
|
<!-- Location Map -->
|
||||||
{% if park.latitude and park.longitude %}
|
{% if park.location.exists %}
|
||||||
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Location</h2>
|
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Location</h2>
|
||||||
<div id="park-map" class="relative rounded-lg" style="z-index: 0;"></div>
|
<div id="park-map" class="relative rounded-lg" style="z-index: 0;"></div>
|
||||||
@@ -253,6 +251,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@@ -260,12 +259,14 @@
|
|||||||
<script src="{% static 'js/photo-gallery.js' %}"></script>
|
<script src="{% static 'js/photo-gallery.js' %}"></script>
|
||||||
|
|
||||||
<!-- Map Script (if location exists) -->
|
<!-- Map Script (if location exists) -->
|
||||||
{% if park.latitude and park.longitude %}
|
{% if park.location.exists %}
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="{% static 'js/park-map.js' %}"></script>
|
<script src="{% static 'js/park-map.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initParkMap({{ park.latitude }}, {{ park.longitude }}, "{{ park.name }}");
|
{% with location=park.location.first %}
|
||||||
|
initParkMap({{ location.latitude }}, {{ location.longitude }}, "{{ park.name }}");
|
||||||
|
{% endwith %}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block title %}{{ ride.name }} at {{ ride.park.name }} - ThrillWiki{% endblock %}
|
{% block title %}{{ ride.name }} at {{ ride.park.name }} - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container px-4 mx-auto">
|
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
|
||||||
<!-- Action Buttons - Above header -->
|
<!-- Action Buttons - Above header -->
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div class="flex justify-end gap-2 mb-2">
|
<div class="flex justify-end gap-2 mb-2">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="grid grid-cols-1 gap-4 mb-8 sm:grid-cols-12">
|
<div class="grid grid-cols-1 gap-4 mb-8 sm:grid-cols-12">
|
||||||
<!-- Ride Info Card -->
|
<!-- Ride Info Card -->
|
||||||
<div class="flex flex-col items-center justify-center h-full col-span-1 p-4 text-center bg-white rounded-lg shadow-lg sm:col-span-3 dark:bg-gray-800">
|
<div class="flex flex-col items-center justify-center h-full col-span-1 p-4 text-center bg-white rounded-lg shadow-lg sm:col-span-3 dark:bg-gray-800">
|
||||||
<h1 class="text-2xl font-bold leading-tight text-gray-900 sm:text-3xl dark:text-white">{{ ride.name }}</h1>
|
<h1 class="text-2xl font-bold leading-tight text-gray-900 sm:text-3xl lg:text-4xl dark:text-white">{{ ride.name }}</h1>
|
||||||
|
|
||||||
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<div class="flex items-center justify-center mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
at <a href="{% url 'parks:park_detail' ride.park.slug %}" class="ml-1 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
|
at <a href="{% url 'parks:park_detail' ride.park.slug %}" class="ml-1 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
@@ -37,18 +37,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
<div class="flex flex-wrap items-center justify-center gap-2 mt-3">
|
||||||
<span class="px-3 py-1 text-xs font-medium status-badge sm:text-sm {% if ride.status == 'OPERATING' %}status-operating
|
<span class="px-3 py-1 text-xs font-medium status-badge sm:text-sm lg:text-base {% if ride.status == 'OPERATING' %}status-operating
|
||||||
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
{% elif ride.status == 'CLOSED_TEMP' or ride.status == 'CLOSED_PERM' %}status-closed
|
||||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
{% elif ride.status == 'UNDER_CONSTRUCTION' %}status-construction
|
||||||
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
{% elif ride.status == 'DEMOLISHED' %}status-demolished
|
||||||
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
|
{% elif ride.status == 'RELOCATED' %}status-relocated{% endif %}">
|
||||||
{{ ride.get_status_display }}
|
{{ ride.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
<span class="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 status-badge sm:text-sm dark:bg-blue-700 dark:text-blue-50">
|
<span class="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 status-badge sm:text-sm lg:text-base dark:bg-blue-700 dark:text-blue-50">
|
||||||
{{ ride.get_category_display }}
|
{{ ride.get_category_display }}
|
||||||
</span>
|
</span>
|
||||||
{% if ride.average_rating %}
|
{% if ride.average_rating %}
|
||||||
<span class="flex items-center px-3 py-1 text-xs font-medium text-yellow-800 bg-yellow-100 status-badge sm:text-sm dark:bg-yellow-600 dark:text-yellow-50">
|
<span class="flex items-center px-3 py-1 text-xs font-medium text-yellow-800 bg-yellow-100 status-badge sm:text-sm lg:text-base dark:bg-yellow-600 dark:text-yellow-50">
|
||||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||||
{{ ride.average_rating|floatformat:1 }}/10
|
{{ ride.average_rating|floatformat:1 }}/10
|
||||||
</span>
|
</span>
|
||||||
@@ -63,28 +63,32 @@
|
|||||||
{% if coaster_stats %}
|
{% if coaster_stats %}
|
||||||
{% if coaster_stats.height_ft %}
|
{% if coaster_stats.height_ft %}
|
||||||
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||||
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Height</dt>
|
<dt class="text-sm font-semibold text-gray-900 sm:text-base lg:text-lg dark:text-white">Height</dt>
|
||||||
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.height_ft }} ft</dd>
|
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl lg:text-3xl dark:text-sky-400">{{ coaster_stats.height_ft }} ft</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if coaster_stats.speed_mph %}
|
{% if coaster_stats.speed_mph %}
|
||||||
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||||
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Speed</dt>
|
<dt class="text-sm font-semibold text-gray-900 sm:text-base lg:text-lg dark:text-white">Speed</dt>
|
||||||
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.speed_mph }} mph</dd>
|
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl lg:text-3xl dark:text-sky-400">{{ coaster_stats.speed_mph }} mph</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if coaster_stats.inversions %}
|
{% if coaster_stats.inversions %}
|
||||||
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||||
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Inversions</dt>
|
<dt class="text-sm font-semibold text-gray-900 sm:text-base lg:text-lg dark:text-white">Inversions</dt>
|
||||||
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.inversions }}</dd>
|
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl lg:text-3xl dark:text-sky-400">{{ coaster_stats.inversions }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if coaster_stats.length_ft %}
|
{% if coaster_stats.length_ft %}
|
||||||
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||||
<dt class="text-sm font-semibold text-gray-900 sm:text-base dark:text-white">Length</dt>
|
<dt class="text-sm font-semibold text-gray-900 sm:text-base lg:text-lg dark:text-white">Length</dt>
|
||||||
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl dark:text-sky-400">{{ coaster_stats.length_ft }} ft</dd>
|
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl lg:text-3xl dark:text-sky-400">{{ coaster_stats.length_ft }} ft</dd>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex flex-col items-center justify-center p-4 text-center bg-white rounded-lg shadow-lg dark:bg-gray-800">
|
||||||
|
<dt class="text-sm font-semibold text-gray-900 sm:text-base lg:text-lg dark:text-white">Length</dt>
|
||||||
|
<dd class="mt-2 text-xl font-bold text-sky-900 sm:text-2xl lg:text-3xl dark:text-sky-400">N/A</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,24 +96,20 @@
|
|||||||
<div class="grid h-full grid-cols-3 col-span-12 gap-2 p-4 bg-white rounded-lg shadow-lg sm:col-span-8 dark:bg-gray-800">
|
<div class="grid h-full grid-cols-3 col-span-12 gap-2 p-4 bg-white rounded-lg shadow-lg sm:col-span-8 dark:bg-gray-800">
|
||||||
{% if ride.manufacturer %}
|
{% if ride.manufacturer %}
|
||||||
<div class="flex flex-col items-center justify-center p-2 text-center">
|
<div class="flex flex-col items-center justify-center p-2 text-center">
|
||||||
<i class="text-lg text-blue-600 sm:text-xl fas fa-industry dark:text-blue-400"></i>
|
<i class="text-lg text-blue-600 sm:text-xl lg:text-2xl fas fa-industry dark:text-blue-400"></i>
|
||||||
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Manufacturer</dt>
|
<dt class="mt-1 text-xs font-medium text-gray-500 sm:text-sm lg:text-base dark:text-gray-400">Manufacturer</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<a href="{% url 'companies:manufacturer_detail' ride.manufacturer.slug %}"
|
<a href="{% url 'companies:manufacturer_detail' ride.manufacturer.slug %}"
|
||||||
class="text-xs text-blue-600 sm:text-sm hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
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.manufacturer.name }}
|
|
||||||
</a>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if ride.designer %}
|
{% if ride.designer %}
|
||||||
<div class="flex flex-col items-center justify-center p-2 text-center">
|
<div class="flex flex-col items-center justify-center p-2 text-center">
|
||||||
<i class="text-lg text-blue-600 sm:text-xl fas fa-drafting-compass dark:text-blue-400"></i>
|
<i class="text-lg text-blue-600 sm:text-xl lg:text-2xl fas fa-drafting-compass dark:text-blue-400"></i>
|
||||||
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Designer</dt>
|
<dt class="mt-1 text-xs font-medium text-gray-500 sm:text-sm lg:text-base dark:text-gray-400">Designer</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<a href="{% url 'designers:designer_detail' ride.designer.slug %}"
|
<a href="{% url 'designers:designer_detail' ride.designer.slug %}"
|
||||||
class="text-xs text-blue-600 sm:text-sm hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
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 }}
|
{{ ride.designer.name }}
|
||||||
</a>
|
</a>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -118,41 +118,41 @@
|
|||||||
|
|
||||||
{% if coaster_stats.roller_coaster_type %}
|
{% if coaster_stats.roller_coaster_type %}
|
||||||
<div class="flex flex-col items-center justify-center p-2 text-center">
|
<div class="flex flex-col items-center justify-center p-2 text-center">
|
||||||
<i class="text-lg text-blue-600 sm:text-xl fas fa-train dark:text-blue-400"></i>
|
<i class="text-lg text-blue-600 sm:text-xl lg:text-2xl fas fa-train dark:text-blue-400"></i>
|
||||||
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Coaster Type</dt>
|
<dt class="mt-1 text-xs font-medium text-gray-500 sm:text-sm lg:text-base dark:text-gray-400">Coaster Type</dt>
|
||||||
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ coaster_stats.get_roller_coaster_type_display }}</dd>
|
<dd class="text-xs text-gray-900 sm:text-sm lg:text-base dark:text-white">{{ coaster_stats.get_roller_coaster_type_display }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if coaster_stats.track_material %}
|
{% if coaster_stats.track_material %}
|
||||||
<div class="flex flex-col items-center justify-center p-2 text-center">
|
<div class="flex flex-col items-center justify-center p-2 text-center">
|
||||||
<i class="text-lg text-blue-600 sm:text-xl fas fa-layer-group dark:text-blue-400"></i>
|
<i class="text-lg text-blue-600 sm:text-xl lg:text-2xl fas fa-layer-group dark:text-blue-400"></i>
|
||||||
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Track Material</dt>
|
<dt class="mt-1 text-xs font-medium text-gray-500 sm:text-sm lg:text-base dark:text-gray-400">Track Material</dt>
|
||||||
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ coaster_stats.get_track_material_display }}</dd>
|
<dd class="text-xs text-gray-900 sm:text-sm lg:text-base dark:text-white">{{ coaster_stats.get_track_material_display }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if ride.opening_date %}
|
{% if ride.opening_date %}
|
||||||
<div class="flex flex-col items-center justify-center p-2 text-center">
|
<div class="flex flex-col items-center justify-center p-2 text-center">
|
||||||
<i class="text-lg text-blue-600 sm:text-xl fas fa-calendar-alt dark:text-blue-400"></i>
|
<i class="text-lg text-blue-600 sm:text-xl lg:text-2xl fas fa-calendar-alt dark:text-blue-400"></i>
|
||||||
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Opened</dt>
|
<dt class="mt-1 text-xs font-medium text-gray-500 sm:text-sm lg:text-base dark:text-gray-400">Opened</dt>
|
||||||
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ ride.opening_date }}</dd>
|
<dd class="text-xs text-gray-900 sm:text-sm lg:text-base dark:text-white">{{ ride.opening_date }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if ride.capacity_per_hour %}
|
{% if ride.capacity_per_hour %}
|
||||||
<div class="flex flex-col items-center justify-center p-2 text-center">
|
<div class="flex flex-col items-center justify-center p-2 text-center">
|
||||||
<i class="text-lg text-blue-600 sm:text-xl fas fa-users dark:text-blue-400"></i>
|
<i class="text-lg text-blue-600 sm:text-xl lg:text-2xl fas fa-users dark:text-blue-400"></i>
|
||||||
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Capacity</dt>
|
<dt class="mt-1 text-xs font-medium text-gray-500 sm:text-sm lg:text-base dark:text-gray-400">Capacity</dt>
|
||||||
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ ride.capacity_per_hour }}/hr</dd>
|
<dd class="text-xs text-gray-900 sm:text-sm lg:text-base dark:text-white">{{ ride.capacity_per_hour }}/hr</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if coaster_stats.launch_type %}
|
{% if coaster_stats.launch_type %}
|
||||||
<div class="flex flex-col items-center justify-center p-2 text-center">
|
<div class="flex flex-col items-center justify-center p-2 text-center">
|
||||||
<i class="text-lg text-blue-600 sm:text-xl fas fa-rocket dark:text-blue-400"></i>
|
<i class="text-lg text-blue-600 sm:text-xl lg:text-2xl fas fa-rocket dark:text-blue-400"></i>
|
||||||
<dt class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">Launch Type</dt>
|
<dt class="mt-1 text-xs font-medium text-gray-500 sm:text-sm lg:text-base dark:text-gray-400">Launch Type</dt>
|
||||||
<dd class="text-xs text-gray-900 sm:text-sm dark:text-white">{{ coaster_stats.get_launch_type_display }}</dd>
|
<dd class="text-xs text-gray-900 sm:text-sm lg:text-base dark:text-white">{{ coaster_stats.get_launch_type_display }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Binary file not shown.
@@ -24,6 +24,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django.contrib.sites",
|
"django.contrib.sites",
|
||||||
|
"django.contrib.gis", # Add GeoDjango
|
||||||
"allauth",
|
"allauth",
|
||||||
"allauth.account",
|
"allauth.account",
|
||||||
"allauth.socialaccount",
|
"allauth.socialaccount",
|
||||||
@@ -47,6 +48,7 @@ INSTALLED_APPS = [
|
|||||||
"history_tracking",
|
"history_tracking",
|
||||||
"designers",
|
"designers",
|
||||||
"analytics",
|
"analytics",
|
||||||
|
"location",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -90,7 +92,7 @@ WSGI_APPLICATION = "thrillwiki.wsgi.application"
|
|||||||
# Database
|
# Database
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
"ENGINE": "django.contrib.gis.db.backends.postgis", # Update to use PostGIS
|
||||||
"NAME": "thrillwiki",
|
"NAME": "thrillwiki",
|
||||||
"USER": "wiki",
|
"USER": "wiki",
|
||||||
"PASSWORD": "thrillwiki",
|
"PASSWORD": "thrillwiki",
|
||||||
|
|||||||
Reference in New Issue
Block a user