mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:31:07 -05:00
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:
@@ -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()
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Django management commands
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
# Django management commands
|
||||
|
||||
|
||||
472
backend/apps/core/management/commands/clear_cache.py
Normal file
472
backend/apps/core/management/commands/clear_cache.py
Normal 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}"))
|
||||
309
backend/apps/core/management/commands/test_trending.py
Normal file
309
backend/apps/core/management/commands/test_trending.py
Normal 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")
|
||||
@@ -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."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
331
backend/apps/core/middleware/view_tracking.py
Normal file
331
backend/apps/core/middleware/view_tracking.py
Normal 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)}
|
||||
415
backend/apps/core/services/entity_fuzzy_matching.py
Normal file
415
backend/apps/core/services/entity_fuzzy_matching.py
Normal 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()
|
||||
594
backend/apps/core/services/trending_service.py
Normal file
594
backend/apps/core/services/trending_service.py
Normal 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
24
backend/apps/core/urls.py
Normal 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)),
|
||||
]
|
||||
1
backend/apps/core/urls/__init__.py
Normal file
1
backend/apps/core/urls/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# URLs package for core app
|
||||
347
backend/apps/core/views/entity_search.py
Normal file
347
backend/apps/core/views/entity_search.py
Normal 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
|
||||
Reference in New Issue
Block a user