mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-25 02:11:12 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
215
django-backend/apps/reviews/admin.py
Normal file
215
django-backend/apps/reviews/admin.py
Normal 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
|
||||
7
django-backend/apps/reviews/apps.py
Normal file
7
django-backend/apps/reviews/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReviewsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.reviews'
|
||||
verbose_name = 'Reviews'
|
||||
225
django-backend/apps/reviews/migrations/0001_initial.py
Normal file
225
django-backend/apps/reviews/migrations/0001_initial.py
Normal 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")},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,222 @@
|
||||
# Generated by Django 4.2.8 on 2025-11-08 21:32
|
||||
|
||||
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
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("moderation", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("reviews", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ReviewEvent",
|
||||
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()),
|
||||
(
|
||||
"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"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"moderation_notes",
|
||||
models.TextField(blank=True, help_text="Notes from moderator"),
|
||||
),
|
||||
("moderated_at", models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="review",
|
||||
name="submission",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="ContentSubmission that created this review",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="reviews",
|
||||
to="moderation.contentsubmission",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="review",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "reviews_reviewevent" ("content", "content_type_id", "created", "helpful_votes", "id", "moderated_at", "moderated_by_id", "moderation_notes", "moderation_status", "modified", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "submission_id", "title", "total_votes", "user_id", "visit_date", "wait_time_minutes") VALUES (NEW."content", NEW."content_type_id", NEW."created", NEW."helpful_votes", NEW."id", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."moderation_status", NEW."modified", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."submission_id", NEW."title", NEW."total_votes", NEW."user_id", NEW."visit_date", NEW."wait_time_minutes"); RETURN NULL;',
|
||||
hash="b35102b3c04881bef39a259f1105a6032033b6d7",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_7a7c1",
|
||||
table="reviews_review",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="review",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "reviews_reviewevent" ("content", "content_type_id", "created", "helpful_votes", "id", "moderated_at", "moderated_by_id", "moderation_notes", "moderation_status", "modified", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "submission_id", "title", "total_votes", "user_id", "visit_date", "wait_time_minutes") VALUES (NEW."content", NEW."content_type_id", NEW."created", NEW."helpful_votes", NEW."id", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."moderation_status", NEW."modified", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."submission_id", NEW."title", NEW."total_votes", NEW."user_id", NEW."visit_date", NEW."wait_time_minutes"); RETURN NULL;',
|
||||
hash="252cddc558c9724c0ef840a91c1d0ebd03a1b7a2",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_b34c8",
|
||||
table="reviews_review",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="content_type",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
limit_choices_to={"model__in": ("park", "ride")},
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="moderated_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
related_query_name="+",
|
||||
to="reviews.review",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="submission",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="ContentSubmission that created this review",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="moderation.contentsubmission",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
0
django-backend/apps/reviews/migrations/__init__.py
Normal file
0
django-backend/apps/reviews/migrations/__init__.py
Normal file
202
django-backend/apps/reviews/models.py
Normal file
202
django-backend/apps/reviews/models.py
Normal file
@@ -0,0 +1,202 @@
|
||||
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
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
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'
|
||||
)
|
||||
|
||||
# Link to ContentSubmission (Sacred Pipeline integration)
|
||||
submission = models.ForeignKey(
|
||||
'moderation.ContentSubmission',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='reviews',
|
||||
help_text="ContentSubmission that created this review"
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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)
|
||||
378
django-backend/apps/reviews/services.py
Normal file
378
django-backend/apps/reviews/services.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
Review services for ThrillWiki.
|
||||
|
||||
This module provides business logic for review submissions through the Sacred Pipeline.
|
||||
All reviews must flow through ModerationService to ensure consistency with the rest of the system.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.db import transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.moderation.services import ModerationService
|
||||
from apps.reviews.models import Review
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReviewSubmissionService:
|
||||
"""
|
||||
Service class for creating and managing review submissions.
|
||||
|
||||
All reviews flow through the ContentSubmission pipeline, ensuring:
|
||||
- Consistent moderation workflow
|
||||
- FSM state machine transitions
|
||||
- 15-minute lock mechanism
|
||||
- Atomic transaction handling
|
||||
- Automatic versioning via pghistory
|
||||
- Audit trail via ContentSubmission
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_review_submission(
|
||||
user,
|
||||
entity,
|
||||
rating,
|
||||
title,
|
||||
content,
|
||||
visit_date=None,
|
||||
wait_time_minutes=None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Create a review submission through the Sacred Pipeline.
|
||||
|
||||
This method creates a ContentSubmission with SubmissionItems for each review field.
|
||||
If the user is a moderator, the submission is auto-approved and the Review is created immediately.
|
||||
Otherwise, the submission enters the pending moderation queue.
|
||||
|
||||
Args:
|
||||
user: User creating the review
|
||||
entity: Entity being reviewed (Park or Ride)
|
||||
rating: Rating from 1-5 stars
|
||||
title: Review title
|
||||
content: Review content text
|
||||
visit_date: Optional date of visit
|
||||
wait_time_minutes: Optional wait time in minutes
|
||||
**kwargs: Additional metadata (source, ip_address, user_agent)
|
||||
|
||||
Returns:
|
||||
tuple: (ContentSubmission, Review or None)
|
||||
Review will be None if pending moderation
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails
|
||||
"""
|
||||
# Check if user is moderator (for bypass)
|
||||
is_moderator = hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
# Get entity ContentType
|
||||
entity_type = ContentType.objects.get_for_model(entity)
|
||||
|
||||
# Check for duplicate review
|
||||
existing = Review.objects.filter(
|
||||
user=user,
|
||||
content_type=entity_type,
|
||||
object_id=entity.id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise ValidationError(
|
||||
f"User has already reviewed this {entity_type.model}. "
|
||||
f"Use update method to modify existing review."
|
||||
)
|
||||
|
||||
# Build submission items for each review field
|
||||
items_data = [
|
||||
{
|
||||
'field_name': 'rating',
|
||||
'field_label': 'Rating',
|
||||
'old_value': None,
|
||||
'new_value': str(rating),
|
||||
'change_type': 'add',
|
||||
'is_required': True,
|
||||
'order': 0
|
||||
},
|
||||
{
|
||||
'field_name': 'title',
|
||||
'field_label': 'Title',
|
||||
'old_value': None,
|
||||
'new_value': title,
|
||||
'change_type': 'add',
|
||||
'is_required': True,
|
||||
'order': 1
|
||||
},
|
||||
{
|
||||
'field_name': 'content',
|
||||
'field_label': 'Review Content',
|
||||
'old_value': None,
|
||||
'new_value': content,
|
||||
'change_type': 'add',
|
||||
'is_required': True,
|
||||
'order': 2
|
||||
},
|
||||
]
|
||||
|
||||
# Add optional fields if provided
|
||||
if visit_date is not None:
|
||||
items_data.append({
|
||||
'field_name': 'visit_date',
|
||||
'field_label': 'Visit Date',
|
||||
'old_value': None,
|
||||
'new_value': str(visit_date),
|
||||
'change_type': 'add',
|
||||
'is_required': False,
|
||||
'order': 3
|
||||
})
|
||||
|
||||
if wait_time_minutes is not None:
|
||||
items_data.append({
|
||||
'field_name': 'wait_time_minutes',
|
||||
'field_label': 'Wait Time (minutes)',
|
||||
'old_value': None,
|
||||
'new_value': str(wait_time_minutes),
|
||||
'change_type': 'add',
|
||||
'is_required': False,
|
||||
'order': 4
|
||||
})
|
||||
|
||||
# Create submission through ModerationService
|
||||
submission = ModerationService.create_submission(
|
||||
user=user,
|
||||
entity=entity,
|
||||
submission_type='review',
|
||||
title=f"Review: {title[:50]}",
|
||||
description=f"User review for {entity_type.model}: {entity}",
|
||||
items_data=items_data,
|
||||
metadata={
|
||||
'rating': rating,
|
||||
'entity_type': entity_type.model,
|
||||
},
|
||||
auto_submit=True,
|
||||
source=kwargs.get('source', 'api'),
|
||||
ip_address=kwargs.get('ip_address'),
|
||||
user_agent=kwargs.get('user_agent', '')
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Review submission created: {submission.id} by {user.email} "
|
||||
f"for {entity_type.model} {entity.id}"
|
||||
)
|
||||
|
||||
# MODERATOR BYPASS: Auto-approve if user is moderator
|
||||
review = None
|
||||
if is_moderator:
|
||||
logger.info(f"Moderator bypass: Auto-approving submission {submission.id}")
|
||||
|
||||
# Approve through ModerationService (this triggers atomic transaction)
|
||||
submission = ModerationService.approve_submission(submission.id, user)
|
||||
|
||||
# Create the Review record
|
||||
review = ReviewSubmissionService._create_review_from_submission(
|
||||
submission=submission,
|
||||
entity=entity,
|
||||
user=user
|
||||
)
|
||||
|
||||
logger.info(f"Review auto-created for moderator: {review.id}")
|
||||
|
||||
return submission, review
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def _create_review_from_submission(submission, entity, user):
|
||||
"""
|
||||
Create a Review record from an approved ContentSubmission.
|
||||
|
||||
This is called internally when a submission is approved.
|
||||
Extracts data from SubmissionItems and creates the Review.
|
||||
|
||||
Args:
|
||||
submission: Approved ContentSubmission
|
||||
entity: Entity being reviewed
|
||||
user: User who created the review
|
||||
|
||||
Returns:
|
||||
Review: Created review instance
|
||||
"""
|
||||
# Extract data from submission items
|
||||
items = submission.items.all()
|
||||
review_data = {}
|
||||
|
||||
for item in items:
|
||||
if item.field_name == 'rating':
|
||||
review_data['rating'] = int(item.new_value)
|
||||
elif item.field_name == 'title':
|
||||
review_data['title'] = item.new_value
|
||||
elif item.field_name == 'content':
|
||||
review_data['content'] = item.new_value
|
||||
elif item.field_name == 'visit_date':
|
||||
from datetime import datetime
|
||||
review_data['visit_date'] = datetime.fromisoformat(item.new_value).date()
|
||||
elif item.field_name == 'wait_time_minutes':
|
||||
review_data['wait_time_minutes'] = int(item.new_value)
|
||||
|
||||
# Get entity ContentType
|
||||
entity_type = ContentType.objects.get_for_model(entity)
|
||||
|
||||
# Create Review
|
||||
review = Review.objects.create(
|
||||
user=user,
|
||||
content_type=entity_type,
|
||||
object_id=entity.id,
|
||||
submission=submission,
|
||||
moderation_status=Review.MODERATION_APPROVED,
|
||||
moderated_by=submission.reviewed_by,
|
||||
moderated_at=submission.reviewed_at,
|
||||
**review_data
|
||||
)
|
||||
|
||||
# pghistory will automatically track this creation
|
||||
|
||||
logger.info(
|
||||
f"Review created from submission: {review.id} "
|
||||
f"(submission: {submission.id})"
|
||||
)
|
||||
|
||||
return review
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def update_review_submission(review, user, **update_data):
|
||||
"""
|
||||
Update an existing review by creating a new submission.
|
||||
|
||||
This follows the Sacred Pipeline by creating a new ContentSubmission
|
||||
for the update, which must be approved before changes take effect.
|
||||
|
||||
Args:
|
||||
review: Existing Review to update
|
||||
user: User making the update (must be review owner)
|
||||
**update_data: Fields to update (rating, title, content, etc.)
|
||||
|
||||
Returns:
|
||||
ContentSubmission: The update submission
|
||||
|
||||
Raises:
|
||||
ValidationError: If user is not the review owner
|
||||
"""
|
||||
# Verify ownership
|
||||
if review.user != user:
|
||||
raise ValidationError("Only the review owner can update their review")
|
||||
|
||||
# Check if user is moderator (for bypass)
|
||||
is_moderator = hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
# Get entity
|
||||
entity = review.content_object
|
||||
if not entity:
|
||||
raise ValidationError("Reviewed entity no longer exists")
|
||||
|
||||
# Build submission items for changed fields
|
||||
items_data = []
|
||||
order = 0
|
||||
|
||||
for field_name, new_value in update_data.items():
|
||||
if field_name in ['rating', 'title', 'content', 'visit_date', 'wait_time_minutes']:
|
||||
old_value = getattr(review, field_name)
|
||||
|
||||
# Only include if value actually changed
|
||||
if old_value != new_value:
|
||||
items_data.append({
|
||||
'field_name': field_name,
|
||||
'field_label': field_name.replace('_', ' ').title(),
|
||||
'old_value': str(old_value) if old_value else None,
|
||||
'new_value': str(new_value),
|
||||
'change_type': 'modify',
|
||||
'is_required': field_name in ['rating', 'title', 'content'],
|
||||
'order': order
|
||||
})
|
||||
order += 1
|
||||
|
||||
if not items_data:
|
||||
raise ValidationError("No changes detected")
|
||||
|
||||
# Create update submission
|
||||
submission = ModerationService.create_submission(
|
||||
user=user,
|
||||
entity=entity,
|
||||
submission_type='update',
|
||||
title=f"Review Update: {review.title[:50]}",
|
||||
description=f"User updating review #{review.id}",
|
||||
items_data=items_data,
|
||||
metadata={
|
||||
'review_id': str(review.id),
|
||||
'update_type': 'review',
|
||||
},
|
||||
auto_submit=True,
|
||||
source='api'
|
||||
)
|
||||
|
||||
logger.info(f"Review update submission created: {submission.id}")
|
||||
|
||||
# MODERATOR BYPASS: Auto-approve if moderator
|
||||
if is_moderator:
|
||||
submission = ModerationService.approve_submission(submission.id, user)
|
||||
|
||||
# Apply updates to review
|
||||
for item in submission.items.filter(status='approved'):
|
||||
setattr(review, item.field_name, item.new_value)
|
||||
|
||||
review.moderation_status = Review.MODERATION_APPROVED
|
||||
review.moderated_by = user
|
||||
review.save()
|
||||
|
||||
logger.info(f"Review update auto-approved for moderator: {review.id}")
|
||||
else:
|
||||
# Regular user: mark review as pending
|
||||
review.moderation_status = Review.MODERATION_PENDING
|
||||
review.save()
|
||||
|
||||
return submission
|
||||
|
||||
@staticmethod
|
||||
def apply_review_approval(submission):
|
||||
"""
|
||||
Apply an approved review submission.
|
||||
|
||||
This is called by ModerationService when a review submission is approved.
|
||||
For new reviews, creates the Review record.
|
||||
For updates, applies changes to existing Review.
|
||||
|
||||
Args:
|
||||
submission: Approved ContentSubmission
|
||||
|
||||
Returns:
|
||||
Review: The created or updated review
|
||||
"""
|
||||
entity = submission.entity
|
||||
user = submission.user
|
||||
|
||||
if submission.submission_type == 'review':
|
||||
# New review
|
||||
return ReviewSubmissionService._create_review_from_submission(
|
||||
submission, entity, user
|
||||
)
|
||||
elif submission.submission_type == 'update':
|
||||
# Update existing review
|
||||
review_id = submission.metadata.get('review_id')
|
||||
if not review_id:
|
||||
raise ValidationError("Missing review_id in submission metadata")
|
||||
|
||||
review = Review.objects.get(id=review_id)
|
||||
|
||||
# Apply approved changes
|
||||
for item in submission.items.filter(status='approved'):
|
||||
setattr(review, item.field_name, item.new_value)
|
||||
|
||||
review.moderation_status = Review.MODERATION_APPROVED
|
||||
review.moderated_by = submission.reviewed_by
|
||||
review.moderated_at = submission.reviewed_at
|
||||
review.save()
|
||||
|
||||
logger.info(f"Review updated from submission: {review.id}")
|
||||
return review
|
||||
else:
|
||||
raise ValidationError(f"Invalid submission type: {submission.submission_type}")
|
||||
Reference in New Issue
Block a user