Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

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,7 @@
from django.apps import AppConfig
class ReviewsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.reviews'
verbose_name = 'Reviews'

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,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,
),
),
]

View 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)

View 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}")