mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 05:11:13 -05:00
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:
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||
|
||||
@@ -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")},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user