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

@@ -5,6 +5,7 @@ from .models.company import Company
from .models.rides import Ride, RideModel, RollerCoasterStats
from .models.location import RideLocation
from .models.reviews import RideReview
from .models.rankings import RideRanking, RidePairComparison, RankingSnapshot
class ManufacturerAdmin(admin.ModelAdmin):
@@ -484,4 +485,222 @@ class CompanyAdmin(admin.ModelAdmin):
return ", ".join(obj.roles) if obj.roles else "No roles"
@admin.register(RideRanking)
class RideRankingAdmin(admin.ModelAdmin):
"""Admin interface for ride rankings"""
list_display = (
"rank",
"ride_name",
"park_name",
"winning_percentage_display",
"wins",
"losses",
"ties",
"average_rating",
"mutual_riders_count",
"last_calculated",
)
list_filter = (
"ride__category",
"last_calculated",
"calculation_version",
)
search_fields = (
"ride__name",
"ride__park__name",
)
readonly_fields = (
"ride",
"rank",
"wins",
"losses",
"ties",
"winning_percentage",
"mutual_riders_count",
"comparison_count",
"average_rating",
"last_calculated",
"calculation_version",
"total_comparisons",
)
ordering = ["rank"]
fieldsets = (
(
"Ride Information",
{"fields": ("ride",)},
),
(
"Ranking Metrics",
{
"fields": (
"rank",
"winning_percentage",
"wins",
"losses",
"ties",
"total_comparisons",
)
},
),
(
"Additional Metrics",
{
"fields": (
"average_rating",
"mutual_riders_count",
"comparison_count",
)
},
),
(
"Calculation Info",
{
"fields": (
"last_calculated",
"calculation_version",
),
"classes": ("collapse",),
},
),
)
@admin.display(description="Ride")
def ride_name(self, obj):
return obj.ride.name
@admin.display(description="Park")
def park_name(self, obj):
return obj.ride.park.name
@admin.display(description="Win %")
def winning_percentage_display(self, obj):
return f"{obj.winning_percentage:.1%}"
def has_add_permission(self, request):
# Rankings are calculated automatically
return False
def has_change_permission(self, request, obj=None):
# Rankings are read-only
return False
@admin.register(RidePairComparison)
class RidePairComparisonAdmin(admin.ModelAdmin):
"""Admin interface for ride pair comparisons"""
list_display = (
"comparison_summary",
"ride_a_name",
"ride_b_name",
"winner_display",
"ride_a_wins",
"ride_b_wins",
"ties",
"mutual_riders_count",
"last_calculated",
)
list_filter = ("last_calculated",)
search_fields = (
"ride_a__name",
"ride_b__name",
"ride_a__park__name",
"ride_b__park__name",
)
readonly_fields = (
"ride_a",
"ride_b",
"ride_a_wins",
"ride_b_wins",
"ties",
"mutual_riders_count",
"ride_a_avg_rating",
"ride_b_avg_rating",
"last_calculated",
"winner",
"is_tie",
)
ordering = ["-mutual_riders_count"]
@admin.display(description="Comparison")
def comparison_summary(self, obj):
return f"{obj.ride_a.name} vs {obj.ride_b.name}"
@admin.display(description="Ride A")
def ride_a_name(self, obj):
return obj.ride_a.name
@admin.display(description="Ride B")
def ride_b_name(self, obj):
return obj.ride_b.name
@admin.display(description="Winner")
def winner_display(self, obj):
if obj.is_tie:
return "TIE"
winner = obj.winner
if winner:
return winner.name
return "N/A"
def has_add_permission(self, request):
# Comparisons are calculated automatically
return False
def has_change_permission(self, request, obj=None):
# Comparisons are read-only
return False
@admin.register(RankingSnapshot)
class RankingSnapshotAdmin(admin.ModelAdmin):
"""Admin interface for ranking history snapshots"""
list_display = (
"ride_name",
"park_name",
"rank",
"winning_percentage_display",
"snapshot_date",
)
list_filter = (
"snapshot_date",
"ride__category",
)
search_fields = (
"ride__name",
"ride__park__name",
)
readonly_fields = (
"ride",
"rank",
"winning_percentage",
"snapshot_date",
)
date_hierarchy = "snapshot_date"
ordering = ["-snapshot_date", "rank"]
@admin.display(description="Ride")
def ride_name(self, obj):
return obj.ride.name
@admin.display(description="Park")
def park_name(self, obj):
return obj.ride.park.name
@admin.display(description="Win %")
def winning_percentage_display(self, obj):
return f"{obj.winning_percentage:.1%}"
def has_add_permission(self, request):
# Snapshots are created automatically
return False
def has_change_permission(self, request, obj=None):
# Snapshots are read-only
return False
admin.site.register(RideLocation, RideLocationAdmin)

View File

@@ -0,0 +1,36 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from apps.rides.services import RideRankingService
class Command(BaseCommand):
help = "Calculates and updates ride rankings using the Internet Roller Coaster Poll algorithm"
def add_arguments(self, parser):
parser.add_argument(
"--category",
type=str,
default=None,
help="Optional ride category to filter (e.g., RC for roller coasters)",
)
def handle(self, *args, **options):
category = options.get("category")
service = RideRankingService()
self.stdout.write(
self.style.SUCCESS(
f"Starting ride ranking calculation at {timezone.now().isoformat()}"
)
)
result = service.update_all_rankings(category=category)
self.stdout.write(
self.style.SUCCESS(
f"Completed ranking calculation: {result.get('rides_ranked', 0)} rides ranked, "
f"{result.get('comparisons_made', 0)} comparisons, "
f"duration={result.get('duration', 0):.2f}s"
)
)

View File

@@ -0,0 +1,603 @@
# Generated by Django 5.2.5 on 2025-08-25 00:50
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pghistory", "0007_auto_20250421_0444"),
("rides", "0005_ridelocationevent_ridelocation_insert_insert_and_more"),
]
operations = [
migrations.CreateModel(
name="RidePairComparison",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"ride_a_wins",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated ride_a higher",
),
),
(
"ride_b_wins",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated ride_b higher",
),
),
(
"ties",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated both rides equally",
),
),
(
"mutual_riders_count",
models.PositiveIntegerField(
default=0,
help_text="Total number of users who have rated both rides",
),
),
(
"ride_a_avg_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating of ride_a from mutual riders",
max_digits=3,
null=True,
),
),
(
"ride_b_avg_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating of ride_b from mutual riders",
max_digits=3,
null=True,
),
),
(
"last_calculated",
models.DateTimeField(
auto_now=True,
help_text="When this comparison was last calculated",
),
),
(
"ride_a",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comparisons_as_a",
to="rides.ride",
),
),
(
"ride_b",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comparisons_as_b",
to="rides.ride",
),
),
],
),
migrations.CreateModel(
name="RidePairComparisonEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"ride_a_wins",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated ride_a higher",
),
),
(
"ride_b_wins",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated ride_b higher",
),
),
(
"ties",
models.PositiveIntegerField(
default=0,
help_text="Number of mutual riders who rated both rides equally",
),
),
(
"mutual_riders_count",
models.PositiveIntegerField(
default=0,
help_text="Total number of users who have rated both rides",
),
),
(
"ride_a_avg_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating of ride_a from mutual riders",
max_digits=3,
null=True,
),
),
(
"ride_b_avg_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating of ride_b from mutual riders",
max_digits=3,
null=True,
),
),
(
"last_calculated",
models.DateTimeField(
auto_now=True,
help_text="When this comparison was last calculated",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.ridepaircomparison",
),
),
(
"ride_a",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
(
"ride_b",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RideRanking",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"rank",
models.PositiveIntegerField(
db_index=True, help_text="Overall rank position (1 = best)"
),
),
(
"wins",
models.PositiveIntegerField(
default=0,
help_text="Number of rides this ride beats in pairwise comparisons",
),
),
(
"losses",
models.PositiveIntegerField(
default=0,
help_text="Number of rides that beat this ride in pairwise comparisons",
),
),
(
"ties",
models.PositiveIntegerField(
default=0,
help_text="Number of rides with equal preference in pairwise comparisons",
),
),
(
"winning_percentage",
models.DecimalField(
db_index=True,
decimal_places=4,
help_text="Win percentage where ties count as 0.5",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(1),
],
),
),
(
"mutual_riders_count",
models.PositiveIntegerField(
default=0,
help_text="Total number of users who have rated this ride",
),
),
(
"comparison_count",
models.PositiveIntegerField(
default=0,
help_text="Number of other rides this was compared against",
),
),
(
"average_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating from all users who have rated this ride",
max_digits=3,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(10),
],
),
),
(
"last_calculated",
models.DateTimeField(
default=django.utils.timezone.now,
help_text="When this ranking was last calculated",
),
),
(
"calculation_version",
models.CharField(
default="1.0",
help_text="Algorithm version used for calculation",
max_length=10,
),
),
(
"ride",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="ranking",
to="rides.ride",
),
),
],
options={
"ordering": ["rank"],
},
),
migrations.CreateModel(
name="RideRankingEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"rank",
models.PositiveIntegerField(
help_text="Overall rank position (1 = best)"
),
),
(
"wins",
models.PositiveIntegerField(
default=0,
help_text="Number of rides this ride beats in pairwise comparisons",
),
),
(
"losses",
models.PositiveIntegerField(
default=0,
help_text="Number of rides that beat this ride in pairwise comparisons",
),
),
(
"ties",
models.PositiveIntegerField(
default=0,
help_text="Number of rides with equal preference in pairwise comparisons",
),
),
(
"winning_percentage",
models.DecimalField(
decimal_places=4,
help_text="Win percentage where ties count as 0.5",
max_digits=5,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(1),
],
),
),
(
"mutual_riders_count",
models.PositiveIntegerField(
default=0,
help_text="Total number of users who have rated this ride",
),
),
(
"comparison_count",
models.PositiveIntegerField(
default=0,
help_text="Number of other rides this was compared against",
),
),
(
"average_rating",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Average rating from all users who have rated this ride",
max_digits=3,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(10),
],
),
),
(
"last_calculated",
models.DateTimeField(
default=django.utils.timezone.now,
help_text="When this ranking was last calculated",
),
),
(
"calculation_version",
models.CharField(
default="1.0",
help_text="Algorithm version used for calculation",
max_length=10,
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="rides.rideranking",
),
),
(
"ride",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RankingSnapshot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("rank", models.PositiveIntegerField()),
(
"winning_percentage",
models.DecimalField(decimal_places=4, max_digits=5),
),
(
"snapshot_date",
models.DateField(
db_index=True,
help_text="Date when this ranking snapshot was taken",
),
),
(
"ride",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ranking_history",
to="rides.ride",
),
),
],
options={
"ordering": ["-snapshot_date", "rank"],
"indexes": [
models.Index(
fields=["snapshot_date", "rank"],
name="rides_ranki_snapsho_8e2657_idx",
),
models.Index(
fields=["ride", "-snapshot_date"],
name="rides_ranki_ride_id_827bb9_idx",
),
],
"unique_together": {("ride", "snapshot_date")},
},
),
migrations.AddIndex(
model_name="ridepaircomparison",
index=models.Index(
fields=["ride_a", "ride_b"], name="rides_ridep_ride_a__eb0674_idx"
),
),
migrations.AddIndex(
model_name="ridepaircomparison",
index=models.Index(
fields=["last_calculated"], name="rides_ridep_last_ca_bd9f6c_idx"
),
),
migrations.AlterUniqueTogether(
name="ridepaircomparison",
unique_together={("ride_a", "ride_b")},
),
pgtrigger.migrations.AddTrigger(
model_name="ridepaircomparison",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridepaircomparisonevent" ("id", "last_calculated", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_a_avg_rating", "ride_a_id", "ride_a_wins", "ride_b_avg_rating", "ride_b_id", "ride_b_wins", "ties") VALUES (NEW."id", NEW."last_calculated", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_a_avg_rating", NEW."ride_a_id", NEW."ride_a_wins", NEW."ride_b_avg_rating", NEW."ride_b_id", NEW."ride_b_wins", NEW."ties"); RETURN NULL;',
hash="6a640e10fcfd58c48029ee5b84ea7f0826f50022",
operation="INSERT",
pgid="pgtrigger_insert_insert_9ad59",
table="rides_ridepaircomparison",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridepaircomparison",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridepaircomparisonevent" ("id", "last_calculated", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_a_avg_rating", "ride_a_id", "ride_a_wins", "ride_b_avg_rating", "ride_b_id", "ride_b_wins", "ties") VALUES (NEW."id", NEW."last_calculated", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_a_avg_rating", NEW."ride_a_id", NEW."ride_a_wins", NEW."ride_b_avg_rating", NEW."ride_b_id", NEW."ride_b_wins", NEW."ties"); RETURN NULL;',
hash="a77eee0b791bada3f84f008dabd7486c66b03fa6",
operation="UPDATE",
pgid="pgtrigger_update_update_73b31",
table="rides_ridepaircomparison",
when="AFTER",
),
),
),
migrations.AddIndex(
model_name="rideranking",
index=models.Index(fields=["rank"], name="rides_rider_rank_ea4706_idx"),
),
migrations.AddIndex(
model_name="rideranking",
index=models.Index(
fields=["winning_percentage", "-mutual_riders_count"],
name="rides_rider_winning_d9b3e8_idx",
),
),
migrations.AddIndex(
model_name="rideranking",
index=models.Index(
fields=["ride", "last_calculated"],
name="rides_rider_ride_id_ece73d_idx",
),
),
migrations.AddConstraint(
model_name="rideranking",
constraint=models.CheckConstraint(
condition=models.Q(
("winning_percentage__gte", 0), ("winning_percentage__lte", 1)
),
name="rideranking_winning_percentage_range",
violation_error_message="Winning percentage must be between 0 and 1",
),
),
migrations.AddConstraint(
model_name="rideranking",
constraint=models.CheckConstraint(
condition=models.Q(
("average_rating__isnull", True),
models.Q(("average_rating__gte", 1), ("average_rating__lte", 10)),
_connector="OR",
),
name="rideranking_average_rating_range",
violation_error_message="Average rating must be between 1 and 10",
),
),
pgtrigger.migrations.AddTrigger(
model_name="rideranking",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_riderankingevent" ("average_rating", "calculation_version", "comparison_count", "id", "last_calculated", "losses", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "ride_id", "ties", "winning_percentage", "wins") VALUES (NEW."average_rating", NEW."calculation_version", NEW."comparison_count", NEW."id", NEW."last_calculated", NEW."losses", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."ride_id", NEW."ties", NEW."winning_percentage", NEW."wins"); RETURN NULL;',
hash="c5f9dced5824a55e6f36e476eb382ed770aa5716",
operation="INSERT",
pgid="pgtrigger_insert_insert_01af3",
table="rides_rideranking",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="rideranking",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_riderankingevent" ("average_rating", "calculation_version", "comparison_count", "id", "last_calculated", "losses", "mutual_riders_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "ride_id", "ties", "winning_percentage", "wins") VALUES (NEW."average_rating", NEW."calculation_version", NEW."comparison_count", NEW."id", NEW."last_calculated", NEW."losses", NEW."mutual_riders_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."ride_id", NEW."ties", NEW."winning_percentage", NEW."wins"); RETURN NULL;',
hash="363e44ce3c87e8b66406d63d6f1b26ad604c79d2",
operation="UPDATE",
pgid="pgtrigger_update_update_c3f27",
table="rides_rideranking",
when="AFTER",
),
),
),
]

View File

@@ -8,9 +8,10 @@ The Company model is aliased as Manufacturer to clarify its role as ride manufac
while maintaining backward compatibility through the Company alias.
"""
from .rides import Ride, RideModel, RollerCoasterStats, Categories
from .rides import Ride, RideModel, RollerCoasterStats, Categories, CATEGORY_CHOICES
from .location import RideLocation
from .reviews import RideReview
from .rankings import RideRanking, RidePairComparison, RankingSnapshot
__all__ = [
# Primary models
@@ -19,6 +20,10 @@ __all__ = [
"RollerCoasterStats",
"RideLocation",
"RideReview",
# Rankings
"RideRanking",
"RidePairComparison",
"RankingSnapshot",
# Shared constants
"Categories",
]

View File

@@ -0,0 +1,212 @@
"""
Models for ride ranking system using Internet Roller Coaster Poll algorithm.
This system calculates rankings based on pairwise comparisons between rides,
where each ride is compared to every other ride to determine which one
more riders preferred.
"""
from django.db import models
from django.utils import timezone
from django.core.validators import MinValueValidator, MaxValueValidator
import pghistory
@pghistory.track()
class RideRanking(models.Model):
"""
Stores calculated rankings for rides using the Internet Roller Coaster Poll algorithm.
Rankings are recalculated daily based on user reviews/ratings.
Each ride's rank is determined by its winning percentage in pairwise comparisons.
"""
ride = models.OneToOneField(
"rides.Ride", on_delete=models.CASCADE, related_name="ranking"
)
# Core ranking metrics
rank = models.PositiveIntegerField(
db_index=True, help_text="Overall rank position (1 = best)"
)
wins = models.PositiveIntegerField(
default=0, help_text="Number of rides this ride beats in pairwise comparisons"
)
losses = models.PositiveIntegerField(
default=0,
help_text="Number of rides that beat this ride in pairwise comparisons",
)
ties = models.PositiveIntegerField(
default=0,
help_text="Number of rides with equal preference in pairwise comparisons",
)
winning_percentage = models.DecimalField(
max_digits=5,
decimal_places=4,
validators=[MinValueValidator(0), MaxValueValidator(1)],
db_index=True,
help_text="Win percentage where ties count as 0.5",
)
# Additional metrics
mutual_riders_count = models.PositiveIntegerField(
default=0, help_text="Total number of users who have rated this ride"
)
comparison_count = models.PositiveIntegerField(
default=0, help_text="Number of other rides this was compared against"
)
average_rating = models.DecimalField(
max_digits=3,
decimal_places=2,
null=True,
blank=True,
validators=[MinValueValidator(1), MaxValueValidator(10)],
help_text="Average rating from all users who have rated this ride",
)
# Metadata
last_calculated = models.DateTimeField(
default=timezone.now, help_text="When this ranking was last calculated"
)
calculation_version = models.CharField(
max_length=10, default="1.0", help_text="Algorithm version used for calculation"
)
class Meta:
ordering = ["rank"]
indexes = [
models.Index(fields=["rank"]),
models.Index(fields=["winning_percentage", "-mutual_riders_count"]),
models.Index(fields=["ride", "last_calculated"]),
]
constraints = [
models.CheckConstraint(
name="rideranking_winning_percentage_range",
check=models.Q(winning_percentage__gte=0)
& models.Q(winning_percentage__lte=1),
violation_error_message="Winning percentage must be between 0 and 1",
),
models.CheckConstraint(
name="rideranking_average_rating_range",
check=models.Q(average_rating__isnull=True)
| (models.Q(average_rating__gte=1) & models.Q(average_rating__lte=10)),
violation_error_message="Average rating must be between 1 and 10",
),
]
def __str__(self):
return f"#{self.rank} - {self.ride.name} ({self.winning_percentage:.1%})"
@property
def total_comparisons(self):
"""Total number of pairwise comparisons (wins + losses + ties)."""
return self.wins + self.losses + self.ties
@pghistory.track()
class RidePairComparison(models.Model):
"""
Caches pairwise comparison results between two rides.
This model stores the results of comparing two rides based on mutual riders
(users who have rated both rides). It's used to speed up ranking calculations.
"""
ride_a = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_a"
)
ride_b = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="comparisons_as_b"
)
# Comparison results
ride_a_wins = models.PositiveIntegerField(
default=0, help_text="Number of mutual riders who rated ride_a higher"
)
ride_b_wins = models.PositiveIntegerField(
default=0, help_text="Number of mutual riders who rated ride_b higher"
)
ties = models.PositiveIntegerField(
default=0, help_text="Number of mutual riders who rated both rides equally"
)
# Metrics
mutual_riders_count = models.PositiveIntegerField(
default=0, help_text="Total number of users who have rated both rides"
)
ride_a_avg_rating = models.DecimalField(
max_digits=3,
decimal_places=2,
null=True,
blank=True,
help_text="Average rating of ride_a from mutual riders",
)
ride_b_avg_rating = models.DecimalField(
max_digits=3,
decimal_places=2,
null=True,
blank=True,
help_text="Average rating of ride_b from mutual riders",
)
# Metadata
last_calculated = models.DateTimeField(
auto_now=True, help_text="When this comparison was last calculated"
)
class Meta:
unique_together = [["ride_a", "ride_b"]]
indexes = [
models.Index(fields=["ride_a", "ride_b"]),
models.Index(fields=["last_calculated"]),
]
def __str__(self):
winner = "TIE"
if self.ride_a_wins > self.ride_b_wins:
winner = self.ride_a.name
elif self.ride_b_wins > self.ride_a_wins:
winner = self.ride_b.name
return f"{self.ride_a.name} vs {self.ride_b.name} - Winner: {winner}"
@property
def winner(self):
"""Returns the winning ride or None for a tie."""
if self.ride_a_wins > self.ride_b_wins:
return self.ride_a
elif self.ride_b_wins > self.ride_a_wins:
return self.ride_b
return None
@property
def is_tie(self):
"""Returns True if the comparison resulted in a tie."""
return self.ride_a_wins == self.ride_b_wins
class RankingSnapshot(models.Model):
"""
Stores historical snapshots of rankings for tracking changes over time.
This allows us to show ranking trends and movements.
"""
ride = models.ForeignKey(
"rides.Ride", on_delete=models.CASCADE, related_name="ranking_history"
)
rank = models.PositiveIntegerField()
winning_percentage = models.DecimalField(max_digits=5, decimal_places=4)
snapshot_date = models.DateField(
db_index=True, help_text="Date when this ranking snapshot was taken"
)
class Meta:
unique_together = [["ride", "snapshot_date"]]
ordering = ["-snapshot_date", "rank"]
indexes = [
models.Index(fields=["snapshot_date", "rank"]),
models.Index(fields=["ride", "-snapshot_date"]),
]
def __str__(self):
return f"{self.ride.name} - Rank #{self.rank} on {self.snapshot_date}"

View File

@@ -1,6 +1,7 @@
from django.db import models
from django.utils.text import slugify
from django.contrib.contenttypes.fields import GenericRelation
from django.db.models import Avg
from apps.core.models import TrackedModel
from .company import Company
import pghistory
@@ -56,7 +57,11 @@ class RideModel(TrackedModel):
@pghistory.track()
class Ride(TrackedModel):
"""Model for individual ride installations at parks"""
"""Model for individual ride installations at parks
Note: The average_rating field is denormalized and refreshed by background
jobs. Use selectors or annotations for real-time calculations if needed.
"""
STATUS_CHOICES = [
("", "Select status"),

View File

@@ -8,7 +8,7 @@ from django.db.models import QuerySet, Q, Count, Avg, Prefetch
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import Distance
from .models import Ride, RideModel, RideReview
from .models import Ride, RideModel, RideReview, CATEGORY_CHOICES
def ride_list_for_display(
@@ -32,15 +32,15 @@ def ride_list_for_display(
"ride_model",
"park_area",
)
.prefetch_related("park__location", "location")
.prefetch_related("park__location")
.annotate(average_rating_calculated=Avg("reviews__rating"))
)
if filters:
if "status" in filters:
queryset = queryset.filter(status=filters["status"])
if "category" in filters:
queryset = queryset.filter(category=filters["category"])
if "status" in filters and filters["status"]:
queryset = queryset.filter(status__in=filters["status"])
if "category" in filters and filters["category"]:
queryset = queryset.filter(category__in=filters["category"])
if "manufacturer" in filters:
queryset = queryset.filter(manufacturer=filters["manufacturer"])
if "park" in filters:
@@ -81,7 +81,6 @@ def ride_detail_optimized(*, slug: str, park_slug: str) -> Ride:
)
.prefetch_related(
"park__location",
"location",
Prefetch(
"reviews",
queryset=RideReview.objects.select_related("user").filter(
@@ -164,7 +163,7 @@ def rides_in_park(*, park_slug: str) -> QuerySet[Ride]:
return (
Ride.objects.filter(park__slug=park_slug)
.select_related("manufacturer", "designer", "ride_model", "park_area")
.prefetch_related("location")
.prefetch_related()
.annotate(average_rating_calculated=Avg("reviews__rating"))
.order_by("park_area__name", "name")
)

View File

@@ -0,0 +1,7 @@
"""
Services for the rides app.
"""
from .ranking_service import RideRankingService
__all__ = ["RideRankingService"]

View File

@@ -0,0 +1,550 @@
"""
Service for calculating ride rankings using the Internet Roller Coaster Poll algorithm.
This service implements a pairwise comparison system where each ride is compared
to every other ride based on mutual riders (users who have rated both rides).
Rankings are determined by winning percentage in these comparisons.
"""
import logging
from typing import Dict, List, Tuple, Optional
from decimal import Decimal
from datetime import date
from django.db import transaction
from django.db.models import Avg, Count, Q, F
from django.utils import timezone
from apps.rides.models import (
Ride,
RideReview,
RideRanking,
RidePairComparison,
RankingSnapshot,
)
logger = logging.getLogger(__name__)
class RideRankingService:
"""
Calculates ride rankings using the Internet Roller Coaster Poll algorithm.
Algorithm Overview:
1. For each pair of rides, find users who have rated both
2. Count how many users preferred each ride (higher rating)
3. Calculate wins, losses, and ties for each ride
4. Rank rides by winning percentage (ties count as 0.5 wins)
5. Break ties by head-to-head comparison
"""
def __init__(self):
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
self.calculation_version = "1.0"
def update_all_rankings(self, category: Optional[str] = None) -> Dict[str, any]:
"""
Main entry point to update all ride rankings.
Args:
category: Optional ride category to filter ('RC' for roller coasters, etc.)
If None, ranks all rides.
Returns:
Dictionary with statistics about the ranking calculation
"""
start_time = timezone.now()
self.logger.info(
f"Starting ranking calculation for category: {category or 'ALL'}"
)
try:
with transaction.atomic():
# Get rides to rank
rides = self._get_eligible_rides(category)
if not rides:
self.logger.warning("No eligible rides found for ranking")
return {
"status": "skipped",
"message": "No eligible rides found",
"duration": (timezone.now() - start_time).total_seconds(),
}
self.logger.info(f"Found {len(rides)} rides to rank")
# Calculate pairwise comparisons
comparisons = self._calculate_all_comparisons(rides)
# Calculate rankings from comparisons
rankings = self._calculate_rankings_from_comparisons(rides, comparisons)
# Save rankings
self._save_rankings(rankings)
# Save snapshots for historical tracking
self._save_ranking_snapshots(rankings)
# Clean up old data
self._cleanup_old_data()
duration = (timezone.now() - start_time).total_seconds()
self.logger.info(
f"Ranking calculation completed in {duration:.2f} seconds"
)
return {
"status": "success",
"rides_ranked": len(rides),
"comparisons_made": len(comparisons),
"duration": duration,
"timestamp": timezone.now(),
}
except Exception as e:
self.logger.error(f"Error updating rankings: {e}", exc_info=True)
raise
def _get_eligible_rides(self, category: Optional[str] = None) -> List[Ride]:
"""
Get rides that are eligible for ranking.
Only includes rides that:
- Are currently operating
- Have at least one review/rating
"""
queryset = (
Ride.objects.filter(status="OPERATING", reviews__is_published=True)
.annotate(
review_count=Count("reviews", filter=Q(reviews__is_published=True))
)
.filter(review_count__gt=0)
)
if category:
queryset = queryset.filter(category=category)
return list(queryset.distinct())
def _calculate_all_comparisons(
self, rides: List[Ride]
) -> Dict[Tuple[int, int], RidePairComparison]:
"""
Calculate pairwise comparisons for all ride pairs.
Returns a dictionary keyed by (ride_a_id, ride_b_id) tuples.
"""
comparisons = {}
total_pairs = len(rides) * (len(rides) - 1) // 2
processed = 0
for i, ride_a in enumerate(rides):
for ride_b in rides[i + 1 :]:
comparison = self._calculate_pairwise_comparison(ride_a, ride_b)
if comparison:
# Store both directions for easy lookup
comparisons[(ride_a.id, ride_b.id)] = comparison
comparisons[(ride_b.id, ride_a.id)] = comparison
processed += 1
if processed % 100 == 0:
self.logger.debug(
f"Processed {processed}/{total_pairs} comparisons"
)
return comparisons
def _calculate_pairwise_comparison(
self, ride_a: Ride, ride_b: Ride
) -> Optional[RidePairComparison]:
"""
Calculate the pairwise comparison between two rides.
Finds users who have rated both rides and determines which ride
they preferred based on their ratings.
"""
# Get mutual riders (users who have rated both rides)
ride_a_reviewers = set(
RideReview.objects.filter(ride=ride_a, is_published=True).values_list(
"user_id", flat=True
)
)
ride_b_reviewers = set(
RideReview.objects.filter(ride=ride_b, is_published=True).values_list(
"user_id", flat=True
)
)
mutual_riders = ride_a_reviewers & ride_b_reviewers
if not mutual_riders:
# No mutual riders, no comparison possible
return None
# Get ratings from mutual riders
ride_a_ratings = {
review.user_id: review.rating
for review in RideReview.objects.filter(
ride=ride_a, user_id__in=mutual_riders, is_published=True
)
}
ride_b_ratings = {
review.user_id: review.rating
for review in RideReview.objects.filter(
ride=ride_b, user_id__in=mutual_riders, is_published=True
)
}
# Count wins and ties
ride_a_wins = 0
ride_b_wins = 0
ties = 0
for user_id in mutual_riders:
rating_a = ride_a_ratings.get(user_id, 0)
rating_b = ride_b_ratings.get(user_id, 0)
if rating_a > rating_b:
ride_a_wins += 1
elif rating_b > rating_a:
ride_b_wins += 1
else:
ties += 1
# Calculate average ratings from mutual riders
ride_a_avg = (
sum(ride_a_ratings.values()) / len(ride_a_ratings) if ride_a_ratings else 0
)
ride_b_avg = (
sum(ride_b_ratings.values()) / len(ride_b_ratings) if ride_b_ratings else 0
)
# Create or update comparison record
comparison, created = RidePairComparison.objects.update_or_create(
ride_a=ride_a if ride_a.id < ride_b.id else ride_b,
ride_b=ride_b if ride_a.id < ride_b.id else ride_a,
defaults={
"ride_a_wins": ride_a_wins if ride_a.id < ride_b.id else ride_b_wins,
"ride_b_wins": ride_b_wins if ride_a.id < ride_b.id else ride_a_wins,
"ties": ties,
"mutual_riders_count": len(mutual_riders),
"ride_a_avg_rating": (
Decimal(str(ride_a_avg))
if ride_a.id < ride_b.id
else Decimal(str(ride_b_avg))
),
"ride_b_avg_rating": (
Decimal(str(ride_b_avg))
if ride_a.id < ride_b.id
else Decimal(str(ride_a_avg))
),
},
)
return comparison
def _calculate_rankings_from_comparisons(
self, rides: List[Ride], comparisons: Dict[Tuple[int, int], RidePairComparison]
) -> List[Dict]:
"""
Calculate final rankings from pairwise comparisons.
Returns a list of dictionaries containing ranking data for each ride.
"""
rankings = []
for ride in rides:
wins = 0
losses = 0
ties = 0
comparison_count = 0
# Count wins, losses, and ties
for other_ride in rides:
if ride.id == other_ride.id:
continue
comparison_key = (
min(ride.id, other_ride.id),
max(ride.id, other_ride.id),
)
comparison = comparisons.get(comparison_key)
if not comparison:
continue
comparison_count += 1
# Determine win/loss/tie for this ride
if comparison.ride_a_id == ride.id:
if comparison.ride_a_wins > comparison.ride_b_wins:
wins += 1
elif comparison.ride_a_wins < comparison.ride_b_wins:
losses += 1
else:
ties += 1
else: # ride_b_id == ride.id
if comparison.ride_b_wins > comparison.ride_a_wins:
wins += 1
elif comparison.ride_b_wins < comparison.ride_a_wins:
losses += 1
else:
ties += 1
# Calculate winning percentage (ties count as 0.5)
total_comparisons = wins + losses + ties
if total_comparisons > 0:
winning_percentage = Decimal(
str((wins + 0.5 * ties) / total_comparisons)
)
else:
winning_percentage = Decimal("0.5")
# Get average rating and reviewer count
ride_stats = RideReview.objects.filter(
ride=ride, is_published=True
).aggregate(
avg_rating=Avg("rating"), reviewer_count=Count("user", distinct=True)
)
rankings.append(
{
"ride": ride,
"wins": wins,
"losses": losses,
"ties": ties,
"winning_percentage": winning_percentage,
"comparison_count": comparison_count,
"average_rating": ride_stats["avg_rating"],
"mutual_riders_count": ride_stats["reviewer_count"] or 0,
}
)
# Sort by winning percentage (descending), then by mutual riders count for ties
rankings.sort(
key=lambda x: (
x["winning_percentage"],
x["mutual_riders_count"],
x["average_rating"] or 0,
),
reverse=True,
)
# Handle tie-breaking with head-to-head comparisons
rankings = self._apply_tiebreakers(rankings, comparisons)
# Assign final ranks
for i, ranking_data in enumerate(rankings, 1):
ranking_data["rank"] = i
return rankings
def _apply_tiebreakers(
self,
rankings: List[Dict],
comparisons: Dict[Tuple[int, int], RidePairComparison],
) -> List[Dict]:
"""
Apply head-to-head tiebreaker for rides with identical winning percentages.
If two rides have the same winning percentage, the one that beat the other
in their head-to-head comparison gets the higher rank.
"""
i = 0
while i < len(rankings) - 1:
# Find rides with same winning percentage
tied_group = [rankings[i]]
j = i + 1
while (
j < len(rankings)
and rankings[j]["winning_percentage"]
== rankings[i]["winning_percentage"]
):
tied_group.append(rankings[j])
j += 1
if len(tied_group) > 1:
# Apply head-to-head tiebreaker within the group
tied_group = self._sort_tied_group(tied_group, comparisons)
# Replace the tied section with sorted group
rankings[i:j] = tied_group
i = j
return rankings
def _sort_tied_group(
self,
tied_group: List[Dict],
comparisons: Dict[Tuple[int, int], RidePairComparison],
) -> List[Dict]:
"""
Sort a group of tied rides using head-to-head comparisons.
"""
# Create mini-rankings within the tied group
for ride_data in tied_group:
mini_wins = 0
mini_losses = 0
for other_data in tied_group:
if ride_data["ride"].id == other_data["ride"].id:
continue
comparison_key = (
min(ride_data["ride"].id, other_data["ride"].id),
max(ride_data["ride"].id, other_data["ride"].id),
)
comparison = comparisons.get(comparison_key)
if comparison:
if comparison.ride_a_id == ride_data["ride"].id:
if comparison.ride_a_wins > comparison.ride_b_wins:
mini_wins += 1
elif comparison.ride_a_wins < comparison.ride_b_wins:
mini_losses += 1
else:
if comparison.ride_b_wins > comparison.ride_a_wins:
mini_wins += 1
elif comparison.ride_b_wins < comparison.ride_a_wins:
mini_losses += 1
ride_data["tiebreaker_score"] = mini_wins - mini_losses
# Sort by tiebreaker score, then by mutual riders count, then by average rating
tied_group.sort(
key=lambda x: (
x["tiebreaker_score"],
x["mutual_riders_count"],
x["average_rating"] or 0,
),
reverse=True,
)
return tied_group
def _save_rankings(self, rankings: List[Dict]):
"""Save calculated rankings to the database."""
for ranking_data in rankings:
RideRanking.objects.update_or_create(
ride=ranking_data["ride"],
defaults={
"rank": ranking_data["rank"],
"wins": ranking_data["wins"],
"losses": ranking_data["losses"],
"ties": ranking_data["ties"],
"winning_percentage": ranking_data["winning_percentage"],
"mutual_riders_count": ranking_data["mutual_riders_count"],
"comparison_count": ranking_data["comparison_count"],
"average_rating": ranking_data["average_rating"],
"last_calculated": timezone.now(),
"calculation_version": self.calculation_version,
},
)
def _save_ranking_snapshots(self, rankings: List[Dict]):
"""Save ranking snapshots for historical tracking."""
today = date.today()
for ranking_data in rankings:
RankingSnapshot.objects.update_or_create(
ride=ranking_data["ride"],
snapshot_date=today,
defaults={
"rank": ranking_data["rank"],
"winning_percentage": ranking_data["winning_percentage"],
},
)
def _cleanup_old_data(self, days_to_keep: int = 365):
"""Clean up old comparison and snapshot data."""
cutoff_date = timezone.now() - timezone.timedelta(days=days_to_keep)
# Delete old snapshots
deleted_snapshots = RankingSnapshot.objects.filter(
snapshot_date__lt=cutoff_date.date()
).delete()
if deleted_snapshots[0] > 0:
self.logger.info(f"Deleted {deleted_snapshots[0]} old ranking snapshots")
def get_ride_ranking_details(self, ride: Ride) -> Optional[Dict]:
"""
Get detailed ranking information for a specific ride.
Returns dictionary with ranking details or None if not ranked.
"""
try:
ranking = RideRanking.objects.get(ride=ride)
# Get recent head-to-head comparisons
comparisons = (
RidePairComparison.objects.filter(Q(ride_a=ride) | Q(ride_b=ride))
.select_related("ride_a", "ride_b")
.order_by("-mutual_riders_count")[:10]
)
# Get ranking history
history = RankingSnapshot.objects.filter(ride=ride).order_by(
"-snapshot_date"
)[:30]
return {
"current_rank": ranking.rank,
"winning_percentage": ranking.winning_percentage,
"wins": ranking.wins,
"losses": ranking.losses,
"ties": ranking.ties,
"average_rating": ranking.average_rating,
"mutual_riders_count": ranking.mutual_riders_count,
"last_calculated": ranking.last_calculated,
"head_to_head": [
{
"opponent": (
comp.ride_b if comp.ride_a_id == ride.id else comp.ride_a
),
"result": (
"win"
if (
(
comp.ride_a_id == ride.id
and comp.ride_a_wins > comp.ride_b_wins
)
or (
comp.ride_b_id == ride.id
and comp.ride_b_wins > comp.ride_a_wins
)
)
else (
"loss"
if (
(
comp.ride_a_id == ride.id
and comp.ride_a_wins < comp.ride_b_wins
)
or (
comp.ride_b_id == ride.id
and comp.ride_b_wins < comp.ride_a_wins
)
)
else "tie"
)
),
"mutual_riders": comp.mutual_riders_count,
}
for comp in comparisons
],
"ranking_history": [
{
"date": snapshot.snapshot_date,
"rank": snapshot.rank,
"winning_percentage": snapshot.winning_percentage,
}
for snapshot in history
],
}
except RideRanking.DoesNotExist:
return None

View File

@@ -53,6 +53,23 @@ urlpatterns = [
views.get_search_suggestions,
name="search_suggestions",
),
# Ranking endpoints
path("rankings/", views.RideRankingsView.as_view(), name="rankings"),
path(
"rankings/<slug:ride_slug>/",
views.RideRankingDetailView.as_view(),
name="ranking_detail",
),
path(
"rankings/<slug:ride_slug>/history-chart/",
views.ranking_history_chart,
name="ranking_history_chart",
),
path(
"rankings/<slug:ride_slug>/comparisons/",
views.ranking_comparisons,
name="ranking_comparisons",
),
# Park-specific URLs
path("create/", views.RideCreateView.as_view(), name="ride_create"),
path("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),

View File

@@ -12,6 +12,8 @@ from .forms import RideForm, RideSearchForm
from apps.parks.models import Park
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
from apps.moderation.models import EditSubmission
from .models.rankings import RideRanking, RankingSnapshot
from .services.ranking_service import RideRankingService
class ParkContextRequired:
@@ -452,3 +454,166 @@ class RideSearchView(ListView):
context = super().get_context_data(**kwargs)
context["search_form"] = RideSearchForm(self.request.GET)
return context
class RideRankingsView(ListView):
"""View for displaying ride rankings using the Internet Roller Coaster Poll algorithm."""
model = RideRanking
template_name = "rides/rankings.html"
context_object_name = "rankings"
paginate_by = 50
def get_queryset(self):
"""Get rankings with optimized queries."""
queryset = RideRanking.objects.select_related(
"ride", "ride__park", "ride__manufacturer", "ride__ride_model"
).order_by("rank")
# Filter by category if specified
category = self.request.GET.get("category")
if category and category != "all":
queryset = queryset.filter(ride__category=category)
# Filter by minimum mutual riders
min_riders = self.request.GET.get("min_riders")
if min_riders:
try:
min_riders = int(min_riders)
queryset = queryset.filter(mutual_riders_count__gte=min_riders)
except ValueError:
pass
return queryset
def get_template_names(self):
"""Return appropriate template based on request type."""
if self.request.htmx:
return ["rides/partials/rankings_table.html"]
return [self.template_name]
def get_context_data(self, **kwargs):
"""Add context for rankings view."""
context = super().get_context_data(**kwargs)
context["category_choices"] = Categories
context["selected_category"] = self.request.GET.get("category", "all")
context["min_riders"] = self.request.GET.get("min_riders", "")
# Add statistics
if self.object_list:
context["total_ranked"] = RideRanking.objects.count()
context["last_updated"] = (
self.object_list[0].last_calculated if self.object_list else None
)
return context
class RideRankingDetailView(DetailView):
"""View for displaying detailed ranking information for a specific ride."""
model = Ride
template_name = "rides/ranking_detail.html"
slug_url_kwarg = "ride_slug"
def get_queryset(self):
"""Get ride with ranking data."""
return Ride.objects.select_related(
"park", "manufacturer", "ranking"
).prefetch_related("comparisons_as_a", "comparisons_as_b", "ranking_history")
def get_context_data(self, **kwargs):
"""Add ranking details to context."""
context = super().get_context_data(**kwargs)
# Get ranking details from service
service = RideRankingService()
ranking_details = service.get_ride_ranking_details(self.object)
if ranking_details:
context.update(ranking_details)
# Get recent movement
recent_snapshots = RankingSnapshot.objects.filter(
ride=self.object
).order_by("-snapshot_date")[:7]
if len(recent_snapshots) >= 2:
context["rank_change"] = (
recent_snapshots[0].rank - recent_snapshots[1].rank
)
context["previous_rank"] = recent_snapshots[1].rank
else:
context["not_ranked"] = True
return context
def ranking_history_chart(request: HttpRequest, ride_slug: str) -> HttpResponse:
"""HTMX endpoint for ranking history chart data."""
ride = get_object_or_404(Ride, slug=ride_slug)
# Get last 30 days of ranking history
history = RankingSnapshot.objects.filter(ride=ride).order_by("-snapshot_date")[:30]
# Prepare data for chart
chart_data = [
{
"date": snapshot.snapshot_date.isoformat(),
"rank": snapshot.rank,
"win_pct": float(snapshot.winning_percentage) * 100,
}
for snapshot in reversed(history)
]
return render(
request,
"rides/partials/ranking_chart.html",
{"chart_data": chart_data, "ride": ride},
)
def ranking_comparisons(request: HttpRequest, ride_slug: str) -> HttpResponse:
"""HTMX endpoint for ride head-to-head comparisons."""
ride = get_object_or_404(Ride, slug=ride_slug)
# Get head-to-head comparisons
from django.db.models import Q
from .models.rankings import RidePairComparison
comparisons = (
RidePairComparison.objects.filter(Q(ride_a=ride) | Q(ride_b=ride))
.select_related("ride_a", "ride_b", "ride_a__park", "ride_b__park")
.order_by("-mutual_riders_count")[:20]
)
# Format comparisons for display
comparison_data = []
for comp in comparisons:
if comp.ride_a == ride:
opponent = comp.ride_b
wins = comp.ride_a_wins
losses = comp.ride_b_wins
else:
opponent = comp.ride_a
wins = comp.ride_b_wins
losses = comp.ride_a_wins
result = "win" if wins > losses else "loss" if losses > wins else "tie"
comparison_data.append(
{
"opponent": opponent,
"wins": wins,
"losses": losses,
"ties": comp.ties,
"result": result,
"mutual_riders": comp.mutual_riders_count,
}
)
return render(
request,
"rides/partials/ranking_comparisons.html",
{"comparisons": comparison_data, "ride": ride},
)