feat: Implement Entity Suggestion Manager and Modal components

- Added EntitySuggestionManager.vue to manage entity suggestions and authentication.
- Created EntitySuggestionModal.vue for displaying suggestions and adding new entities.
- Integrated AuthManager for user authentication within the suggestion modal.
- Enhanced signal handling in start-servers.sh for graceful shutdown of servers.
- Improved server startup script to ensure proper cleanup and responsiveness to termination signals.
- Added documentation for signal handling fixes and usage instructions.
This commit is contained in:
pacnpal
2025-08-25 10:46:54 -04:00
parent 937eee19e4
commit dcf890a55c
61 changed files with 10328 additions and 740 deletions

View File

@@ -26,12 +26,12 @@ class PageView(models.Model):
]
@classmethod
def get_trending_items(cls, model_class, hours=24, limit=10):
def get_trending_items(cls, model_class, hours=168, limit=10):
"""Get trending items of a specific model class based on views in last X hours.
Args:
model_class: The model class to get trending items for (e.g., Park, Ride)
hours (int): Number of hours to look back for views (default: 24)
hours (int): Number of hours to look back for views (default: 168 = 7 days)
limit (int): Maximum number of items to return (default: 10)
Returns:
@@ -61,3 +61,65 @@ class PageView(models.Model):
return model_class.objects.filter(pk__in=id_list).order_by(preserved)
return model_class.objects.none()
@classmethod
def get_views_growth(
cls, content_type, object_id, current_period_hours, previous_period_hours
):
"""Get view growth statistics between two time periods.
Args:
content_type: ContentType instance for the model
object_id: ID of the specific object
current_period_hours: Hours for current period (e.g., 24)
previous_period_hours: Hours for previous period (e.g., 48)
Returns:
tuple: (current_views, previous_views, growth_percentage)
"""
from datetime import timedelta
now = timezone.now()
# Current period: last X hours
current_start = now - timedelta(hours=current_period_hours)
current_views = cls.objects.filter(
content_type=content_type, object_id=object_id, timestamp__gte=current_start
).count()
# Previous period: X hours before current period
previous_start = now - timedelta(hours=previous_period_hours)
previous_end = current_start
previous_views = cls.objects.filter(
content_type=content_type,
object_id=object_id,
timestamp__gte=previous_start,
timestamp__lt=previous_end,
).count()
# Calculate growth percentage
if previous_views == 0:
growth_percentage = current_views * 100 if current_views > 0 else 0
else:
growth_percentage = (
(current_views - previous_views) / previous_views
) * 100
return current_views, previous_views, growth_percentage
@classmethod
def get_total_views_count(cls, content_type, object_id, hours=168):
"""Get total view count for an object within specified hours.
Args:
content_type: ContentType instance for the model
object_id: ID of the specific object
hours: Number of hours to look back (default: 168 = 7 days)
Returns:
int: Total view count
"""
cutoff = timezone.now() - timedelta(hours=hours)
return cls.objects.filter(
content_type=content_type, object_id=object_id, timestamp__gte=cutoff
).count()

View File

@@ -1 +1 @@
# Django management commands

View File

@@ -1 +1 @@
# Django management commands

View File

@@ -0,0 +1,472 @@
"""
Django management command to clear all types of cache data.
This command provides comprehensive cache clearing functionality including:
- Django cache framework (all configured backends)
- Python __pycache__ directories and .pyc files
- Static files cache
- Session cache
- Template cache
- Tailwind CSS build cache
- OPcache (if available)
"""
import shutil
import subprocess
from pathlib import Path
from django.core.cache import cache, caches
from django.core.management.base import BaseCommand
from django.conf import settings
class Command(BaseCommand):
help = (
"Clear all types of cache data including Django cache, "
"__pycache__, and build caches"
)
def add_arguments(self, parser):
parser.add_argument(
"--django-cache",
action="store_true",
help="Clear Django cache framework cache only",
)
parser.add_argument(
"--pycache",
action="store_true",
help="Clear Python __pycache__ directories and .pyc files only",
)
parser.add_argument(
"--static",
action="store_true",
help="Clear static files cache only",
)
parser.add_argument(
"--sessions",
action="store_true",
help="Clear session cache only",
)
parser.add_argument(
"--templates",
action="store_true",
help="Clear template cache only",
)
parser.add_argument(
"--tailwind",
action="store_true",
help="Clear Tailwind CSS build cache only",
)
parser.add_argument(
"--opcache",
action="store_true",
help="Clear PHP OPcache if available",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be cleared without actually clearing",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show detailed output of clearing operations",
)
def handle(self, *args, **options):
"""Clear cache data based on provided options."""
self.dry_run = options["dry_run"]
self.verbose = options["verbose"]
# If no specific cache type is specified, clear all
clear_all = not any(
[
options["django_cache"],
options["pycache"],
options["static"],
options["sessions"],
options["templates"],
options["tailwind"],
options["opcache"],
]
)
if self.dry_run:
self.stdout.write(
self.style.WARNING("🔍 DRY RUN MODE - No files will be deleted")
)
self.stdout.write("")
self.stdout.write(self.style.SUCCESS("🧹 ThrillWiki Cache Clearing Utility"))
self.stdout.write("")
# Clear Django cache framework
if clear_all or options["django_cache"]:
self.clear_django_cache()
# Clear Python __pycache__
if clear_all or options["pycache"]:
self.clear_pycache()
# Clear static files cache
if clear_all or options["static"]:
self.clear_static_cache()
# Clear sessions cache
if clear_all or options["sessions"]:
self.clear_sessions_cache()
# Clear template cache
if clear_all or options["templates"]:
self.clear_template_cache()
# Clear Tailwind cache
if clear_all or options["tailwind"]:
self.clear_tailwind_cache()
# Clear OPcache
if clear_all or options["opcache"]:
self.clear_opcache()
self.stdout.write("")
self.stdout.write(
self.style.SUCCESS("✅ Cache clearing completed successfully!")
)
def clear_django_cache(self):
"""Clear Django cache framework cache."""
self.stdout.write("🗄️ Clearing Django cache framework...")
try:
# Clear default cache
if not self.dry_run:
cache.clear()
cache_info = f"Default cache ({cache.__class__.__name__})"
self.stdout.write(self.style.SUCCESS(f" ✅ Cleared {cache_info}"))
# Clear all configured caches
cache_aliases = getattr(settings, "CACHES", {}).keys()
for alias in cache_aliases:
if alias != "default": # Already cleared above
try:
cache_backend = caches[alias]
if not self.dry_run:
cache_backend.clear()
cache_info = (
f"{alias} cache ({cache_backend.__class__.__name__})"
)
self.stdout.write(
self.style.SUCCESS(f" ✅ Cleared {cache_info}")
)
except Exception as e:
self.stdout.write(
self.style.WARNING(
f" ⚠️ Could not clear {alias} cache: {e}"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f" ❌ Error clearing Django cache: {e}")
)
def clear_pycache(self):
"""Clear Python __pycache__ directories and .pyc files."""
self.stdout.write("🐍 Clearing Python __pycache__ and .pyc files...")
removed_count = 0
removed_size = 0
try:
# Start from project root
project_root = Path(settings.BASE_DIR)
# Find and remove __pycache__ directories
for pycache_dir in project_root.rglob("__pycache__"):
if pycache_dir.is_dir():
try:
# Calculate size before removal
dir_size = sum(
f.stat().st_size
for f in pycache_dir.rglob("*")
if f.is_file()
)
removed_size += dir_size
if self.verbose:
self.stdout.write(f" 🗑️ Removing: {pycache_dir}")
if not self.dry_run:
shutil.rmtree(pycache_dir)
removed_count += 1
except Exception as e:
self.stdout.write(
self.style.WARNING(
f" ⚠️ Could not remove {pycache_dir}: {e}"
)
)
# Find and remove .pyc files
for pyc_file in project_root.rglob("*.pyc"):
try:
file_size = pyc_file.stat().st_size
removed_size += file_size
if self.verbose:
self.stdout.write(f" 🗑️ Removing: {pyc_file}")
if not self.dry_run:
pyc_file.unlink()
removed_count += 1
except Exception as e:
self.stdout.write(
self.style.WARNING(f" ⚠️ Could not remove {pyc_file}: {e}")
)
# Format file size
size_mb = removed_size / (1024 * 1024)
self.stdout.write(
self.style.SUCCESS(
f" ✅ Removed {removed_count} Python cache items ({size_mb:.2f} MB)"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f" ❌ Error clearing Python cache: {e}")
)
def clear_static_cache(self):
"""Clear static files cache."""
self.stdout.write("📦 Clearing static files cache...")
try:
static_root = getattr(settings, "STATIC_ROOT", None)
if static_root and Path(static_root).exists():
static_path = Path(static_root)
# Calculate size
total_size = sum(
f.stat().st_size for f in static_path.rglob("*") if f.is_file()
)
size_mb = total_size / (1024 * 1024)
if self.verbose:
self.stdout.write(f" 🗑️ Removing: {static_path}")
if not self.dry_run:
shutil.rmtree(static_path)
static_path.mkdir(parents=True, exist_ok=True)
self.stdout.write(
self.style.SUCCESS(
f" ✅ Cleared static files cache ({size_mb:.2f} MB)"
)
)
else:
self.stdout.write(
self.style.WARNING(
" ⚠️ No STATIC_ROOT configured or directory doesn't exist"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f" ❌ Error clearing static cache: {e}")
)
def clear_sessions_cache(self):
"""Clear session cache if using cache-based sessions."""
self.stdout.write("🔐 Clearing session cache...")
try:
session_engine = getattr(settings, "SESSION_ENGINE", "")
if "cache" in session_engine:
# Using cache-based sessions
session_cache_alias = getattr(
settings, "SESSION_CACHE_ALIAS", "default"
)
session_cache = caches[session_cache_alias]
if not self.dry_run:
# Clear session keys (this is a simplified approach)
# In production, you might want more sophisticated session clearing
session_cache.clear()
self.stdout.write(
self.style.SUCCESS(
f" ✅ Cleared cache-based sessions ({session_cache_alias})"
)
)
else:
self.stdout.write(
self.style.WARNING(" ⚠️ Not using cache-based sessions")
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f" ❌ Error clearing session cache: {e}")
)
def clear_template_cache(self):
"""Clear template cache."""
self.stdout.write("📄 Clearing template cache...")
try:
# Clear template cache if using cached template loader
from django.template import engines
from django.template.loaders.cached import Loader as CachedLoader
cleared_engines = 0
for engine in engines.all():
try:
# Check for DjangoTemplates engine with cached loaders
engine_backend = getattr(engine, "backend", "")
if "DjangoTemplates" in engine_backend:
# Get engine instance safely
engine_instance = getattr(engine, "engine", None)
if engine_instance:
template_loaders = getattr(
engine_instance, "template_loaders", []
)
for loader in template_loaders:
if isinstance(loader, CachedLoader):
if not self.dry_run:
loader.reset()
cleared_engines += 1
if self.verbose:
self.stdout.write(
f" 🗑️ Cleared cached loader: {loader}"
)
# Check for Jinja2 engines (if present)
elif "Jinja2" in engine_backend and hasattr(engine, "env"):
env = getattr(engine, "env", None)
if env and hasattr(env, "cache"):
if not self.dry_run:
env.cache.clear()
cleared_engines += 1
if self.verbose:
self.stdout.write(
f" 🗑️ Cleared Jinja2 cache: {engine}"
)
except Exception as e:
if self.verbose:
self.stdout.write(
self.style.WARNING(
f" ⚠️ Could not clear cache for engine {engine}: {e}"
)
)
if cleared_engines > 0:
self.stdout.write(
self.style.SUCCESS(
f" ✅ Cleared template cache for "
f"{cleared_engines} loaders/engines"
)
)
else:
self.stdout.write(
self.style.WARNING(" ⚠️ No cached template loaders found")
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f" ❌ Error clearing template cache: {e}")
)
def clear_tailwind_cache(self):
"""Clear Tailwind CSS build cache."""
self.stdout.write("🎨 Clearing Tailwind CSS cache...")
try:
# Look for common Tailwind cache directories
project_root = Path(settings.BASE_DIR)
cache_paths = [
project_root / "node_modules" / ".cache",
project_root / ".tailwindcss-cache",
project_root / "static" / "css" / ".cache",
]
cleared_count = 0
for cache_path in cache_paths:
if cache_path.exists():
try:
if self.verbose:
self.stdout.write(f" 🗑️ Removing: {cache_path}")
if not self.dry_run:
if cache_path.is_file():
cache_path.unlink()
else:
shutil.rmtree(cache_path)
cleared_count += 1
except Exception as e:
self.stdout.write(
self.style.WARNING(
f" ⚠️ Could not remove {cache_path}: {e}"
)
)
if cleared_count > 0:
self.stdout.write(
self.style.SUCCESS(
f" ✅ Cleared {cleared_count} Tailwind cache directories"
)
)
else:
self.stdout.write(
self.style.WARNING(" ⚠️ No Tailwind cache directories found")
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f" ❌ Error clearing Tailwind cache: {e}")
)
def clear_opcache(self):
"""Clear PHP OPcache if available."""
self.stdout.write("⚡ Clearing OPcache...")
try:
# This is mainly for mixed environments
php_code = (
"if (function_exists('opcache_reset')) { "
"opcache_reset(); echo 'cleared'; } "
"else { echo 'not_available'; }"
)
result = subprocess.run(
["php", "-r", php_code],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
if "cleared" in result.stdout:
self.stdout.write(
self.style.SUCCESS(" ✅ OPcache cleared successfully")
)
else:
self.stdout.write(self.style.WARNING(" ⚠️ OPcache not available"))
else:
self.stdout.write(
self.style.WARNING(
" ⚠️ PHP not available or OPcache not accessible"
)
)
except (subprocess.TimeoutExpired, FileNotFoundError):
self.stdout.write(
self.style.WARNING(" ⚠️ PHP not found or not accessible")
)
except Exception as e:
self.stdout.write(self.style.ERROR(f" ❌ Error clearing OPcache: {e}"))

View File

@@ -0,0 +1,309 @@
from django.core.management.base import BaseCommand
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from apps.parks.models.parks import Park
from apps.rides.models.rides import Ride
from apps.parks.models.companies import Company
from apps.core.analytics import PageView
from apps.core.services.trending_service import trending_service
from datetime import datetime, timedelta
import random
class Command(BaseCommand):
help = "Test the trending algorithm with sample data"
def add_arguments(self, parser):
parser.add_argument(
"--clean",
action="store_true",
help="Clean existing test data before creating new data",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show detailed output",
)
def handle(self, *args, **options):
self.verbose = options["verbose"]
if options["clean"]:
self.clean_test_data()
self.create_test_data()
self.test_trending_algorithm()
self.test_api_format()
self.stdout.write(
self.style.SUCCESS("✓ Trending system test completed successfully!")
)
def clean_test_data(self):
"""Clean existing test data."""
self.stdout.write("Cleaning existing test data...")
# Delete test PageViews
PageView.objects.filter(
content_type__in=[
ContentType.objects.get_for_model(Park),
ContentType.objects.get_for_model(Ride),
]
).delete()
self.stdout.write("✓ Test data cleaned")
def create_test_data(self):
"""Create sample parks, rides, and page views for testing."""
self.stdout.write("Creating test data...")
# Create or get default operator company
operator, created = Company.objects.get_or_create(
name="Default Theme Park Operator",
defaults={
"roles": ["OPERATOR"],
"description": "Default operator for test parks",
},
)
if created and self.verbose:
self.stdout.write(f" Created operator company: {operator.name}")
# Get or create test parks and rides
parks_data = [
{
"name": "Cedar Point",
"slug": "cedar-point",
"description": "America's Roller Coast featuring world-class roller coasters",
"average_rating": 9.2,
"opening_date": datetime(1870, 1, 1).date(),
"operator": operator,
},
{
"name": "Magic Kingdom",
"slug": "magic-kingdom",
"description": "Walt Disney World's most magical theme park",
"average_rating": 9.5,
"opening_date": datetime(1971, 10, 1).date(),
"operator": operator,
},
{
"name": "Six Flags Great Adventure",
"slug": "six-flags-great-adventure",
"description": "Home to Kingda Ka and incredible thrills",
"average_rating": 8.8,
"opening_date": datetime(1974, 7, 1).date(),
"operator": operator,
},
]
# Create parks
parks = []
for park_data in parks_data:
park, created = Park.objects.get_or_create(
name=park_data["name"], defaults=park_data
)
parks.append(park)
if created and self.verbose:
self.stdout.write(f" Created park: {park.name}")
# Now create rides - they need park references
rides_data = [
{
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"description": "Hybrid roller coaster at Cedar Point",
"park": next(p for p in parks if p.name == "Cedar Point"),
"category": "RC", # Roller Coaster
"average_rating": 9.8,
"opening_date": datetime(2018, 5, 5).date(),
},
{
"name": "Space Mountain",
"slug": "space-mountain",
"description": "Indoor space-themed roller coaster",
"park": next(p for p in parks if p.name == "Magic Kingdom"),
"category": "RC", # Roller Coaster
"average_rating": 8.5,
"opening_date": datetime(1975, 1, 15).date(),
},
{
"name": "Kingda Ka",
"slug": "kingda-ka",
"description": "World's tallest roller coaster",
"park": next(p for p in parks if p.name == "Six Flags Great Adventure"),
"category": "RC", # Roller Coaster
"average_rating": 9.0,
"opening_date": datetime(2005, 5, 21).date(),
},
{
"name": "Millennium Force",
"slug": "millennium-force",
"description": "Legendary steel roller coaster",
"park": next(p for p in parks if p.name == "Cedar Point"),
"category": "RC", # Roller Coaster
"average_rating": 9.4,
"opening_date": datetime(2000, 5, 13).date(),
},
]
# Create rides
rides = []
for ride_data in rides_data:
ride, created = Ride.objects.get_or_create(
name=ride_data["name"], defaults=ride_data
)
rides.append(ride)
if created and self.verbose:
self.stdout.write(f" Created ride: {ride.name}")
# Create PageViews with different patterns to test trending
self.create_page_views(parks, rides)
self.stdout.write("✓ Test data created")
def create_page_views(self, parks, rides):
"""Create PageViews with different trending patterns."""
now = timezone.now()
# Pattern 1: Recently trending item (Steel Vengeance)
steel_vengeance = next(r for r in rides if r.name == "Steel Vengeance")
self.create_views_for_content(
steel_vengeance, recent_views=50, older_views=10, base_time=now
)
# Pattern 2: Consistently popular item (Space Mountain)
space_mountain = next(r for r in rides if r.name == "Space Mountain")
self.create_views_for_content(
space_mountain, recent_views=30, older_views=25, base_time=now
)
# Pattern 3: Declining popularity (Kingda Ka)
kingda_ka = next(r for r in rides if r.name == "Kingda Ka")
self.create_views_for_content(
kingda_ka, recent_views=5, older_views=40, base_time=now
)
# Pattern 4: New but growing (Millennium Force)
millennium_force = next(r for r in rides if r.name == "Millennium Force")
self.create_views_for_content(
millennium_force, recent_views=25, older_views=5, base_time=now
)
# Create some park views too
cedar_point = next(p for p in parks if p.name == "Cedar Point")
self.create_views_for_content(
cedar_point, recent_views=35, older_views=20, base_time=now
)
if self.verbose:
self.stdout.write(" Created PageView data for trending analysis")
def create_views_for_content(
self, content_object, recent_views, older_views, base_time
):
"""Create PageViews for a content object with specified patterns."""
content_type = ContentType.objects.get_for_model(type(content_object))
# Create recent views (last 2 hours)
for i in range(recent_views):
view_time = base_time - timedelta(
minutes=random.randint(0, 120) # Last 2 hours
)
PageView.objects.create(
content_type=content_type,
object_id=content_object.id,
ip_address=f"192.168.1.{random.randint(1, 255)}",
user_agent="Test Agent",
timestamp=view_time,
)
# Create older views (2-24 hours ago)
for i in range(older_views):
view_time = base_time - timedelta(hours=random.randint(2, 24))
PageView.objects.create(
content_type=content_type,
object_id=content_object.id,
ip_address=f"10.0.0.{random.randint(1, 255)}",
user_agent="Test Agent",
timestamp=view_time,
)
def test_trending_algorithm(self):
"""Test the trending algorithm functionality."""
self.stdout.write("Testing trending algorithm...")
# Test trending content for different content types
trending_parks = trending_service.get_trending_content(
content_type="parks", limit=3
)
trending_rides = trending_service.get_trending_content(
content_type="rides", limit=3
)
trending_all = trending_service.get_trending_content(
content_type="all", limit=5
)
# Test new content
new_parks = trending_service.get_new_content(content_type="parks", limit=3)
new_rides = trending_service.get_new_content(content_type="rides", limit=3)
new_all = trending_service.get_new_content(content_type="all", limit=5)
if self.verbose:
self.stdout.write(f" Trending parks: {len(trending_parks)} results")
self.stdout.write(f" Trending rides: {len(trending_rides)} results")
self.stdout.write(f" Trending all: {len(trending_all)} results")
self.stdout.write(f" New parks: {len(new_parks)} results")
self.stdout.write(f" New rides: {len(new_rides)} results")
self.stdout.write(f" New all: {len(new_all)} results")
self.stdout.write("✓ Trending algorithm working correctly")
def test_api_format(self):
"""Test that API responses match expected frontend format."""
self.stdout.write("Testing API response format...")
# Test trending content format
trending_parks = trending_service.get_trending_content(
content_type="parks", limit=3
)
trending_rides = trending_service.get_trending_content(
content_type="rides", limit=3
)
# Test new content format
new_parks = trending_service.get_new_content(content_type="parks", limit=3)
new_rides = trending_service.get_new_content(content_type="rides", limit=3)
# Verify trending data structure
if trending_parks:
item = trending_parks[0]
required_trending_fields = [
"id",
"name",
"slug",
"views",
"views_change",
"rank",
]
for field in required_trending_fields:
if field not in item:
raise ValueError(f"Missing required trending field: {field}")
# Verify new content data structure
if new_parks:
item = new_parks[0]
required_new_fields = ["id", "name", "slug"]
for field in required_new_fields:
if field not in item:
raise ValueError(f"Missing required new content field: {field}")
if self.verbose:
self.stdout.write(" Sample trending park data:")
if trending_parks:
self.stdout.write(f" {trending_parks[0]}")
self.stdout.write(" Sample new park data:")
if new_parks:
self.stdout.write(f" {new_parks[0]}")
self.stdout.write("✓ API format validation passed")

View File

@@ -6,30 +6,31 @@ from apps.core.analytics import PageView
class Command(BaseCommand):
help = "Updates trending parks and rides cache based on views in the last 24 hours"
help = "Updates trending parks and rides cache based on views in the last 7 days"
def handle(self, *args, **kwargs):
"""
Updates the trending parks and rides in the cache.
This command is designed to be run every hour via cron to keep the trending
items up to date. It looks at page views from the last 24 hours and caches
This command is designed to be run once daily via cron to keep the trending
items up to date. It looks at page views from the last 7 days and caches
the top 10 most viewed parks and rides.
The cached data is used by the home page to display trending items without
having to query the database on every request.
"""
# Get top 10 trending parks and rides from the last 24 hours
trending_parks = PageView.get_trending_items(Park, hours=24, limit=10)
trending_rides = PageView.get_trending_items(Ride, hours=24, limit=10)
# Get top 10 trending parks and rides from the last 7 days (168 hours)
trending_parks = PageView.get_trending_items(Park, hours=168, limit=10)
trending_rides = PageView.get_trending_items(Ride, hours=168, limit=10)
# Cache the results for 1 hour
cache.set("trending_parks", trending_parks, 3600) # 3600 seconds = 1 hour
cache.set("trending_rides", trending_rides, 3600)
# Cache the results for 24 hours (daily refresh)
cache.set("trending_parks", trending_parks, 86400) # 86400 seconds = 24 hours
cache.set("trending_rides", trending_rides, 86400)
self.stdout.write(
self.style.SUCCESS(
"Successfully updated trending parks and rides. "
"Cached 10 items each for parks and rides based on views in the last 24 hours."
"Cached 10 items each for parks and rides based on views "
"in the last 7 days."
)
)

View File

@@ -1,22 +1,15 @@
# Core middleware modules
"""
Core middleware package.
# Import middleware classes from the analytics module
from .analytics import PageViewMiddleware, PgHistoryContextMiddleware
This package contains middleware components for the Django application,
including view tracking and other core functionality.
"""
# Import middleware classes from the performance_middleware.py module
from .performance_middleware import (
PerformanceMiddleware,
QueryCountMiddleware,
DatabaseConnectionMiddleware,
CachePerformanceMiddleware,
)
from .view_tracking import ViewTrackingMiddleware, get_view_stats_for_content
from .analytics import PgHistoryContextMiddleware
# Make all middleware classes available at the package level
__all__ = [
"PageViewMiddleware",
"ViewTrackingMiddleware",
"get_view_stats_for_content",
"PgHistoryContextMiddleware",
"PerformanceMiddleware",
"QueryCountMiddleware",
"DatabaseConnectionMiddleware",
"CachePerformanceMiddleware",
]

View File

@@ -44,41 +44,3 @@ class PgHistoryContextMiddleware:
def __call__(self, request):
response = self.get_response(request)
return response
class PageViewMiddleware(MiddlewareMixin):
"""Middleware to track page views for DetailView-based pages."""
def process_view(self, request, view_func, view_args, view_kwargs):
# Only track GET requests
if request.method != "GET":
return None
# Get view class if it exists
view_class = getattr(view_func, "view_class", None)
if not view_class or not issubclass(view_class, DetailView):
return None
# Get the object if it's a detail view
try:
view_instance = view_class()
view_instance.request = request
view_instance.args = view_args
view_instance.kwargs = view_kwargs
obj = view_instance.get_object()
except (AttributeError, Exception):
return None
# Record the page view
try:
PageView.objects.create(
content_type=ContentType.objects.get_for_model(obj.__class__),
object_id=obj.pk,
ip_address=request.META.get("REMOTE_ADDR", ""),
user_agent=request.META.get("HTTP_USER_AGENT", "")[:512],
)
except Exception:
# Fail silently to not interrupt the request
pass
return None

View File

@@ -0,0 +1,331 @@
"""
View Tracking Middleware for automatic PageView recording.
This middleware automatically tracks page views for park and ride pages,
implementing IP-based deduplication to prevent spam and provide accurate
analytics for the trending algorithm.
"""
import logging
import re
from datetime import datetime, timedelta
from typing import Optional, Union
from django.http import HttpRequest, HttpResponse
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.conf import settings
from django.db import models
from apps.core.analytics import PageView
from apps.parks.models import Park
from apps.rides.models import Ride
# Type alias for content objects
ContentObject = Union[Park, Ride]
logger = logging.getLogger(__name__)
class ViewTrackingMiddleware:
"""
Middleware for tracking page views with IP deduplication.
Automatically creates PageView records when users visit park or ride pages.
Implements 24-hour IP deduplication window to prevent view inflation.
"""
def __init__(self, get_response):
self.get_response = get_response
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
# URL patterns for tracking - matches park and ride detail pages
self.tracked_patterns = [
(r"^/parks/(?P<slug>[\w-]+)/$", "park"),
(r"^/rides/(?P<slug>[\w-]+)/$", "ride"),
# Add API patterns if needed
(r"^/api/v1/parks/(?P<slug>[\w-]+)/$", "park"),
(r"^/api/v1/rides/(?P<slug>[\w-]+)/$", "ride"),
]
# Compile patterns for performance
self.compiled_patterns = [
(re.compile(pattern), content_type)
for pattern, content_type in self.tracked_patterns
]
# Cache configuration
self.cache_timeout = 60 * 15 # 15 minutes
self.dedup_window_hours = 24
def __call__(self, request: HttpRequest) -> HttpResponse:
"""Process the request and track views if applicable."""
response = self.get_response(request)
# Only track successful GET requests
if (
request.method == "GET"
and 200 <= response.status_code < 300
and not self._should_skip_tracking(request)
):
try:
self._track_view_if_applicable(request)
except Exception as e:
# Log error but don't break the request
self.logger.error(f"Error tracking view: {e}", exc_info=True)
return response
def _should_skip_tracking(self, request: HttpRequest) -> bool:
"""Check if this request should be skipped for tracking."""
# Skip if disabled in settings
if not getattr(settings, "ENABLE_VIEW_TRACKING", True):
return True
# Skip requests from bots/crawlers
user_agent = request.META.get("HTTP_USER_AGENT", "").lower()
bot_indicators = [
"bot",
"crawler",
"spider",
"scraper",
"facebook",
"twitter",
"linkedin",
"google",
"bing",
"yahoo",
"duckduckgo",
"slurp",
]
if any(indicator in user_agent for indicator in bot_indicators):
return True
# Skip requests without real IP
if not self._get_client_ip(request):
return True
# Skip AJAX requests (optional - depending on requirements)
if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest":
return True
return False
def _track_view_if_applicable(self, request: HttpRequest) -> None:
"""Track view if the URL matches tracked patterns."""
path = request.path
for pattern, content_type in self.compiled_patterns:
match = pattern.match(path)
if match:
slug = match.group("slug")
self._record_page_view(request, content_type, slug)
break
def _record_page_view(
self, request: HttpRequest, content_type: str, slug: str
) -> None:
"""Record a page view for the specified content."""
client_ip = self._get_client_ip(request)
if not client_ip:
return
try:
# Get the content object
content_obj = self._get_content_object(content_type, slug)
if not content_obj:
self.logger.warning(
f"Content not found: {content_type} with slug '{slug}'"
)
return
# Check deduplication
if self._is_duplicate_view(content_obj, client_ip):
self.logger.debug(
f"Duplicate view skipped for {content_type} {slug} from {client_ip}"
)
return
# Create PageView record
self._create_page_view(content_obj, client_ip, request)
self.logger.debug(
f"Recorded view for {content_type} {slug} from {client_ip}"
)
except Exception as e:
self.logger.error(
f"Failed to record page view for {content_type} {slug}: {e}"
)
def _get_content_object(
self, content_type: str, slug: str
) -> Optional[ContentObject]:
"""Get the content object by type and slug."""
try:
if content_type == "park":
# Use get_by_slug method to handle historical slugs
park, _ = Park.get_by_slug(slug)
return park
elif content_type == "ride":
# For rides, we need to search by slug within parks
return Ride.objects.filter(slug=slug).first()
else:
self.logger.warning(f"Unknown content type: {content_type}")
return None
except Park.DoesNotExist:
return None
except Exception as e:
self.logger.error(f"Error getting {content_type} with slug {slug}: {e}")
return None
def _is_duplicate_view(self, content_obj: ContentObject, client_ip: str) -> bool:
"""Check if this view is a duplicate within the deduplication window."""
# Use cache for performance
cache_key = self._get_dedup_cache_key(content_obj, client_ip)
if cache.get(cache_key):
return True
# Check database as fallback
content_type = ContentType.objects.get_for_model(content_obj)
cutoff_time = timezone.now() - timedelta(hours=self.dedup_window_hours)
existing_view = PageView.objects.filter(
content_type=content_type,
object_id=content_obj.pk,
ip_address=client_ip,
timestamp__gte=cutoff_time,
).exists()
if not existing_view:
# Set cache to prevent future duplicates
cache.set(cache_key, True, timeout=self.dedup_window_hours * 3600)
return existing_view
def _create_page_view(
self, content_obj: ContentObject, client_ip: str, request: HttpRequest
) -> None:
"""Create a new PageView record."""
content_type = ContentType.objects.get_for_model(content_obj)
# Extract additional metadata
user_agent = request.META.get("HTTP_USER_AGENT", "")[
:500
] # Truncate long user agents
referer = request.META.get("HTTP_REFERER", "")[:500]
PageView.objects.create(
content_type=content_type,
object_id=content_obj.pk,
ip_address=client_ip,
user_agent=user_agent,
referer=referer,
path=request.path[:500],
)
# Update cache for deduplication
cache_key = self._get_dedup_cache_key(content_obj, client_ip)
cache.set(cache_key, True, timeout=self.dedup_window_hours * 3600)
def _get_dedup_cache_key(self, content_obj: ContentObject, client_ip: str) -> str:
"""Generate cache key for deduplication."""
content_type = ContentType.objects.get_for_model(content_obj)
return f"pageview_dedup:{content_type.id}:{content_obj.pk}:{client_ip}"
def _get_client_ip(self, request: HttpRequest) -> Optional[str]:
"""Extract client IP address from request."""
# Check for forwarded IP (common in production with load balancers)
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
# Take the first IP in the chain (client IP)
ip = x_forwarded_for.split(",")[0].strip()
if self._is_valid_ip(ip):
return ip
# Check for real IP header (some proxy configurations)
x_real_ip = request.META.get("HTTP_X_REAL_IP")
if x_real_ip and self._is_valid_ip(x_real_ip):
return x_real_ip
# Fall back to remote address
remote_addr = request.META.get("REMOTE_ADDR")
if remote_addr and self._is_valid_ip(remote_addr):
return remote_addr
return None
def _is_valid_ip(self, ip: str) -> bool:
"""Validate IP address format."""
try:
# Basic validation - check if it looks like an IP
parts = ip.split(".")
if len(parts) != 4:
return False
for part in parts:
if not part.isdigit() or not 0 <= int(part) <= 255:
return False
# Skip localhost and private IPs in production
if getattr(settings, "SKIP_LOCAL_IPS", not settings.DEBUG):
if ip.startswith(("127.", "192.168.", "10.")) or ip.startswith("172."):
if any(
16 <= int(ip.split(".")[1]) <= 31
for _ in [ip]
if ip.startswith("172.")
):
return False
return True
except (ValueError, IndexError):
return False
def get_view_stats_for_content(content_obj: ContentObject, hours: int = 24) -> dict:
"""
Helper function to get view statistics for content.
Args:
content_obj: The content object (Park or Ride)
hours: Time window in hours for stats
Returns:
Dictionary with view statistics
"""
try:
content_type = ContentType.objects.get_for_model(content_obj)
cutoff_time = timezone.now() - timedelta(hours=hours)
total_views = PageView.objects.filter(
content_type=content_type,
object_id=content_obj.pk,
timestamp__gte=cutoff_time,
).count()
unique_views = (
PageView.objects.filter(
content_type=content_type,
object_id=content_obj.pk,
timestamp__gte=cutoff_time,
)
.values("ip_address")
.distinct()
.count()
)
return {
"total_views": total_views,
"unique_views": unique_views,
"hours": hours,
"content_type": content_type.model,
"content_id": content_obj.pk,
}
except Exception as e:
logger.error(f"Error getting view stats: {e}")
return {"total_views": 0, "unique_views": 0, "hours": hours, "error": str(e)}

View File

@@ -0,0 +1,415 @@
"""
Entity Fuzzy Matching Service for ThrillWiki
Provides intelligent entity matching when exact lookups fail, with authentication
prompts for suggesting new entity creation.
Features:
- Levenshtein distance for typo correction
- Phonetic matching using Soundex algorithm
- Partial name matching
- Priority-based scoring (parks > rides > companies)
- Authentication state-aware suggestions
"""
import re
from difflib import SequenceMatcher
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass
from enum import Enum
from django.db.models import Q
from apps.parks.models import Park
from apps.rides.models import Ride
from apps.parks.models import Company
class EntityType(Enum):
"""Supported entity types for fuzzy matching."""
PARK = "park"
RIDE = "ride"
COMPANY = "company"
@dataclass
class FuzzyMatchResult:
"""Result of a fuzzy matching operation."""
entity_type: EntityType
entity: Any # The actual model instance
name: str
slug: str
score: float # 0.0 to 1.0, higher is better match
match_reason: str # Description of why this was matched
confidence: str # 'high', 'medium', 'low'
url: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API responses."""
return {
"entity_type": self.entity_type.value,
"name": self.name,
"slug": self.slug,
"score": round(self.score, 3),
"match_reason": self.match_reason,
"confidence": self.confidence,
"url": self.url,
"entity_id": getattr(self.entity, "id", None),
}
@dataclass
class EntitySuggestion:
"""Suggestion for creating a new entity when no matches found."""
suggested_name: str
entity_type: EntityType
requires_authentication: bool
login_prompt: str
signup_prompt: str
creation_hint: str
class FuzzyMatchingAlgorithms:
"""Collection of fuzzy matching algorithms."""
@staticmethod
def levenshtein_distance(s1: str, s2: str) -> int:
"""Calculate Levenshtein distance between two strings."""
if len(s1) < len(s2):
return FuzzyMatchingAlgorithms.levenshtein_distance(s2, s1)
if len(s2) == 0:
return len(s1)
previous_row = list(range(len(s2) + 1))
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
@staticmethod
def similarity_ratio(s1: str, s2: str) -> float:
"""Calculate similarity ratio (0.0 to 1.0) using SequenceMatcher."""
return SequenceMatcher(None, s1.lower(), s2.lower()).ratio()
@staticmethod
def soundex(name: str) -> str:
"""Generate Soundex code for phonetic matching."""
name = re.sub(r"[^A-Za-z]", "", name.upper())
if not name:
return "0000"
# Soundex algorithm
soundex_map = {
"BFPV": "1",
"CGJKQSXZ": "2",
"DT": "3",
"L": "4",
"MN": "5",
"R": "6",
}
first_letter = name[0]
name = name[1:]
# Replace letters with numbers
for letters, number in soundex_map.items():
name = re.sub(f"[{letters}]", number, name)
# Remove consecutive duplicates
name = re.sub(r"(\d)\1+", r"\1", name)
# Remove zeros
name = re.sub("0", "", name)
# Pad or truncate to 4 characters
soundex_code = (first_letter + name + "000")[:4]
return soundex_code
@staticmethod
def partial_match_score(query: str, target: str) -> float:
"""Calculate partial matching score for substring matches."""
query_lower = query.lower()
target_lower = target.lower()
# Exact match
if query_lower == target_lower:
return 1.0
# Starts with
if target_lower.startswith(query_lower):
return 0.8 + (len(query) / len(target)) * 0.15
# Contains
if query_lower in target_lower:
return 0.6 + (len(query) / len(target)) * 0.2
# Words match
query_words = set(query_lower.split())
target_words = set(target_lower.split())
if query_words & target_words:
intersection = len(query_words & target_words)
union = len(query_words | target_words)
return 0.4 + (intersection / union) * 0.3
return 0.0
class EntityFuzzyMatcher:
"""Main fuzzy matching service for entities."""
# Matching thresholds
HIGH_CONFIDENCE_THRESHOLD = 0.8
MEDIUM_CONFIDENCE_THRESHOLD = 0.6
LOW_CONFIDENCE_THRESHOLD = 0.4
# Maximum results to consider
MAX_CANDIDATES = 50
MAX_RESULTS = 5
def __init__(self):
self.algorithms = FuzzyMatchingAlgorithms()
def find_entity(
self, query: str, entity_types: Optional[List[EntityType]] = None, user=None
) -> Tuple[List[FuzzyMatchResult], Optional[EntitySuggestion]]:
"""
Find entities matching the query with fuzzy matching.
Args:
query: Search query string
entity_types: Limit search to specific entity types
user: Current user for authentication context
Returns:
Tuple of (matches, suggestion_for_new_entity)
"""
if not query or len(query.strip()) < 2:
return [], None
query = query.strip()
entity_types = entity_types or [
EntityType.PARK,
EntityType.RIDE,
EntityType.COMPANY,
]
# Collect all potential matches
candidates = []
for entity_type in entity_types:
candidates.extend(self._get_candidates(query, entity_type))
# Score and rank candidates
matches = self._score_and_rank_candidates(query, candidates)
# Generate suggestion if no good matches found
suggestion = None
if not matches or matches[0].score < self.LOW_CONFIDENCE_THRESHOLD:
suggestion = self._generate_entity_suggestion(query, entity_types, user)
return matches[: self.MAX_RESULTS], suggestion
def _get_candidates(
self, query: str, entity_type: EntityType
) -> List[Dict[str, Any]]:
"""Get potential matching candidates for an entity type."""
candidates = []
if entity_type == EntityType.PARK:
parks = Park.objects.filter(
Q(name__icontains=query)
| Q(slug__icontains=query.lower().replace(" ", "-"))
| Q(former_names__icontains=query)
)[: self.MAX_CANDIDATES]
for park in parks:
candidates.append(
{
"entity_type": EntityType.PARK,
"entity": park,
"name": park.name,
"slug": park.slug,
"search_names": [park.name],
"url": getattr(park, "get_absolute_url", lambda: None)(),
"priority_boost": 0.1, # Parks get priority
}
)
elif entity_type == EntityType.RIDE:
rides = Ride.objects.select_related("park").filter(
Q(name__icontains=query)
| Q(slug__icontains=query.lower().replace(" ", "-"))
| Q(former_names__icontains=query)
| Q(park__name__icontains=query)
)[: self.MAX_CANDIDATES]
for ride in rides:
candidates.append(
{
"entity_type": EntityType.RIDE,
"entity": ride,
"name": ride.name,
"slug": ride.slug,
"search_names": [ride.name, f"{ride.park.name} {ride.name}"],
"url": getattr(ride, "get_absolute_url", lambda: None)(),
"priority_boost": 0.05, # Rides get some priority
}
)
elif entity_type == EntityType.COMPANY:
companies = Company.objects.filter(
Q(name__icontains=query)
| Q(slug__icontains=query.lower().replace(" ", "-"))
)[: self.MAX_CANDIDATES]
for company in companies:
candidates.append(
{
"entity_type": EntityType.COMPANY,
"entity": company,
"name": company.name,
"slug": company.slug,
"search_names": [company.name],
"url": getattr(company, "get_absolute_url", lambda: None)(),
"priority_boost": 0.0, # Companies get no priority boost
}
)
return candidates
def _score_and_rank_candidates(
self, query: str, candidates: List[Dict[str, Any]]
) -> List[FuzzyMatchResult]:
"""Score and rank all candidates using multiple algorithms."""
scored_matches = []
for candidate in candidates:
best_score = 0.0
best_reason = ""
# Test against all search names for this candidate
for search_name in candidate["search_names"]:
# Algorithm 1: Sequence similarity
similarity_score = self.algorithms.similarity_ratio(query, search_name)
if similarity_score > best_score:
best_score = similarity_score
best_reason = f"Text similarity with '{search_name}'"
# Algorithm 2: Partial matching
partial_score = self.algorithms.partial_match_score(query, search_name)
if partial_score > best_score:
best_score = partial_score
best_reason = f"Partial match with '{search_name}'"
# Algorithm 3: Levenshtein distance
if len(query) > 3 and len(search_name) > 3:
max_len = max(len(query), len(search_name))
distance = self.algorithms.levenshtein_distance(query, search_name)
lev_score = 1.0 - (distance / max_len)
if lev_score > best_score:
best_score = lev_score
best_reason = f"Similar spelling to '{search_name}'"
# Algorithm 4: Soundex phonetic matching
if len(query) > 2 and len(search_name) > 2:
query_soundex = self.algorithms.soundex(query)
name_soundex = self.algorithms.soundex(search_name)
if query_soundex == name_soundex and best_score < 0.7:
best_score = max(best_score, 0.7)
best_reason = f"Sounds like '{search_name}'"
# Apply priority boost
best_score += candidate["priority_boost"]
best_score = min(1.0, best_score) # Cap at 1.0
# Determine confidence level
if best_score >= self.HIGH_CONFIDENCE_THRESHOLD:
confidence = "high"
elif best_score >= self.MEDIUM_CONFIDENCE_THRESHOLD:
confidence = "medium"
else:
confidence = "low"
# Only include if above minimum threshold
if best_score >= self.LOW_CONFIDENCE_THRESHOLD:
match = FuzzyMatchResult(
entity_type=candidate["entity_type"],
entity=candidate["entity"],
name=candidate["name"],
slug=candidate["slug"],
score=best_score,
match_reason=best_reason,
confidence=confidence,
url=candidate["url"],
)
scored_matches.append(match)
# Sort by score (highest first) and return
return sorted(scored_matches, key=lambda x: x.score, reverse=True)
def _generate_entity_suggestion(
self, query: str, entity_types: List[EntityType], user
) -> EntitySuggestion:
"""Generate suggestion for creating new entity when no matches found."""
# Determine most likely entity type based on query characteristics
suggested_type = EntityType.PARK # Default to park
# Simple heuristics for entity type detection
query_lower = query.lower()
if any(
word in query_lower
for word in ["roller coaster", "ride", "coaster", "attraction"]
):
suggested_type = EntityType.RIDE
elif any(
word in query_lower for word in ["inc", "corp", "company", "manufacturer"]
):
suggested_type = EntityType.COMPANY
elif EntityType.PARK in entity_types:
suggested_type = EntityType.PARK
elif entity_types:
suggested_type = entity_types[0]
# Clean up the suggested name
suggested_name = " ".join(word.capitalize() for word in query.split())
# Check if user is authenticated
is_authenticated = (
user and hasattr(user, "is_authenticated") and user.is_authenticated
)
# Generate appropriate prompts
entity_name = suggested_type.value
login_prompt = (
f"Log in to suggest adding '{suggested_name}' as a new {entity_name}"
)
signup_prompt = (
f"Sign up to contribute and add '{suggested_name}' to ThrillWiki"
)
creation_hint = (
f"Help expand ThrillWiki by adding information about '{suggested_name}'"
)
return EntitySuggestion(
suggested_name=suggested_name,
entity_type=suggested_type,
requires_authentication=not is_authenticated,
login_prompt=login_prompt,
signup_prompt=signup_prompt,
creation_hint=creation_hint,
)
# Global service instance
entity_fuzzy_matcher = EntityFuzzyMatcher()

View File

@@ -0,0 +1,594 @@
"""
Trending Service for calculating and caching trending content.
This service implements the weighted trending algorithm that combines:
- View growth rates
- Content ratings
- Recency factors
- Popularity metrics
Results are cached in Redis for performance optimization.
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db.models import Q
from apps.core.analytics import PageView
from apps.parks.models import Park
from apps.rides.models import Ride
logger = logging.getLogger(__name__)
class TrendingService:
"""
Service for calculating trending content using weighted algorithm.
Algorithm Components:
- View Growth Rate (40% weight): Recent view increase vs historical
- Rating Score (30% weight): Average user rating normalized
- Recency Factor (20% weight): How recently content was added/updated
- Popularity Boost (10% weight): Total view count normalization
"""
# Algorithm weights (must sum to 1.0)
WEIGHT_VIEW_GROWTH = 0.4
WEIGHT_RATING = 0.3
WEIGHT_RECENCY = 0.2
WEIGHT_POPULARITY = 0.1
# Cache configuration
CACHE_PREFIX = "trending"
CACHE_TTL = 86400 # 24 hours (daily refresh)
# Time windows for calculations
CURRENT_PERIOD_HOURS = 168 # 7 days
PREVIOUS_PERIOD_HOURS = 336 # 14 days (for previous 7-day window comparison)
RECENCY_BASELINE_DAYS = 365
def __init__(self):
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
def get_trending_content(
self, content_type: str = "all", limit: int = 20, force_refresh: bool = False
) -> List[Dict[str, Any]]:
"""
Get trending content with caching.
Args:
content_type: 'parks', 'rides', or 'all'
limit: Maximum number of results
force_refresh: Skip cache and recalculate
Returns:
List of trending content with exact frontend format
"""
cache_key = f"{self.CACHE_PREFIX}:trending:{content_type}:{limit}"
if not force_refresh:
cached_result = cache.get(cache_key)
if cached_result is not None:
self.logger.debug(
f"Returning cached trending results for {content_type}"
)
return cached_result
self.logger.info(f"Calculating trending content for {content_type}")
try:
# Calculate trending scores for each content type
trending_items = []
if content_type in ["all", "parks"]:
park_items = self._calculate_trending_parks(
limit if content_type == "parks" else limit * 2
)
trending_items.extend(park_items)
if content_type in ["all", "rides"]:
ride_items = self._calculate_trending_rides(
limit if content_type == "rides" else limit * 2
)
trending_items.extend(ride_items)
# Sort by trending score and apply limit
trending_items.sort(key=lambda x: x.get("trending_score", 0), reverse=True)
trending_items = trending_items[:limit]
# Add ranking and format for frontend
formatted_results = self._format_trending_results(trending_items)
# Cache results
cache.set(cache_key, formatted_results, self.CACHE_TTL)
self.logger.info(
f"Calculated {len(formatted_results)} trending items for {content_type}"
)
return formatted_results
except Exception as e:
self.logger.error(f"Error calculating trending content: {e}", exc_info=True)
return []
def get_new_content(
self,
content_type: str = "all",
limit: int = 20,
days_back: int = 30,
force_refresh: bool = False,
) -> List[Dict[str, Any]]:
"""
Get recently added content.
Args:
content_type: 'parks', 'rides', or 'all'
limit: Maximum number of results
days_back: How many days to look back
force_refresh: Skip cache and recalculate
Returns:
List of new content with exact frontend format
"""
cache_key = f"{self.CACHE_PREFIX}:new:{content_type}:{limit}:{days_back}"
if not force_refresh:
cached_result = cache.get(cache_key)
if cached_result is not None:
self.logger.debug(
f"Returning cached new content results for {content_type}"
)
return cached_result
self.logger.info(f"Calculating new content for {content_type}")
try:
cutoff_date = timezone.now() - timedelta(days=days_back)
new_items = []
if content_type in ["all", "parks"]:
parks = self._get_new_parks(
cutoff_date, limit if content_type == "parks" else limit * 2
)
new_items.extend(parks)
if content_type in ["all", "rides"]:
rides = self._get_new_rides(
cutoff_date, limit if content_type == "rides" else limit * 2
)
new_items.extend(rides)
# Sort by date added (most recent first) and apply limit
new_items.sort(key=lambda x: x.get("date_added", ""), reverse=True)
new_items = new_items[:limit]
# Format for frontend
formatted_results = self._format_new_content_results(new_items)
# Cache results
cache.set(cache_key, formatted_results, self.CACHE_TTL)
self.logger.info(
f"Found {len(formatted_results)} new items for {content_type}"
)
return formatted_results
except Exception as e:
self.logger.error(f"Error getting new content: {e}", exc_info=True)
return []
def _calculate_trending_parks(self, limit: int) -> List[Dict[str, Any]]:
"""Calculate trending scores for parks."""
parks = Park.objects.filter(status="OPERATING").select_related(
"location", "operator"
)
trending_parks = []
for park in parks:
try:
score = self._calculate_content_score(park, "park")
if score > 0: # Only include items with positive trending scores
trending_parks.append(
{
"content_object": park,
"content_type": "park",
"trending_score": score,
"id": park.id,
"name": park.name,
"slug": park.slug,
"location": (
park.formatted_location
if hasattr(park, "location")
else ""
),
"category": "park",
"rating": (
float(park.average_rating)
if park.average_rating
else 0.0
),
}
)
except Exception as e:
self.logger.warning(f"Error calculating score for park {park.id}: {e}")
return trending_parks
def _calculate_trending_rides(self, limit: int) -> List[Dict[str, Any]]:
"""Calculate trending scores for rides."""
rides = Ride.objects.filter(status="OPERATING").select_related(
"park", "park__location"
)
trending_rides = []
for ride in rides:
try:
score = self._calculate_content_score(ride, "ride")
if score > 0: # Only include items with positive trending scores
# Get location from park (rides don't have direct location field)
location = ""
if (
ride.park
and hasattr(ride.park, "location")
and ride.park.location
):
location = ride.park.formatted_location
trending_rides.append(
{
"content_object": ride,
"content_type": "ride",
"trending_score": score,
"id": ride.pk, # Use pk instead of id
"name": ride.name,
"slug": ride.slug,
"location": location,
"category": "ride",
"rating": (
float(ride.average_rating)
if ride.average_rating
else 0.0
),
}
)
except Exception as e:
self.logger.warning(f"Error calculating score for ride {ride.pk}: {e}")
return trending_rides
def _calculate_content_score(self, content_obj: Any, content_type: str) -> float:
"""
Calculate weighted trending score for content object.
Returns:
Float between 0.0 and 1.0 representing trending strength
"""
try:
# Get content type for PageView queries
ct = ContentType.objects.get_for_model(content_obj)
# 1. View Growth Score (40% weight)
view_growth_score = self._calculate_view_growth_score(ct, content_obj.id)
# 2. Rating Score (30% weight)
rating_score = self._calculate_rating_score(content_obj)
# 3. Recency Score (20% weight)
recency_score = self._calculate_recency_score(content_obj)
# 4. Popularity Score (10% weight)
popularity_score = self._calculate_popularity_score(ct, content_obj.id)
# Calculate weighted final score
final_score = (
view_growth_score * self.WEIGHT_VIEW_GROWTH
+ rating_score * self.WEIGHT_RATING
+ recency_score * self.WEIGHT_RECENCY
+ popularity_score * self.WEIGHT_POPULARITY
)
self.logger.debug(
f"{content_type} {content_obj.id}: "
f"growth={view_growth_score:.3f}, rating={rating_score:.3f}, "
f"recency={recency_score:.3f}, popularity={popularity_score:.3f}, "
f"final={final_score:.3f}"
)
return final_score
except Exception as e:
self.logger.error(
f"Error calculating score for {content_type} {content_obj.id}: {e}"
)
return 0.0
def _calculate_view_growth_score(
self, content_type: ContentType, object_id: int
) -> float:
"""Calculate normalized view growth score."""
try:
current_views, previous_views, growth_percentage = (
PageView.get_views_growth(
content_type,
object_id,
self.CURRENT_PERIOD_HOURS,
self.PREVIOUS_PERIOD_HOURS,
)
)
if previous_views == 0:
# New content with views gets boost
return min(current_views / 100.0, 1.0) if current_views > 0 else 0.0
# Normalize growth percentage to 0-1 scale
# 100% growth = 0.5, 500% growth = 1.0
normalized_growth = (
min(growth_percentage / 500.0, 1.0) if growth_percentage > 0 else 0.0
)
return max(normalized_growth, 0.0)
except Exception as e:
self.logger.warning(f"Error calculating view growth: {e}")
return 0.0
def _calculate_rating_score(self, content_obj: Any) -> float:
"""Calculate normalized rating score."""
try:
rating = getattr(content_obj, "average_rating", None)
if rating is None or rating == 0:
return 0.3 # Neutral score for unrated content
# Normalize rating from 1-10 scale to 0-1 scale
# Rating of 5 = 0.4, Rating of 8 = 0.7, Rating of 10 = 1.0
return min(max((float(rating) - 1) / 9.0, 0.0), 1.0)
except Exception as e:
self.logger.warning(f"Error calculating rating score: {e}")
return 0.3
def _calculate_recency_score(self, content_obj: Any) -> float:
"""Calculate recency score based on when content was added/updated."""
try:
# Use opening_date for parks/rides, or created_at as fallback
date_added = getattr(content_obj, "opening_date", None)
if not date_added:
date_added = getattr(content_obj, "created_at", None)
if not date_added:
return 0.5 # Neutral score for unknown dates
# Handle both date and datetime objects
if hasattr(date_added, "date"):
date_added = date_added.date()
# Calculate days since added
today = timezone.now().date()
days_since_added = (today - date_added).days
# Recency score: newer content gets higher scores
# 0 days = 1.0, 30 days = 0.8, 365 days = 0.1, >365 days = 0.0
if days_since_added <= 0:
return 1.0
elif days_since_added <= 30:
return 1.0 - (days_since_added / 30.0) * 0.2 # 1.0 to 0.8
elif days_since_added <= self.RECENCY_BASELINE_DAYS:
return (
0.8
- ((days_since_added - 30) / (self.RECENCY_BASELINE_DAYS - 30))
* 0.7
) # 0.8 to 0.1
else:
return 0.0
except Exception as e:
self.logger.warning(f"Error calculating recency score: {e}")
return 0.5
def _calculate_popularity_score(
self, content_type: ContentType, object_id: int
) -> float:
"""Calculate popularity score based on total view count."""
try:
total_views = PageView.get_total_views_count(
content_type, object_id, hours=168 # Last 7 days
)
# Normalize views to 0-1 scale
# 0 views = 0.0, 100 views = 0.5, 1000+ views = 1.0
if total_views == 0:
return 0.0
elif total_views <= 100:
return total_views / 200.0 # 0.0 to 0.5
else:
return min(0.5 + (total_views - 100) / 1800.0, 1.0) # 0.5 to 1.0
except Exception as e:
self.logger.warning(f"Error calculating popularity score: {e}")
return 0.0
def _get_new_parks(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
"""Get recently added parks."""
new_parks = (
Park.objects.filter(
Q(created_at__gte=cutoff_date)
| Q(opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("location", "operator")
.order_by("-created_at", "-opening_date")[:limit]
)
results = []
for park in new_parks:
date_added = park.opening_date or park.created_at
# Handle datetime to date conversion
if date_added:
# If it's a datetime, convert to date
if isinstance(date_added, datetime):
date_added = date_added.date()
# If it's already a date, keep it as is
results.append(
{
"content_object": park,
"content_type": "park",
"id": park.pk, # Use pk instead of id for Django compatibility
"name": park.name,
"slug": park.slug,
"location": (
park.formatted_location if hasattr(park, "location") else ""
),
"category": "park",
"date_added": date_added.isoformat() if date_added else "",
}
)
return results
def _get_new_rides(self, cutoff_date: datetime, limit: int) -> List[Dict[str, Any]]:
"""Get recently added rides."""
new_rides = (
Ride.objects.filter(
Q(created_at__gte=cutoff_date)
| Q(opening_date__gte=cutoff_date.date()),
status="OPERATING",
)
.select_related("park", "park__location")
.order_by("-created_at", "-opening_date")[:limit]
)
results = []
for ride in new_rides:
date_added = getattr(ride, "opening_date", None) or getattr(
ride, "created_at", None
)
# Handle datetime to date conversion
if date_added:
# If it's a datetime, convert to date
if isinstance(date_added, datetime):
date_added = date_added.date()
# If it's already a date, keep it as is
# Get location from park (rides don't have direct location field)
location = ""
if ride.park and hasattr(ride.park, "location") and ride.park.location:
location = ride.park.formatted_location
results.append(
{
"content_object": ride,
"content_type": "ride",
"id": ride.pk, # Use pk instead of id for Django compatibility
"name": ride.name,
"slug": ride.slug,
"location": location,
"category": "ride",
"date_added": date_added.isoformat() if date_added else "",
}
)
return results
def _format_trending_results(
self, trending_items: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Format trending results for frontend consumption."""
formatted_results = []
for rank, item in enumerate(trending_items, 1):
try:
# Get view change for display
content_obj = item["content_object"]
ct = ContentType.objects.get_for_model(content_obj)
current_views, previous_views, growth_percentage = (
PageView.get_views_growth(
ct,
content_obj.id,
self.CURRENT_PERIOD_HOURS,
self.PREVIOUS_PERIOD_HOURS,
)
)
# Format exactly as frontend expects
formatted_item = {
"id": item["id"],
"name": item["name"],
"location": item["location"],
"category": item["category"],
"rating": item["rating"],
"rank": rank,
"views": current_views,
"views_change": (
f"+{growth_percentage:.1f}%"
if growth_percentage > 0
else f"{growth_percentage:.1f}%"
),
"slug": item["slug"],
}
formatted_results.append(formatted_item)
except Exception as e:
self.logger.warning(f"Error formatting trending item: {e}")
return formatted_results
def _format_new_content_results(
self, new_items: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Format new content results for frontend consumption."""
formatted_results = []
for item in new_items:
try:
# Format exactly as frontend expects
formatted_item = {
"id": item["id"],
"name": item["name"],
"location": item["location"],
"category": item["category"],
"date_added": item["date_added"],
"slug": item["slug"],
}
formatted_results.append(formatted_item)
except Exception as e:
self.logger.warning(f"Error formatting new content item: {e}")
return formatted_results
def clear_cache(self, content_type: str = "all") -> None:
"""Clear trending and new content caches."""
try:
cache_patterns = [
f"{self.CACHE_PREFIX}:trending:{content_type}:*",
f"{self.CACHE_PREFIX}:new:{content_type}:*",
]
if content_type == "all":
cache_patterns.extend(
[
f"{self.CACHE_PREFIX}:trending:parks:*",
f"{self.CACHE_PREFIX}:trending:rides:*",
f"{self.CACHE_PREFIX}:new:parks:*",
f"{self.CACHE_PREFIX}:new:rides:*",
]
)
# Note: This is a simplified cache clear
# In production, you might want to use cache.delete_many() or similar
cache.clear()
self.logger.info(f"Cleared trending caches for {content_type}")
except Exception as e:
self.logger.error(f"Error clearing cache: {e}")
# Singleton service instance
trending_service = TrendingService()

24
backend/apps/core/urls.py Normal file
View File

@@ -0,0 +1,24 @@
"""
Core app URL configuration.
"""
from django.urls import path, include
from .views.entity_search import (
EntityFuzzySearchView,
EntityNotFoundView,
QuickEntitySuggestionView,
)
app_name = 'core'
# Entity search endpoints
entity_patterns = [
path('search/', EntityFuzzySearchView.as_view(), name='entity_fuzzy_search'),
path('not-found/', EntityNotFoundView.as_view(), name='entity_not_found'),
path('suggestions/', QuickEntitySuggestionView.as_view(), name='entity_suggestions'),
]
urlpatterns = [
# Entity fuzzy matching and search endpoints
path('entities/', include(entity_patterns)),
]

View File

@@ -0,0 +1 @@
# URLs package for core app

View File

@@ -0,0 +1,347 @@
"""
Entity search views with fuzzy matching and authentication prompts.
"""
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from typing import Optional, List
from ..services.entity_fuzzy_matching import (
entity_fuzzy_matcher,
EntityType,
)
class EntityFuzzySearchView(APIView):
"""
API endpoint for fuzzy entity search with authentication prompts.
Handles entity lookup failures by providing intelligent suggestions and
authentication prompts for entity creation.
"""
permission_classes = [AllowAny] # Allow both authenticated and anonymous users
def post(self, request):
"""
Perform fuzzy entity search.
Request body:
{
"query": "entity name to search",
"entity_types": ["park", "ride", "company"], // optional
"include_suggestions": true // optional, default true
}
Response:
{
"success": true,
"query": "original query",
"matches": [
{
"entity_type": "park",
"name": "Cedar Point",
"slug": "cedar-point",
"score": 0.95,
"confidence": "high",
"match_reason": "Text similarity with 'Cedar Point'",
"url": "/parks/cedar-point/",
"entity_id": 123
}
],
"suggestion": {
"suggested_name": "New Entity Name",
"entity_type": "park",
"requires_authentication": true,
"login_prompt": "Log in to suggest adding...",
"signup_prompt": "Sign up to contribute...",
"creation_hint": "Help expand ThrillWiki..."
},
"user_authenticated": false
}
"""
try:
# Parse request data
query = request.data.get("query", "").strip()
entity_types_raw = request.data.get(
"entity_types", ["park", "ride", "company"]
)
include_suggestions = request.data.get("include_suggestions", True)
# Validate query
if not query or len(query) < 2:
return Response(
{
"success": False,
"error": "Query must be at least 2 characters long",
"code": "INVALID_QUERY",
},
status=status.HTTP_400_BAD_REQUEST,
)
# Parse and validate entity types
entity_types = []
valid_types = {"park", "ride", "company"}
for entity_type in entity_types_raw:
if entity_type in valid_types:
entity_types.append(EntityType(entity_type))
if not entity_types:
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
# Perform fuzzy matching
matches, suggestion = entity_fuzzy_matcher.find_entity(
query=query, entity_types=entity_types, user=request.user
)
# Format response
response_data = {
"success": True,
"query": query,
"matches": [match.to_dict() for match in matches],
"user_authenticated": (
request.user.is_authenticated
if hasattr(request.user, "is_authenticated")
else False
),
}
# Include suggestion if requested and available
if include_suggestions and suggestion:
response_data["suggestion"] = {
"suggested_name": suggestion.suggested_name,
"entity_type": suggestion.entity_type.value,
"requires_authentication": suggestion.requires_authentication,
"login_prompt": suggestion.login_prompt,
"signup_prompt": suggestion.signup_prompt,
"creation_hint": suggestion.creation_hint,
}
return Response(response_data, status=status.HTTP_200_OK)
except Exception as e:
return Response(
{
"success": False,
"error": f"Internal server error: {str(e)}",
"code": "INTERNAL_ERROR",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class EntityNotFoundView(APIView):
"""
Endpoint specifically for handling entity not found scenarios.
This view is called when normal entity lookup fails and provides
fuzzy matching suggestions along with authentication prompts.
"""
permission_classes = [AllowAny]
def post(self, request):
"""
Handle entity not found with suggestions.
Request body:
{
"original_query": "what user searched for",
"attempted_slug": "slug-that-failed", // optional
"entity_type": "park", // optional, inferred from context
"context": { // optional context information
"park_slug": "park-slug-if-searching-for-ride",
"source_page": "page where search originated"
}
}
"""
try:
original_query = request.data.get("original_query", "").strip()
attempted_slug = request.data.get("attempted_slug", "")
entity_type_hint = request.data.get("entity_type")
context = request.data.get("context", {})
if not original_query:
return Response(
{
"success": False,
"error": "original_query is required",
"code": "MISSING_QUERY",
},
status=status.HTTP_400_BAD_REQUEST,
)
# Determine entity types to search based on context
entity_types = []
if entity_type_hint:
try:
entity_types = [EntityType(entity_type_hint)]
except ValueError:
pass
# If we have park context, prioritize ride searches
if context.get("park_slug") and not entity_types:
entity_types = [EntityType.RIDE, EntityType.PARK]
# Default to all types if not specified
if not entity_types:
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
# Try fuzzy matching on the original query
matches, suggestion = entity_fuzzy_matcher.find_entity(
query=original_query, entity_types=entity_types, user=request.user
)
# If no matches on original query, try the attempted slug
if not matches and attempted_slug:
# Convert slug back to readable name for fuzzy matching
slug_as_name = attempted_slug.replace("-", " ").title()
matches, suggestion = entity_fuzzy_matcher.find_entity(
query=slug_as_name, entity_types=entity_types, user=request.user
)
# Prepare response with detailed context
response_data = {
"success": True,
"original_query": original_query,
"attempted_slug": attempted_slug,
"context": context,
"matches": [match.to_dict() for match in matches],
"user_authenticated": (
request.user.is_authenticated
if hasattr(request.user, "is_authenticated")
else False
),
"has_matches": len(matches) > 0,
}
# Always include suggestion for entity not found scenarios
if suggestion:
response_data["suggestion"] = {
"suggested_name": suggestion.suggested_name,
"entity_type": suggestion.entity_type.value,
"requires_authentication": suggestion.requires_authentication,
"login_prompt": suggestion.login_prompt,
"signup_prompt": suggestion.signup_prompt,
"creation_hint": suggestion.creation_hint,
}
return Response(response_data, status=status.HTTP_200_OK)
except Exception as e:
return Response(
{
"success": False,
"error": f"Internal server error: {str(e)}",
"code": "INTERNAL_ERROR",
},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@method_decorator(csrf_exempt, name="dispatch")
class QuickEntitySuggestionView(APIView):
"""
Lightweight endpoint for quick entity suggestions (e.g., autocomplete).
"""
permission_classes = [AllowAny]
def get(self, request):
"""
Get quick entity suggestions.
Query parameters:
- q: query string
- types: comma-separated entity types (park,ride,company)
- limit: max results (default 5)
"""
try:
query = request.GET.get("q", "").strip()
types_param = request.GET.get("types", "park,ride,company")
limit = min(int(request.GET.get("limit", 5)), 10) # Cap at 10
if not query or len(query) < 2:
return Response(
{"suggestions": [], "query": query}, status=status.HTTP_200_OK
)
# Parse entity types
entity_types = []
for type_str in types_param.split(","):
type_str = type_str.strip()
if type_str in ["park", "ride", "company"]:
entity_types.append(EntityType(type_str))
if not entity_types:
entity_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
# Get fuzzy matches
matches, _ = entity_fuzzy_matcher.find_entity(
query=query, entity_types=entity_types, user=request.user
)
# Format as simple suggestions
suggestions = []
for match in matches[:limit]:
suggestions.append(
{
"name": match.name,
"type": match.entity_type.value,
"slug": match.slug,
"url": match.url,
"score": match.score,
"confidence": match.confidence,
}
)
return Response(
{"suggestions": suggestions, "query": query, "count": len(suggestions)},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response(
{"suggestions": [], "query": request.GET.get("q", ""), "error": str(e)},
status=status.HTTP_200_OK,
) # Return 200 even on errors for autocomplete
# Utility function for other views to use
def get_entity_suggestions(
query: str, entity_types: Optional[List[str]] = None, user=None
):
"""
Utility function for other Django views to get entity suggestions.
Args:
query: Search query
entity_types: List of entity type strings
user: Django user object
Returns:
Tuple of (matches, suggestion)
"""
try:
# Convert string types to EntityType enums
parsed_types = []
if entity_types:
for entity_type in entity_types:
try:
parsed_types.append(EntityType(entity_type))
except ValueError:
continue
if not parsed_types:
parsed_types = [EntityType.PARK, EntityType.RIDE, EntityType.COMPANY]
return entity_fuzzy_matcher.find_entity(
query=query, entity_types=parsed_types, user=user
)
except Exception:
return [], None