Files
thrilltrack-explorer/django-backend/apps/reviews/services.py

379 lines
13 KiB
Python

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