Implement reviews and voting system

- Added Review model with fields for user, content type, title, content, rating, visit metadata, helpful votes, moderation status, and timestamps.
- Created ReviewHelpfulVote model to track user votes on reviews.
- Implemented moderation workflow for reviews with approve and reject methods.
- Developed admin interface for managing reviews and helpful votes, including custom display methods and actions for bulk approval/rejection.
- Added migrations for the new models and their relationships.
- Ensured unique constraints and indexes for efficient querying.
This commit is contained in:
pacnpal
2025-11-08 15:50:43 -05:00
parent d6ff4cc3a3
commit 00985eac8d
17 changed files with 1726 additions and 1 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,215 @@
from django.contrib import admin
from django.utils.html import format_html
from unfold.admin import ModelAdmin
from unfold.decorators import display
from .models import Review, ReviewHelpfulVote
@admin.register(Review)
class ReviewAdmin(ModelAdmin):
list_display = [
'id',
'user_link',
'entity_type',
'entity_link',
'rating_display',
'title',
'moderation_status_badge',
'helpful_score',
'created',
]
list_filter = [
'moderation_status',
'rating',
'created',
'content_type',
]
search_fields = [
'title',
'content',
'user__username',
'user__email',
]
readonly_fields = [
'user',
'content_type',
'object_id',
'content_object',
'helpful_votes',
'total_votes',
'helpful_percentage',
'created',
'modified',
]
fieldsets = (
('Review Information', {
'fields': (
'user',
'content_type',
'object_id',
'content_object',
'title',
'content',
'rating',
)
}),
('Visit Details', {
'fields': (
'visit_date',
'wait_time_minutes',
)
}),
('Voting Statistics', {
'fields': (
'helpful_votes',
'total_votes',
'helpful_percentage',
)
}),
('Moderation', {
'fields': (
'moderation_status',
'moderation_notes',
'moderated_by',
'moderated_at',
)
}),
('Timestamps', {
'fields': (
'created',
'modified',
)
}),
)
list_per_page = 50
@display(description='User', ordering='user__username')
def user_link(self, obj):
from django.urls import reverse
url = reverse('admin:users_user_change', args=[obj.user.pk])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
@display(description='Entity Type', ordering='content_type')
def entity_type(self, obj):
return obj.content_type.model.title()
@display(description='Entity')
def entity_link(self, obj):
if obj.content_object:
from django.urls import reverse
model_name = obj.content_type.model
url = reverse(f'admin:entities_{model_name}_change', args=[obj.object_id])
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
return f"ID: {obj.object_id}"
@display(description='Rating', ordering='rating')
def rating_display(self, obj):
stars = '' * obj.rating
return format_html('<span title="{}/5">{}</span>', obj.rating, stars)
@display(description='Status', ordering='moderation_status')
def moderation_status_badge(self, obj):
colors = {
'pending': '#FFA500',
'approved': '#28A745',
'rejected': '#DC3545',
}
color = colors.get(obj.moderation_status, '#6C757D')
return format_html(
'<span style="background-color: {}; color: white; padding: 3px 10px; '
'border-radius: 3px; font-weight: bold;">{}</span>',
color,
obj.get_moderation_status_display()
)
@display(description='Helpful Score')
def helpful_score(self, obj):
if obj.total_votes == 0:
return "No votes yet"
percentage = obj.helpful_percentage
return f"{obj.helpful_votes}/{obj.total_votes} ({percentage:.0f}%)"
def has_add_permission(self, request):
# Reviews should only be created by users via API
return False
def has_delete_permission(self, request, obj=None):
# Only superusers can delete reviews
return request.user.is_superuser
actions = ['approve_reviews', 'reject_reviews']
@admin.action(description='Approve selected reviews')
def approve_reviews(self, request, queryset):
count = 0
for review in queryset.filter(moderation_status='pending'):
review.approve(request.user, 'Bulk approved from admin')
count += 1
self.message_user(request, f'{count} reviews approved.')
@admin.action(description='Reject selected reviews')
def reject_reviews(self, request, queryset):
count = 0
for review in queryset.filter(moderation_status='pending'):
review.reject(request.user, 'Bulk rejected from admin')
count += 1
self.message_user(request, f'{count} reviews rejected.')
@admin.register(ReviewHelpfulVote)
class ReviewHelpfulVoteAdmin(ModelAdmin):
list_display = [
'id',
'review_link',
'user_link',
'vote_type',
'created',
]
list_filter = [
'is_helpful',
'created',
]
search_fields = [
'review__title',
'user__username',
'user__email',
]
readonly_fields = [
'review',
'user',
'is_helpful',
'created',
'modified',
]
list_per_page = 50
@display(description='Review', ordering='review__title')
def review_link(self, obj):
from django.urls import reverse
url = reverse('admin:reviews_review_change', args=[obj.review.pk])
return format_html('<a href="{}">{}</a>', url, obj.review.title)
@display(description='User', ordering='user__username')
def user_link(self, obj):
from django.urls import reverse
url = reverse('admin:users_user_change', args=[obj.user.pk])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
@display(description='Vote', ordering='is_helpful')
def vote_type(self, obj):
if obj.is_helpful:
return format_html('<span style="color: green;">👍 Helpful</span>')
else:
return format_html('<span style="color: red;">👎 Not Helpful</span>')
def has_add_permission(self, request):
# Votes should only be created by users via API
return False
def has_change_permission(self, request, obj=None):
# Votes should not be changed after creation
return False
def has_delete_permission(self, request, obj=None):
# Only superusers can delete votes
return request.user.is_superuser

View File

@@ -0,0 +1,225 @@
# Generated by Django 4.2.8 on 2025-11-08 20:44
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Review",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
("object_id", models.PositiveIntegerField()),
("title", models.CharField(max_length=200)),
("content", models.TextField()),
(
"rating",
models.IntegerField(
help_text="Rating from 1 to 5 stars",
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
),
),
(
"visit_date",
models.DateField(
blank=True, help_text="Date the user visited", null=True
),
),
(
"wait_time_minutes",
models.PositiveIntegerField(
blank=True, help_text="Wait time in minutes", null=True
),
),
(
"helpful_votes",
models.PositiveIntegerField(
default=0,
help_text="Number of users who found this review helpful",
),
),
(
"total_votes",
models.PositiveIntegerField(
default=0,
help_text="Total number of votes (helpful + not helpful)",
),
),
(
"moderation_status",
models.CharField(
choices=[
("pending", "Pending"),
("approved", "Approved"),
("rejected", "Rejected"),
],
db_index=True,
default="pending",
max_length=20,
),
),
(
"moderation_notes",
models.TextField(blank=True, help_text="Notes from moderator"),
),
("moderated_at", models.DateTimeField(blank=True, null=True)),
(
"content_type",
models.ForeignKey(
limit_choices_to={"model__in": ("park", "ride")},
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"moderated_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_reviews",
to=settings.AUTH_USER_MODEL,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reviews",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created"],
},
),
migrations.CreateModel(
name="ReviewHelpfulVote",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"is_helpful",
models.BooleanField(
help_text="True if user found review helpful, False if not helpful"
),
),
(
"review",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="vote_records",
to="reviews.review",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="review_votes",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"indexes": [
models.Index(
fields=["review", "user"], name="reviews_rev_review__7d0d79_idx"
)
],
"unique_together": {("review", "user")},
},
),
migrations.AddIndex(
model_name="review",
index=models.Index(
fields=["content_type", "object_id"],
name="reviews_rev_content_627d80_idx",
),
),
migrations.AddIndex(
model_name="review",
index=models.Index(
fields=["user", "created"], name="reviews_rev_user_id_d4b7bb_idx"
),
),
migrations.AddIndex(
model_name="review",
index=models.Index(
fields=["moderation_status", "created"],
name="reviews_rev_moderat_d4dca0_idx",
),
),
migrations.AddIndex(
model_name="review",
index=models.Index(fields=["rating"], name="reviews_rev_rating_2db6dd_idx"),
),
migrations.AlterUniqueTogether(
name="review",
unique_together={("user", "content_type", "object_id")},
),
]

View File

@@ -0,0 +1,208 @@
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from model_utils.models import TimeStampedModel
class Review(TimeStampedModel):
"""
User reviews for parks or rides.
Users can leave reviews with ratings, text, photos, and metadata like visit date.
Reviews support helpful voting and go through moderation workflow.
"""
# User who created the review
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='reviews'
)
# Generic relation - can review either a Park or a Ride
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
limit_choices_to={'model__in': ('park', 'ride')}
)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
# Review content
title = models.CharField(max_length=200)
content = models.TextField()
rating = models.IntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)],
help_text="Rating from 1 to 5 stars"
)
# Visit metadata
visit_date = models.DateField(
null=True,
blank=True,
help_text="Date the user visited"
)
wait_time_minutes = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Wait time in minutes"
)
# Helpful voting system
helpful_votes = models.PositiveIntegerField(
default=0,
help_text="Number of users who found this review helpful"
)
total_votes = models.PositiveIntegerField(
default=0,
help_text="Total number of votes (helpful + not helpful)"
)
# Moderation status
MODERATION_PENDING = 'pending'
MODERATION_APPROVED = 'approved'
MODERATION_REJECTED = 'rejected'
MODERATION_STATUS_CHOICES = [
(MODERATION_PENDING, 'Pending'),
(MODERATION_APPROVED, 'Approved'),
(MODERATION_REJECTED, 'Rejected'),
]
moderation_status = models.CharField(
max_length=20,
choices=MODERATION_STATUS_CHOICES,
default=MODERATION_PENDING,
db_index=True
)
moderation_notes = models.TextField(
blank=True,
help_text="Notes from moderator"
)
moderated_at = models.DateTimeField(null=True, blank=True)
moderated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='moderated_reviews'
)
# Photos related to this review (via media.Photo model with generic relation)
photos = GenericRelation('media.Photo')
class Meta:
ordering = ['-created']
indexes = [
models.Index(fields=['content_type', 'object_id']),
models.Index(fields=['user', 'created']),
models.Index(fields=['moderation_status', 'created']),
models.Index(fields=['rating']),
]
# A user can only review a specific park/ride once
unique_together = [['user', 'content_type', 'object_id']]
def __str__(self):
entity_type = self.content_type.model
return f"{self.user.username}'s review of {entity_type} #{self.object_id}"
@property
def helpful_percentage(self):
"""Calculate percentage of helpful votes."""
if self.total_votes == 0:
return None
return (self.helpful_votes / self.total_votes) * 100
@property
def is_approved(self):
"""Check if review is approved."""
return self.moderation_status == self.MODERATION_APPROVED
@property
def is_pending(self):
"""Check if review is pending moderation."""
return self.moderation_status == self.MODERATION_PENDING
def approve(self, moderator, notes=''):
"""Approve the review."""
from django.utils import timezone
self.moderation_status = self.MODERATION_APPROVED
self.moderated_by = moderator
self.moderated_at = timezone.now()
self.moderation_notes = notes
self.save()
def reject(self, moderator, notes=''):
"""Reject the review."""
from django.utils import timezone
self.moderation_status = self.MODERATION_REJECTED
self.moderated_by = moderator
self.moderated_at = timezone.now()
self.moderation_notes = notes
self.save()
class ReviewHelpfulVote(TimeStampedModel):
"""
Track individual helpful votes to prevent duplicate voting.
"""
review = models.ForeignKey(
Review,
on_delete=models.CASCADE,
related_name='vote_records'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='review_votes'
)
is_helpful = models.BooleanField(
help_text="True if user found review helpful, False if not helpful"
)
class Meta:
unique_together = [['review', 'user']]
indexes = [
models.Index(fields=['review', 'user']),
]
def __str__(self):
vote_type = "helpful" if self.is_helpful else "not helpful"
return f"{self.user.username} voted {vote_type} on review #{self.review.id}"
def save(self, *args, **kwargs):
"""Update review vote counts when saving."""
is_new = self.pk is None
old_is_helpful = None
if not is_new:
# Get old value before update
old_vote = ReviewHelpfulVote.objects.get(pk=self.pk)
old_is_helpful = old_vote.is_helpful
super().save(*args, **kwargs)
# Update review vote counts
if is_new:
# New vote
self.review.total_votes += 1
if self.is_helpful:
self.review.helpful_votes += 1
self.review.save()
elif old_is_helpful != self.is_helpful:
# Vote changed
if self.is_helpful:
self.review.helpful_votes += 1
else:
self.review.helpful_votes -= 1
self.review.save()
def delete(self, *args, **kwargs):
"""Update review vote counts when deleting."""
self.review.total_votes -= 1
if self.is_helpful:
self.review.helpful_votes -= 1
self.review.save()
super().delete(*args, **kwargs)

View File

@@ -12,7 +12,7 @@ from unfold.decorators import display
from import_export import resources
from import_export.admin import ImportExportModelAdmin
from .models import User, UserRole, UserProfile
from .models import User, UserRole, UserProfile, UserRideCredit, UserTopList, UserTopListItem
class UserResource(resources.ModelResource):
@@ -370,3 +370,215 @@ class UserProfileAdmin(ModelAdmin):
"""Optimize queryset."""
qs = super().get_queryset(request)
return qs.select_related('user')
@admin.register(UserRideCredit)
class UserRideCreditAdmin(ModelAdmin):
"""Admin interface for UserRideCredit model."""
list_display = [
'user_link',
'ride_link',
'park_link',
'first_ride_date',
'ride_count',
'created',
]
list_filter = [
'first_ride_date',
'created',
]
search_fields = [
'user__email',
'user__username',
'ride__name',
'notes',
]
ordering = ['-first_ride_date', '-created']
readonly_fields = ['created', 'modified']
fieldsets = (
('Credit Information', {
'fields': ('user', 'ride', 'first_ride_date', 'ride_count')
}),
('Notes', {
'fields': ('notes',)
}),
('Timestamps', {
'fields': ('created', 'modified'),
'classes': ('collapse',)
}),
)
@display(description='User', ordering='user__username')
def user_link(self, obj):
url = reverse('admin:users_user_change', args=[obj.user.pk])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
@display(description='Ride', ordering='ride__name')
def ride_link(self, obj):
url = reverse('admin:entities_ride_change', args=[obj.ride.pk])
return format_html('<a href="{}">{}</a>', url, obj.ride.name)
@display(description='Park')
def park_link(self, obj):
if obj.ride.park:
url = reverse('admin:entities_park_change', args=[obj.ride.park.pk])
return format_html('<a href="{}">{}</a>', url, obj.ride.park.name)
return '-'
def get_queryset(self, request):
"""Optimize queryset."""
qs = super().get_queryset(request)
return qs.select_related('user', 'ride', 'ride__park')
class UserTopListItemInline(admin.TabularInline):
"""Inline for top list items."""
model = UserTopListItem
extra = 1
fields = ('position', 'content_type', 'object_id', 'notes')
ordering = ['position']
@admin.register(UserTopList)
class UserTopListAdmin(ModelAdmin):
"""Admin interface for UserTopList model."""
list_display = [
'title',
'user_link',
'list_type',
'item_count_display',
'visibility_badge',
'created',
]
list_filter = [
'list_type',
'is_public',
'created',
]
search_fields = [
'title',
'description',
'user__email',
'user__username',
]
ordering = ['-created']
readonly_fields = ['created', 'modified', 'item_count']
fieldsets = (
('List Information', {
'fields': ('user', 'list_type', 'title', 'description')
}),
('Privacy', {
'fields': ('is_public',)
}),
('Statistics', {
'fields': ('item_count',)
}),
('Timestamps', {
'fields': ('created', 'modified'),
'classes': ('collapse',)
}),
)
inlines = [UserTopListItemInline]
@display(description='User', ordering='user__username')
def user_link(self, obj):
url = reverse('admin:users_user_change', args=[obj.user.pk])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
@display(description='Items', ordering='items__count')
def item_count_display(self, obj):
count = obj.item_count
return format_html('<span style="font-weight: bold;">{}</span>', count)
@display(description='Visibility', ordering='is_public')
def visibility_badge(self, obj):
if obj.is_public:
return format_html(
'<span style="background-color: green; color: white; padding: 3px 8px; '
'border-radius: 3px; font-size: 11px;">PUBLIC</span>'
)
else:
return format_html(
'<span style="background-color: gray; color: white; padding: 3px 8px; '
'border-radius: 3px; font-size: 11px;">PRIVATE</span>'
)
def get_queryset(self, request):
"""Optimize queryset."""
qs = super().get_queryset(request)
return qs.select_related('user').prefetch_related('items')
@admin.register(UserTopListItem)
class UserTopListItemAdmin(ModelAdmin):
"""Admin interface for UserTopListItem model."""
list_display = [
'position',
'list_link',
'entity_type',
'entity_link',
'created',
]
list_filter = [
'content_type',
'created',
]
search_fields = [
'top_list__title',
'notes',
]
ordering = ['top_list', 'position']
readonly_fields = ['created', 'modified']
fieldsets = (
('Item Information', {
'fields': ('top_list', 'position', 'content_type', 'object_id')
}),
('Notes', {
'fields': ('notes',)
}),
('Timestamps', {
'fields': ('created', 'modified'),
'classes': ('collapse',)
}),
)
@display(description='List', ordering='top_list__title')
def list_link(self, obj):
url = reverse('admin:users_usertoplist_change', args=[obj.top_list.pk])
return format_html('<a href="{}">{}</a>', url, obj.top_list.title)
@display(description='Type', ordering='content_type')
def entity_type(self, obj):
return obj.content_type.model.title()
@display(description='Entity')
def entity_link(self, obj):
if obj.content_object:
model_name = obj.content_type.model
url = reverse(f'admin:entities_{model_name}_change', args=[obj.object_id])
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
return f"ID: {obj.object_id}"
def get_queryset(self, request):
"""Optimize queryset."""
qs = super().get_queryset(request)
return qs.select_related('top_list', 'content_type')

View File

@@ -0,0 +1,265 @@
# Generated by Django 4.2.8 on 2025-11-08 20:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import django_lifecycle.mixins
import model_utils.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("entities", "0003_add_search_vector_gin_indexes"),
("users", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="UserTopList",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"list_type",
models.CharField(
choices=[
("parks", "Parks"),
("rides", "Rides"),
("coasters", "Coasters"),
],
db_index=True,
help_text="Type of entities in this list",
max_length=20,
),
),
(
"title",
models.CharField(help_text="Title of the list", max_length=200),
),
(
"description",
models.TextField(blank=True, help_text="Description of the list"),
),
(
"is_public",
models.BooleanField(
db_index=True,
default=True,
help_text="Whether this list is publicly visible",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="top_lists",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"db_table": "user_top_lists",
"ordering": ["-created"],
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
migrations.CreateModel(
name="UserRideCredit",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"first_ride_date",
models.DateField(
blank=True, help_text="Date of first ride", null=True
),
),
(
"ride_count",
models.PositiveIntegerField(
default=1, help_text="Number of times user has ridden this ride"
),
),
(
"notes",
models.TextField(
blank=True, help_text="User notes about this ride"
),
),
(
"ride",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_credits",
to="entities.ride",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ride_credits",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"db_table": "user_ride_credits",
"ordering": ["-first_ride_date", "-created"],
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
migrations.CreateModel(
name="UserTopListItem",
fields=[
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("object_id", models.PositiveIntegerField()),
(
"position",
models.PositiveIntegerField(
help_text="Position in the list (1 = top)"
),
),
(
"notes",
models.TextField(
blank=True,
help_text="User notes about why this item is ranked here",
),
),
(
"content_type",
models.ForeignKey(
limit_choices_to={"model__in": ("park", "ride")},
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"top_list",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="users.usertoplist",
),
),
],
options={
"db_table": "user_top_list_items",
"ordering": ["position"],
"indexes": [
models.Index(
fields=["top_list", "position"],
name="user_top_li_top_lis_d31db9_idx",
),
models.Index(
fields=["content_type", "object_id"],
name="user_top_li_content_889eb7_idx",
),
],
"unique_together": {("top_list", "position")},
},
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
),
migrations.AddIndex(
model_name="usertoplist",
index=models.Index(
fields=["user", "list_type"], name="user_top_li_user_id_63f56d_idx"
),
),
migrations.AddIndex(
model_name="usertoplist",
index=models.Index(
fields=["is_public", "created"], name="user_top_li_is_publ_983146_idx"
),
),
migrations.AddIndex(
model_name="userridecredit",
index=models.Index(
fields=["user", "first_ride_date"],
name="user_ride_c_user_id_56a0e5_idx",
),
),
migrations.AddIndex(
model_name="userridecredit",
index=models.Index(fields=["ride"], name="user_ride_c_ride_id_f0990b_idx"),
),
migrations.AlterUniqueTogether(
name="userridecredit",
unique_together={("user", "ride")},
),
]

View File

@@ -255,3 +255,165 @@ class UserProfile(BaseModel):
status='approved'
).count()
self.save(update_fields=['total_submissions', 'approved_submissions'])
class UserRideCredit(BaseModel):
"""
Track which rides users have ridden (ride credits/coaster counting).
Users can log which rides they've been on and track their first ride date.
"""
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='ride_credits'
)
ride = models.ForeignKey(
'entities.Ride',
on_delete=models.CASCADE,
related_name='user_credits'
)
# First ride date
first_ride_date = models.DateField(
null=True,
blank=True,
help_text="Date of first ride"
)
# Ride count for this specific ride
ride_count = models.PositiveIntegerField(
default=1,
help_text="Number of times user has ridden this ride"
)
# Notes about the ride experience
notes = models.TextField(
blank=True,
help_text="User notes about this ride"
)
class Meta:
db_table = 'user_ride_credits'
unique_together = [['user', 'ride']]
ordering = ['-first_ride_date', '-created']
indexes = [
models.Index(fields=['user', 'first_ride_date']),
models.Index(fields=['ride']),
]
def __str__(self):
return f"{self.user.username} - {self.ride.name}"
@property
def park(self):
"""Get the park this ride is at"""
return self.ride.park
class UserTopList(BaseModel):
"""
User-created ranked lists (top parks, top rides, top coasters, etc.).
Users can create and share their personal rankings of parks, rides, and other entities.
"""
LIST_TYPE_CHOICES = [
('parks', 'Parks'),
('rides', 'Rides'),
('coasters', 'Coasters'),
]
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='top_lists'
)
# List metadata
list_type = models.CharField(
max_length=20,
choices=LIST_TYPE_CHOICES,
db_index=True,
help_text="Type of entities in this list"
)
title = models.CharField(
max_length=200,
help_text="Title of the list"
)
description = models.TextField(
blank=True,
help_text="Description of the list"
)
# Privacy
is_public = models.BooleanField(
default=True,
db_index=True,
help_text="Whether this list is publicly visible"
)
class Meta:
db_table = 'user_top_lists'
ordering = ['-created']
indexes = [
models.Index(fields=['user', 'list_type']),
models.Index(fields=['is_public', 'created']),
]
def __str__(self):
return f"{self.user.username} - {self.title}"
@property
def item_count(self):
"""Get the number of items in this list"""
return self.items.count()
class UserTopListItem(BaseModel):
"""
Individual items in a user's top list with position and notes.
"""
top_list = models.ForeignKey(
UserTopList,
on_delete=models.CASCADE,
related_name='items'
)
# Generic relation to park or ride
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
limit_choices_to={'model__in': ('park', 'ride')}
)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
# Position in list (1 = top)
position = models.PositiveIntegerField(
help_text="Position in the list (1 = top)"
)
# Optional notes about this specific item
notes = models.TextField(
blank=True,
help_text="User notes about why this item is ranked here"
)
class Meta:
db_table = 'user_top_list_items'
ordering = ['position']
unique_together = [['top_list', 'position']]
indexes = [
models.Index(fields=['top_list', 'position']),
models.Index(fields=['content_type', 'object_id']),
]
def __str__(self):
entity_name = str(self.content_object) if self.content_object else f"ID {self.object_id}"
return f"#{self.position}: {entity_name}"