feat: complete monorepo structure with frontend and shared resources

- Add complete backend/ directory with full Django application
- Add frontend/ directory with Vite + TypeScript setup ready for Next.js
- Add comprehensive shared/ directory with:
  - Complete documentation and memory-bank archives
  - Media files and avatars (letters, park/ride images)
  - Deployment scripts and automation tools
  - Shared types and utilities
- Add architecture/ directory with migration guides
- Configure pnpm workspace for monorepo development
- Update .gitignore to exclude .django_tailwind_cli/ build artifacts
- Preserve all historical documentation in shared/docs/memory-bank/
- Set up proper structure for full-stack development with shared resources
This commit is contained in:
pacnpal
2025-08-23 18:40:07 -04:00
parent b0e0678590
commit d504d41de2
762 changed files with 142636 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
"""
Parks app models with clean import interface.
This module provides a clean import interface for all parks-related models,
enabling imports like: from parks.models import Park, Operator
The Company model is aliased as Operator to clarify its role as park operators,
while maintaining backward compatibility through the Company alias.
"""
from .parks import Park
from .areas import ParkArea
from .location import ParkLocation
from .reviews import ParkReview
from .companies import Company, CompanyHeadquarters
# Alias Company as Operator for clarity
Operator = Company
__all__ = [
# Primary models
"Park",
"ParkArea",
"ParkLocation",
"ParkReview",
# Company models with clear naming
"Operator",
"CompanyHeadquarters",
# Backward compatibility
"Company", # Alias to Operator
]

View File

@@ -0,0 +1,33 @@
from django.db import models
from django.utils.text import slugify
import pghistory
from apps.core.history import TrackedModel
from .parks import Park
@pghistory.track()
class ParkArea(TrackedModel):
# Import managers
from ..managers import ParkAreaManager
objects = ParkAreaManager()
id: int # Type hint for Django's automatic id field
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)
opening_date = models.DateField(null=True, blank=True)
closing_date = models.DateField(null=True, blank=True)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def __str__(self):
return self.name
class Meta:
unique_together = ("park", "slug")

View File

@@ -0,0 +1,136 @@
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.utils.text import slugify
from apps.core.models import TrackedModel
import pghistory
@pghistory.track()
class Company(TrackedModel):
# Import managers
from ..managers import CompanyManager
objects = CompanyManager()
class CompanyRole(models.TextChoices):
OPERATOR = "OPERATOR", "Park Operator"
PROPERTY_OWNER = "PROPERTY_OWNER", "Property Owner"
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
roles = ArrayField(
models.CharField(max_length=20, choices=CompanyRole.choices),
default=list,
blank=True,
)
description = models.TextField(blank=True)
website = models.URLField(blank=True)
# Operator-specific fields
founded_year = models.PositiveIntegerField(blank=True, null=True)
parks_count = models.IntegerField(default=0)
rides_count = models.IntegerField(default=0)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def __str__(self):
return self.name
class Meta:
app_label = "parks"
ordering = ["name"]
verbose_name_plural = "Companies"
class CompanyHeadquarters(models.Model):
"""
Simple address storage for company headquarters without coordinate tracking.
Focus on human-readable location information for display purposes.
"""
# Relationships
company = models.OneToOneField(
"Company", on_delete=models.CASCADE, related_name="headquarters"
)
# Address Fields (No coordinates needed)
street_address = models.CharField(
max_length=255,
blank=True,
help_text="Mailing address if publicly available",
)
city = models.CharField(
max_length=100, db_index=True, help_text="Headquarters city"
)
state_province = models.CharField(
max_length=100,
blank=True,
db_index=True,
help_text="State/Province/Region",
)
country = models.CharField(
max_length=100,
default="USA",
db_index=True,
help_text="Country where headquarters is located",
)
postal_code = models.CharField(
max_length=20, blank=True, help_text="ZIP or postal code"
)
# Optional mailing address if different or more complete
mailing_address = models.TextField(
blank=True,
help_text="Complete mailing address if different from basic address",
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def formatted_location(self):
"""Returns a formatted address string for display."""
components = []
if self.street_address:
components.append(self.street_address)
if self.city:
components.append(self.city)
if self.state_province:
components.append(self.state_province)
if self.postal_code:
components.append(self.postal_code)
if self.country and self.country != "USA":
components.append(self.country)
return (
", ".join(components)
if components
else f"{
self.city}, {
self.country}"
)
@property
def location_display(self):
"""Simple city, state/country display for compact views."""
parts = [self.city]
if self.state_province:
parts.append(self.state_province)
elif self.country != "USA":
parts.append(self.country)
return ", ".join(parts) if parts else "Unknown Location"
def __str__(self):
return f"{self.company.name} Headquarters - {self.location_display}"
class Meta:
verbose_name = "Company Headquarters"
verbose_name_plural = "Company Headquarters"
ordering = ["company__name"]
indexes = [
models.Index(fields=["city", "country"]),
]

View File

@@ -0,0 +1,112 @@
from django.contrib.gis.db import models
from django.contrib.gis.geos import Point
class ParkLocation(models.Model):
"""
Represents the geographic location and address of a park, with PostGIS support.
"""
park = models.OneToOneField(
"parks.Park", on_delete=models.CASCADE, related_name="location"
)
# Spatial Data
point = models.PointField(
srid=4326,
null=True,
blank=True,
help_text="Geographic coordinates (longitude, latitude)",
)
# Address Fields
street_address = models.CharField(max_length=255, blank=True)
city = models.CharField(max_length=100, db_index=True)
state = models.CharField(max_length=100, db_index=True)
country = models.CharField(max_length=100, default="USA")
postal_code = models.CharField(max_length=20, blank=True)
# Road Trip Metadata
highway_exit = models.CharField(max_length=100, blank=True)
parking_notes = models.TextField(blank=True)
best_arrival_time = models.TimeField(null=True, blank=True)
seasonal_notes = models.TextField(blank=True)
# OSM Integration
osm_id = models.BigIntegerField(null=True, blank=True)
osm_type = models.CharField(
max_length=10,
blank=True,
help_text="Type of OpenStreetMap object (node, way, or relation)",
)
@property
def latitude(self):
"""Return latitude from point field."""
if self.point:
return self.point.y
return None
@property
def longitude(self):
"""Return longitude from point field."""
if self.point:
return self.point.x
return None
@property
def coordinates(self):
"""Return (latitude, longitude) tuple."""
if self.point:
return (self.latitude, self.longitude)
return (None, None)
@property
def formatted_address(self):
"""Return a nicely formatted address string."""
address_parts = [
self.street_address,
self.city,
self.state,
self.postal_code,
self.country,
]
return ", ".join(part for part in address_parts if part)
def set_coordinates(self, latitude, longitude):
"""
Set the location's point from latitude and longitude coordinates.
Validates coordinate ranges.
"""
if latitude is None or longitude is None:
self.point = None
return
if not -90 <= latitude <= 90:
raise ValueError("Latitude must be between -90 and 90.")
if not -180 <= longitude <= 180:
raise ValueError("Longitude must be between -180 and 180.")
self.point = Point(longitude, latitude, srid=4326)
def distance_to(self, other_location):
"""
Calculate the distance to another ParkLocation instance.
Returns distance in kilometers.
"""
if not self.point or not other_location.point:
return None
# Use geodetic distance calculation which returns meters, convert to km
distance_m = self.point.distance(other_location.point)
return distance_m / 1000.0
def __str__(self):
return f"Location for {self.park.name}"
class Meta:
verbose_name = "Park Location"
verbose_name_plural = "Park Locations"
ordering = ["park__name"]
indexes = [
models.Index(fields=["city", "state"]),
]

View File

@@ -0,0 +1,267 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from typing import Tuple, Optional, Any, TYPE_CHECKING
import pghistory
from apps.media.models import Photo
from apps.core.history import TrackedModel
if TYPE_CHECKING:
from apps.rides.models import Ride
from . import ParkArea
@pghistory.track()
class Park(TrackedModel):
# Import managers
from ..managers import ParkManager
objects = ParkManager()
id: int # Type hint for Django's automatic id field
STATUS_CHOICES = [
("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"
)
# Location relationship - reverse relation from ParkLocation
# location will be available via the 'location' related_name on
# ParkLocation
# Details
opening_date = models.DateField(null=True, blank=True)
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
)
website = models.URLField(blank=True)
# Statistics
average_rating = models.DecimalField(
max_digits=3, decimal_places=2, null=True, blank=True
)
ride_count = models.IntegerField(null=True, blank=True)
coaster_count = models.IntegerField(null=True, blank=True)
# Relationships
operator = models.ForeignKey(
"Company",
on_delete=models.PROTECT,
related_name="operated_parks",
help_text="Company that operates this park",
limit_choices_to={"roles__contains": ["OPERATOR"]},
)
property_owner = models.ForeignKey(
"Company",
on_delete=models.PROTECT,
related_name="owned_parks",
null=True,
blank=True,
help_text="Company that owns the property (if different from operator)",
limit_choices_to={"roles__contains": ["PROPERTY_OWNER"]},
)
photos = GenericRelation(Photo, related_query_name="park")
areas: models.Manager["ParkArea"] # Type hint for reverse relation
# Type hint for reverse relation from rides app
rides: models.Manager["Ride"]
# Metadata
created_at = models.DateTimeField(auto_now_add=True, null=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
constraints = [
# Business rule: Closing date must be after opening date
models.CheckConstraint(
name="park_closing_after_opening",
check=models.Q(closing_date__isnull=True)
| models.Q(opening_date__isnull=True)
| models.Q(closing_date__gte=models.F("opening_date")),
violation_error_message="Closing date must be after opening date",
),
# Business rule: Size must be positive
models.CheckConstraint(
name="park_size_positive",
check=models.Q(size_acres__isnull=True) | models.Q(size_acres__gt=0),
violation_error_message="Park size must be positive",
),
# Business rule: Rating must be between 1 and 10
models.CheckConstraint(
name="park_rating_range",
check=models.Q(average_rating__isnull=True)
| (models.Q(average_rating__gte=1) & models.Q(average_rating__lte=10)),
violation_error_message="Average rating must be between 1 and 10",
),
# Business rule: Counts must be non-negative
models.CheckConstraint(
name="park_ride_count_non_negative",
check=models.Q(ride_count__isnull=True) | models.Q(ride_count__gte=0),
violation_error_message="Ride count must be non-negative",
),
models.CheckConstraint(
name="park_coaster_count_non_negative",
check=models.Q(coaster_count__isnull=True)
| models.Q(coaster_count__gte=0),
violation_error_message="Coaster count must be non-negative",
),
# Business rule: Coaster count cannot exceed ride count
models.CheckConstraint(
name="park_coaster_count_lte_ride_count",
check=models.Q(coaster_count__isnull=True)
| models.Q(ride_count__isnull=True)
| models.Q(coaster_count__lte=models.F("ride_count")),
violation_error_message="Coaster count cannot exceed total ride count",
),
]
def __str__(self) -> str:
return self.name
def save(self, *args: Any, **kwargs: Any) -> None:
from django.contrib.contenttypes.models import ContentType
from apps.core.history import HistoricalSlug
# Get old instance if it exists
if self.pk:
try:
old_instance = type(self).objects.get(pk=self.pk)
old_name = old_instance.name
old_slug = old_instance.slug
except type(self).DoesNotExist:
old_name = None
old_slug = None
else:
old_name = None
old_slug = None
# Generate new slug if name has changed or slug is missing
if not self.slug or (old_name and old_name != self.name):
self.slug = slugify(self.name)
# Save the model
super().save(*args, **kwargs)
# If slug has changed, save historical record
if old_slug and old_slug != self.slug:
HistoricalSlug.objects.create(
content_type=ContentType.objects.get_for_model(self),
object_id=self.pk,
slug=old_slug,
)
def clean(self):
super().clean()
if self.operator and "OPERATOR" not in self.operator.roles:
raise ValidationError({"operator": "Company must have the OPERATOR role."})
if self.property_owner and "PROPERTY_OWNER" not in self.property_owner.roles:
raise ValidationError(
{"property_owner": "Company must have the PROPERTY_OWNER role."}
)
def get_absolute_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.slug})
def get_status_color(self) -> str:
"""Get Tailwind color classes for park status"""
status_colors = {
"OPERATING": "bg-green-100 text-green-800",
"CLOSED_TEMP": "bg-yellow-100 text-yellow-800",
"CLOSED_PERM": "bg-red-100 text-red-800",
"UNDER_CONSTRUCTION": "bg-blue-100 text-blue-800",
"DEMOLISHED": "bg-gray-100 text-gray-800",
"RELOCATED": "bg-purple-100 text-purple-800",
}
return status_colors.get(self.status, "bg-gray-100 text-gray-500")
@property
def formatted_location(self) -> str:
"""Get formatted address from ParkLocation if it exists"""
if hasattr(self, "location") and self.location:
return self.location.formatted_address
return ""
@property
def coordinates(self) -> Optional[Tuple[float, float]]:
"""Returns coordinates as a tuple (latitude, longitude)"""
if hasattr(self, "location") and self.location:
return self.location.coordinates
return None
@classmethod
def get_by_slug(cls, slug: str) -> Tuple["Park", bool]:
"""Get park by current or historical slug"""
from django.contrib.contenttypes.models import ContentType
from apps.core.history import HistoricalSlug
print(f"\nLooking up slug: {slug}")
try:
park = cls.objects.get(slug=slug)
print(f"Found current park with slug: {slug}")
return park, False
except cls.DoesNotExist:
print(f"No current park found with slug: {slug}")
# Try historical slugs in HistoricalSlug model
content_type = ContentType.objects.get_for_model(cls)
print(f"Searching HistoricalSlug with content_type: {content_type}")
historical = (
HistoricalSlug.objects.filter(content_type=content_type, slug=slug)
.order_by("-created_at")
.first()
)
if historical:
print(
f"Found historical slug record for object_id: {
historical.object_id}"
)
try:
park = cls.objects.get(pk=historical.object_id)
print(f"Found park from historical slug: {park.name}")
return park, True
except cls.DoesNotExist:
print("Park not found for historical slug record")
else:
print("No historical slug record found")
# Try pghistory events
print("Searching pghistory events")
event_model = getattr(cls, "event_model", None)
if event_model:
historical_event = (
event_model.objects.filter(slug=slug)
.order_by("-pgh_created_at")
.first()
)
if historical_event:
print(
f"Found pghistory event for pgh_obj_id: {
historical_event.pgh_obj_id}"
)
try:
park = cls.objects.get(pk=historical_event.pgh_obj_id)
print(f"Found park from pghistory: {park.name}")
return park, True
except cls.DoesNotExist:
print("Park not found for pghistory event")
else:
print("No pghistory event found")
raise cls.DoesNotExist("No park found with this slug")

View File

@@ -0,0 +1,77 @@
from django.db import models
from django.db.models import functions
from django.core.validators import MinValueValidator, MaxValueValidator
from apps.core.history import TrackedModel
import pghistory
@pghistory.track()
class ParkReview(TrackedModel):
# Import managers
from ..managers import ParkReviewManager
objects = ParkReviewManager()
"""
A review of a park.
"""
park = models.ForeignKey(
"parks.Park", on_delete=models.CASCADE, related_name="reviews"
)
user = models.ForeignKey(
"accounts.User", on_delete=models.CASCADE, related_name="park_reviews"
)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(10)]
)
title = models.CharField(max_length=200)
content = models.TextField()
visit_date = models.DateField()
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Moderation
is_published = models.BooleanField(default=True)
moderation_notes = models.TextField(blank=True)
moderated_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="moderated_park_reviews",
)
moderated_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
unique_together = ["park", "user"]
constraints = [
# Business rule: Rating must be between 1 and 10 (database level
# enforcement)
models.CheckConstraint(
name="park_review_rating_range",
check=models.Q(rating__gte=1) & models.Q(rating__lte=10),
violation_error_message="Rating must be between 1 and 10",
),
# Business rule: Visit date cannot be in the future
models.CheckConstraint(
name="park_review_visit_date_not_future",
check=models.Q(visit_date__lte=functions.Now()),
violation_error_message="Visit date cannot be in the future",
),
# Business rule: If moderated, must have moderator and timestamp
models.CheckConstraint(
name="park_review_moderation_consistency",
check=models.Q(moderated_by__isnull=True, moderated_at__isnull=True)
| models.Q(moderated_by__isnull=False, moderated_at__isnull=False),
violation_error_message=(
"Moderated reviews must have both moderator and moderation "
"timestamp"
),
),
]
def __str__(self):
return f"Review of {self.park.name} by {self.user.username}"