diff --git a/history_tracking/__init__.py b/history_tracking/__init__.py new file mode 100644 index 00000000..946f71a0 --- /dev/null +++ b/history_tracking/__init__.py @@ -0,0 +1,2 @@ +# history_tracking/__init__.py +default_app_config = "history_tracking.apps.HistoryTrackingConfig" diff --git a/history_tracking/admin.py b/history_tracking/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/history_tracking/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/history_tracking/apps.py b/history_tracking/apps.py new file mode 100644 index 00000000..cf058912 --- /dev/null +++ b/history_tracking/apps.py @@ -0,0 +1,20 @@ +# history_tracking/apps.py +from django.apps import AppConfig + + +class HistoryTrackingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "history_tracking" + + def ready(self): + from .mixins import HistoricalChangeMixin + from .models import Park + + models_with_history = [Park] + + for model in models_with_history: + # Check if mixin is already applied + if HistoricalChangeMixin not in model.history.model.__bases__: + model.history.model.__bases__ = ( + HistoricalChangeMixin, + ) + model.history.model.__bases__ diff --git a/history_tracking/management/__init__.py b/history_tracking/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/history_tracking/management/commands/__init__.py b/history_tracking/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/history_tracking/management/commands/initialize_history.py b/history_tracking/management/commands/initialize_history.py new file mode 100644 index 00000000..51bd636f --- /dev/null +++ b/history_tracking/management/commands/initialize_history.py @@ -0,0 +1,99 @@ +# history_tracking/management/commands/initialize_history.py +from django.core.management.base import BaseCommand +from django.utils import timezone +from django.apps import apps +from django.db.models import Model +from simple_history.models import HistoricalRecords + + +class Command(BaseCommand): + help = "Initialize history records for existing objects with historical records" + + def add_arguments(self, parser): + parser.add_argument( + "--model", + type=str, + help="Specify model in format app_name.ModelName (e.g., history_tracking.Park)", + ) + parser.add_argument( + "--all", + action="store_true", + help="Initialize history for all models with historical records", + ) + parser.add_argument( + "--force", + action="store_true", + help="Create history even if records already exist", + ) + + def initialize_model(self, model, force=False): + total = model.objects.count() + initialized = 0 + model_name = f"{model._meta.app_label}.{model._meta.model_name}" + + self.stdout.write(f"Processing {model_name}: Found {total} records") + + for obj in model.objects.all(): + try: + if force or not obj.history.exists(): + obj.history.create( + history_date=timezone.now(), + history_type="+", + history_change_reason="Initial history record", + **{ + field.name: getattr(obj, field.name) + for field in obj._meta.fields + if not isinstance(field, HistoricalRecords) + }, + ) + initialized += 1 + self.stdout.write(f"Created history for {model_name} id={obj.pk}") + except Exception as e: + self.stdout.write( + self.style.ERROR( + f"Error creating history for {model_name} id={obj.pk}: {str(e)}" + ) + ) + + return initialized, total + + def handle(self, *args, **options): + if not options["model"] and not options["all"]: + self.stdout.write( + self.style.ERROR("Please specify either --model or --all") + ) + return + + force = options["force"] + total_initialized = 0 + total_records = 0 + + if options["model"]: + try: + app_label, model_name = options["model"].split(".") + model = apps.get_model(app_label, model_name) + if hasattr(model, "history"): + initialized, total = self.initialize_model(model, force) + total_initialized += initialized + total_records += total + else: + self.stdout.write( + self.style.ERROR( + f'Model {options["model"]} does not have historical records' + ) + ) + except Exception as e: + self.stdout.write(self.style.ERROR(str(e))) + else: + # Process all models with historical records + for model in apps.get_models(): + if hasattr(model, "history"): + initialized, total = self.initialize_model(model, force) + total_initialized += initialized + total_records += total + + self.stdout.write( + self.style.SUCCESS( + f"Successfully initialized {total_initialized} of {total_records} total records" + ) + ) diff --git a/history_tracking/migrations/0001_initial.py b/history_tracking/migrations/0001_initial.py new file mode 100644 index 00000000..e8d71312 --- /dev/null +++ b/history_tracking/migrations/0001_initial.py @@ -0,0 +1,76 @@ +# Generated by Django 5.1.2 on 2024-11-03 19:59 + +import django.db.models.deletion +import history_tracking.mixins +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Park", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ], + ), + migrations.CreateModel( + name="HistoricalPark", + fields=[ + ( + "id", + models.BigIntegerField( + auto_created=True, blank=True, db_index=True, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=200)), + ("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, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical park", + "verbose_name_plural": "historical parks", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=( + history_tracking.mixins.HistoricalChangeMixin, + simple_history.models.HistoricalChanges, + models.Model, + ), + ), + ] diff --git a/history_tracking/migrations/__init__.py b/history_tracking/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/history_tracking/mixins.py b/history_tracking/mixins.py new file mode 100644 index 00000000..fd81e158 --- /dev/null +++ b/history_tracking/mixins.py @@ -0,0 +1,23 @@ +# history_tracking/mixins.py + + +class HistoricalChangeMixin: + @property + def diff_against_previous(self): + prev_record = self.prev_record + if not prev_record: + return {} + + changes = {} + for field in self.__dict__: + if field not in [ + "history_date", + "history_id", + "history_type", + "history_user_id", + ] and not field.startswith("_"): + old_value = getattr(prev_record, field) + new_value = getattr(self, field) + if old_value != new_value: + changes[field] = {"old": old_value, "new": new_value} + return changes diff --git a/history_tracking/models.py b/history_tracking/models.py new file mode 100644 index 00000000..8fc5524b --- /dev/null +++ b/history_tracking/models.py @@ -0,0 +1,17 @@ +# history_tracking/models.py +from django.db import models +from simple_history.models import HistoricalRecords +from .mixins import HistoricalChangeMixin + + +class Park(models.Model): + name = models.CharField(max_length=200) + # ... other fields ... + history = HistoricalRecords() + + @property + def _history_model(self): + return self.history.model + + +# Apply the mixin diff --git a/history_tracking/tests.py b/history_tracking/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/history_tracking/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/history_tracking/views.py b/history_tracking/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/history_tracking/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/parks/__pycache__/models.cpython-312.pyc b/parks/__pycache__/models.cpython-312.pyc index a575f4dd..e2a426ed 100644 Binary files a/parks/__pycache__/models.cpython-312.pyc and b/parks/__pycache__/models.cpython-312.pyc differ diff --git a/parks/migrations/0006_alter_historicalpark_latitude_and_more.py b/parks/migrations/0006_alter_historicalpark_latitude_and_more.py new file mode 100644 index 00000000..cbd59494 --- /dev/null +++ b/parks/migrations/0006_alter_historicalpark_latitude_and_more.py @@ -0,0 +1,80 @@ +# 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 + + +class Migration(migrations.Migration): + + dependencies = [ + ("parks", "0005_normalize_coordinates"), + ] + + operations = [ + migrations.AlterField( + model_name="historicalpark", + name="latitude", + field=models.DecimalField( + blank=True, + decimal_places=6, + help_text="Latitude coordinate (-90 to 90)", + max_digits=9, + null=True, + validators=[ + django.core.validators.MinValueValidator(Decimal("-90")), + django.core.validators.MaxValueValidator(Decimal("90")), + parks.models.validate_latitude_digits, + ], + ), + ), + migrations.AlterField( + model_name="historicalpark", + name="longitude", + field=models.DecimalField( + blank=True, + decimal_places=6, + help_text="Longitude coordinate (-180 to 180)", + max_digits=10, + null=True, + validators=[ + django.core.validators.MinValueValidator(Decimal("-180")), + django.core.validators.MaxValueValidator(Decimal("180")), + parks.models.validate_longitude_digits, + ], + ), + ), + migrations.AlterField( + model_name="park", + name="latitude", + field=models.DecimalField( + blank=True, + decimal_places=6, + help_text="Latitude coordinate (-90 to 90)", + max_digits=9, + null=True, + validators=[ + django.core.validators.MinValueValidator(Decimal("-90")), + django.core.validators.MaxValueValidator(Decimal("90")), + parks.models.validate_latitude_digits, + ], + ), + ), + migrations.AlterField( + model_name="park", + name="longitude", + field=models.DecimalField( + blank=True, + decimal_places=6, + help_text="Longitude coordinate (-180 to 180)", + max_digits=10, + null=True, + validators=[ + django.core.validators.MinValueValidator(Decimal("-180")), + django.core.validators.MaxValueValidator(Decimal("180")), + parks.models.validate_longitude_digits, + ], + ), + ), + ] diff --git a/parks/models.py b/parks/models.py index 6db7525d..b3f1f95c 100644 --- a/parks/models.py +++ b/parks/models.py @@ -16,12 +16,14 @@ def normalize_coordinate(value, max_digits, 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)) - + value = Decimal( + value.quantize(Decimal("0." + "0" * decimal_places), rounding=ROUND_DOWN) + ) + return value except (TypeError, ValueError, InvalidOperation): return None @@ -34,10 +36,10 @@ def validate_coordinate_digits(value, max_digits, decimal_places): # 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) + value = value.quantize(Decimal("0.000001"), rounding=ROUND_DOWN) return value except (InvalidOperation, TypeError): - raise ValidationError('Invalid coordinate value.') + raise ValidationError("Invalid coordinate value.") return value @@ -53,21 +55,19 @@ def validate_longitude_digits(value): class Park(models.Model): STATUS_CHOICES = [ - ('OPERATING', 'Operating'), - ('CLOSED_TEMP', 'Temporarily Closed'), - ('CLOSED_PERM', 'Permanently Closed'), - ('UNDER_CONSTRUCTION', 'Under Construction'), - ('DEMOLISHED', 'Demolished'), - ('RELOCATED', 'Relocated'), + ("OPERATING", "Operating"), + ("CLOSED_TEMP", "Temporarily Closed"), + ("CLOSED_PERM", "Permanently Closed"), + ("UNDER_CONSTRUCTION", "Under Construction"), + ("DEMOLISHED", "Demolished"), + ("RELOCATED", "Relocated"), ] name = models.CharField(max_length=255) slug = models.SlugField(max_length=255, unique=True) description = models.TextField(blank=True) status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, - default='OPERATING' + max_length=20, choices=STATUS_CHOICES, default="OPERATING" ) # Location fields @@ -76,24 +76,24 @@ class Park(models.Model): decimal_places=6, null=True, blank=True, - help_text='Latitude coordinate (-90 to 90)', + help_text="Latitude coordinate (-90 to 90)", validators=[ - MinValueValidator(Decimal('-90')), - MaxValueValidator(Decimal('90')), + 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)', + help_text="Longitude coordinate (-180 to 180)", validators=[ - MinValueValidator(Decimal('-180')), - MaxValueValidator(Decimal('180')), + 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) @@ -106,32 +106,22 @@ class Park(models.Model): closing_date = models.DateField(null=True, blank=True) operating_season = models.CharField(max_length=255, blank=True) size_acres = models.DecimalField( - max_digits=10, - decimal_places=2, - null=True, - blank=True + max_digits=10, decimal_places=2, null=True, blank=True ) website = models.URLField(blank=True) # Statistics average_rating = models.DecimalField( - max_digits=3, - decimal_places=2, - null=True, - blank=True + max_digits=3, decimal_places=2, null=True, blank=True ) total_rides = models.IntegerField(null=True, blank=True) total_roller_coasters = models.IntegerField(null=True, blank=True) # Relationships owner = models.ForeignKey( - Company, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='parks' + Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="parks" ) - photos = GenericRelation(Photo, related_query_name='park') + photos = GenericRelation(Photo, related_query_name="park") # Metadata created_at = models.DateTimeField(auto_now_add=True, null=True) @@ -139,7 +129,7 @@ class Park(models.Model): history = HistoricalRecords() class Meta: - ordering = ['name'] + ordering = ["name"] def __str__(self): return self.name @@ -147,17 +137,28 @@ class Park(models.Model): 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): - return reverse('parks:park_detail', kwargs={'slug': self.slug}) + return reverse("parks:park_detail", kwargs={"slug": self.slug}) + + @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) @classmethod def get_by_slug(cls, slug): @@ -166,7 +167,7 @@ class Park(models.Model): return cls.objects.get(slug=slug), False except cls.DoesNotExist: # Check historical slugs - history = cls.history.filter(slug=slug).order_by('-history_date').first() + history = cls.history.filter(slug=slug).order_by("-history_date").first() if history: try: return cls.objects.get(id=history.id), True @@ -176,11 +177,7 @@ class Park(models.Model): class ParkArea(models.Model): - park = models.ForeignKey( - Park, - on_delete=models.CASCADE, - related_name='areas' - ) + park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name="areas") name = models.CharField(max_length=255) slug = models.SlugField(max_length=255) description = models.TextField(blank=True) @@ -193,8 +190,8 @@ class ParkArea(models.Model): history = HistoricalRecords() class Meta: - ordering = ['name'] - unique_together = ['park', 'slug'] + ordering = ["name"] + unique_together = ["park", "slug"] def __str__(self): return f"{self.name} at {self.park.name}" @@ -205,10 +202,10 @@ class ParkArea(models.Model): super().save(*args, **kwargs) def get_absolute_url(self): - return reverse('parks:area_detail', kwargs={ - 'park_slug': self.park.slug, - 'area_slug': self.slug - }) + return reverse( + "parks:area_detail", + kwargs={"park_slug": self.park.slug, "area_slug": self.slug}, + ) @classmethod def get_by_slug(cls, slug): @@ -217,7 +214,7 @@ class ParkArea(models.Model): return cls.objects.get(slug=slug), False except cls.DoesNotExist: # Check historical slugs - history = cls.history.filter(slug=slug).order_by('-history_date').first() + history = cls.history.filter(slug=slug).order_by("-history_date").first() if history: try: return cls.objects.get(id=history.id), True diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 5cb989cd..a27ec1c5 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -2401,10 +2401,6 @@ select { height: 300px; } -.h-\[400px\] { - height: 400px; -} - .h-full { height: 100%; } @@ -2860,11 +2856,6 @@ select { background-color: rgb(202 138 4 / var(--tw-bg-opacity)); } -.bg-purple-100 { - --tw-bg-opacity: 1; - background-color: rgb(243 232 255 / var(--tw-bg-opacity)); -} - .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -3165,11 +3156,6 @@ select { color: rgb(133 77 14 / var(--tw-text-opacity)); } -.text-purple-800 { - --tw-text-opacity: 1; - color: rgb(107 33 168 / var(--tw-text-opacity)); -} - .opacity-0 { opacity: 0; } @@ -3569,11 +3555,6 @@ select { background-color: rgb(113 63 18 / var(--tw-bg-opacity)); } -.dark\:bg-purple-700:is(.dark *) { - --tw-bg-opacity: 1; - background-color: rgb(126 34 206 / var(--tw-bg-opacity)); -} - .dark\:from-gray-950:is(.dark *) { --tw-gradient-from: #030712 var(--tw-gradient-from-position); --tw-gradient-to: rgb(3 7 18 / 0) var(--tw-gradient-to-position); @@ -3689,11 +3670,6 @@ select { color: rgb(254 252 232 / var(--tw-text-opacity)); } -.dark\:text-purple-50:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(250 245 255 / var(--tw-text-opacity)); -} - .dark\:ring-1:is(.dark *) { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); @@ -3838,10 +3814,6 @@ select { margin-right: 1.5rem; } - .lg\:mt-0 { - margin-top: 0px; - } - .lg\:flex { display: flex; } diff --git a/templates/parks/park_detail.html b/templates/parks/park_detail.html index d78f0673..b9d78caf 100644 --- a/templates/parks/park_detail.html +++ b/templates/parks/park_detail.html @@ -13,14 +13,11 @@
- - {% if park.city %}{{ park.city }}{% endif %} - {% if park.city and park.state %}, {% endif %} - {% if park.state %}{{ park.state }}{% endif %} - {% if park.country and park.state or park.city %}, {% endif %} - {% if park.country %}{{ park.country }}{% endif %} -
+ {% spaceless %} ++ {% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %} +
+ {% endspaceless %} {% endif %}- {% if park.city %}{{ park.city }}{% endif %} - {% if park.city and park.state %}, {% endif %} - {% if park.state %}{{ park.state }}{% endif %} - {% if park.country and park.state or park.city %}, {% endif %} - {% if park.country %}{{ park.country }}{% endif %} + {% spaceless %} + {% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %} +
+ {% endspaceless %} {% endif %}