mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 08:51:09 -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:
@@ -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)
|
||||
|
||||
0
backend/apps/rides/management/__init__.py
Normal file
0
backend/apps/rides/management/__init__.py
Normal file
0
backend/apps/rides/management/commands/__init__.py
Normal file
0
backend/apps/rides/management/commands/__init__.py
Normal 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"
|
||||
)
|
||||
)
|
||||
603
backend/apps/rides/migrations/0006_add_ride_rankings.py
Normal file
603
backend/apps/rides/migrations/0006_add_ride_rankings.py
Normal 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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
212
backend/apps/rides/models/rankings.py
Normal file
212
backend/apps/rides/models/rankings.py
Normal 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}"
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
7
backend/apps/rides/services/__init__.py
Normal file
7
backend/apps/rides/services/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Services for the rides app.
|
||||
"""
|
||||
|
||||
from .ranking_service import RideRankingService
|
||||
|
||||
__all__ = ["RideRankingService"]
|
||||
550
backend/apps/rides/services/ranking_service.py
Normal file
550
backend/apps/rides/services/ranking_service.py
Normal 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
|
||||
@@ -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"),
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user