mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-21 16:51:13 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
844
django-backend/api/v1/endpoints/reviews.py
Normal file
844
django-backend/api/v1/endpoints/reviews.py
Normal file
@@ -0,0 +1,844 @@
|
||||
"""
|
||||
Review endpoints for API v1.
|
||||
|
||||
Provides CRUD operations for reviews with moderation workflow integration.
|
||||
Users can review parks and rides, vote on reviews, and moderators can approve/reject.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q, Count, Avg
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from ninja import Router, Query
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
import logging
|
||||
|
||||
from apps.reviews.models import Review, ReviewHelpfulVote
|
||||
from apps.reviews.services import ReviewSubmissionService
|
||||
from apps.entities.models import Park, Ride
|
||||
from apps.users.permissions import jwt_auth, require_auth
|
||||
from ..schemas import (
|
||||
ReviewCreateSchema,
|
||||
ReviewUpdateSchema,
|
||||
ReviewOut,
|
||||
ReviewListOut,
|
||||
ReviewStatsOut,
|
||||
VoteRequest,
|
||||
VoteResponse,
|
||||
ErrorResponse,
|
||||
UserSchema,
|
||||
HistoryListResponse,
|
||||
HistoryEventDetailSchema,
|
||||
HistoryComparisonSchema,
|
||||
HistoryDiffCurrentSchema,
|
||||
FieldHistorySchema,
|
||||
HistoryActivitySummarySchema,
|
||||
RollbackRequestSchema,
|
||||
RollbackResponseSchema,
|
||||
ErrorSchema,
|
||||
)
|
||||
from ..services.history_service import HistoryService
|
||||
|
||||
router = Router(tags=["Reviews"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReviewPagination(PageNumberPagination):
|
||||
"""Custom pagination for reviews."""
|
||||
page_size = 50
|
||||
|
||||
|
||||
def _get_entity(entity_type: str, entity_id: UUID):
|
||||
"""Helper to get and validate entity (Park or Ride)."""
|
||||
if entity_type == 'park':
|
||||
return get_object_or_404(Park, id=entity_id), ContentType.objects.get_for_model(Park)
|
||||
elif entity_type == 'ride':
|
||||
return get_object_or_404(Ride, id=entity_id), ContentType.objects.get_for_model(Ride)
|
||||
else:
|
||||
raise ValidationError(f"Invalid entity_type: {entity_type}")
|
||||
|
||||
|
||||
def _serialize_review(review: Review, user=None) -> dict:
|
||||
"""Serialize review with computed fields."""
|
||||
data = {
|
||||
'id': review.id,
|
||||
'user': UserSchema(
|
||||
id=review.user.id,
|
||||
username=review.user.username,
|
||||
display_name=review.user.display_name,
|
||||
avatar_url=review.user.avatar_url,
|
||||
reputation_score=review.user.reputation_score,
|
||||
),
|
||||
'entity_type': review.content_type.model,
|
||||
'entity_id': str(review.object_id),
|
||||
'entity_name': str(review.content_object) if review.content_object else 'Unknown',
|
||||
'title': review.title,
|
||||
'content': review.content,
|
||||
'rating': review.rating,
|
||||
'visit_date': review.visit_date,
|
||||
'wait_time_minutes': review.wait_time_minutes,
|
||||
'helpful_votes': review.helpful_votes,
|
||||
'total_votes': review.total_votes,
|
||||
'helpful_percentage': review.helpful_percentage,
|
||||
'moderation_status': review.moderation_status,
|
||||
'moderated_at': review.moderated_at,
|
||||
'moderated_by_email': review.moderated_by.email if review.moderated_by else None,
|
||||
'photo_count': review.photos.count(),
|
||||
'created': review.created,
|
||||
'modified': review.modified,
|
||||
'user_vote': None,
|
||||
}
|
||||
|
||||
# Add user's vote if authenticated
|
||||
if user and user.is_authenticated:
|
||||
try:
|
||||
vote = ReviewHelpfulVote.objects.get(review=review, user=user)
|
||||
data['user_vote'] = vote.is_helpful
|
||||
except ReviewHelpfulVote.DoesNotExist:
|
||||
pass
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Review CRUD Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/", response={201: ReviewOut, 400: ErrorResponse, 409: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def create_review(request, data: ReviewCreateSchema):
|
||||
"""
|
||||
Create a new review for a park or ride through the Sacred Pipeline.
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- entity_type: "park" or "ride"
|
||||
- entity_id: UUID of park or ride
|
||||
- title: Review title
|
||||
- content: Review content (min 10 characters)
|
||||
- rating: 1-5 stars
|
||||
- visit_date: Optional visit date
|
||||
- wait_time_minutes: Optional wait time
|
||||
|
||||
**Returns:** Created review or submission confirmation
|
||||
|
||||
**Flow:**
|
||||
- Moderators: Review created immediately (bypass moderation)
|
||||
- Regular users: Submission created, enters moderation queue
|
||||
|
||||
**Note:** All reviews flow through ContentSubmission pipeline.
|
||||
Users can only create one review per entity.
|
||||
"""
|
||||
try:
|
||||
user = request.auth
|
||||
|
||||
# Get and validate entity
|
||||
entity, content_type = _get_entity(data.entity_type, data.entity_id)
|
||||
|
||||
# Create review through Sacred Pipeline
|
||||
submission, review = ReviewSubmissionService.create_review_submission(
|
||||
user=user,
|
||||
entity=entity,
|
||||
rating=data.rating,
|
||||
title=data.title,
|
||||
content=data.content,
|
||||
visit_date=data.visit_date,
|
||||
wait_time_minutes=data.wait_time_minutes,
|
||||
source='api'
|
||||
)
|
||||
|
||||
# If moderator bypass happened, Review was created immediately
|
||||
if review:
|
||||
logger.info(f"Review created (moderator): {review.id} by {user.email}")
|
||||
review_data = _serialize_review(review, user)
|
||||
return 201, review_data
|
||||
|
||||
# Regular user: submission pending moderation
|
||||
logger.info(f"Review submission created: {submission.id} by {user.email}")
|
||||
return 201, {
|
||||
'submission_id': str(submission.id),
|
||||
'status': 'pending_moderation',
|
||||
'message': 'Review submitted for moderation. You will be notified when it is approved.',
|
||||
}
|
||||
|
||||
except ValidationError as e:
|
||||
return 400, {'detail': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating review: {e}")
|
||||
return 400, {'detail': str(e)}
|
||||
|
||||
|
||||
@router.get("/", response={200: List[ReviewOut]})
|
||||
@paginate(ReviewPagination)
|
||||
def list_reviews(
|
||||
request,
|
||||
entity_type: Optional[str] = Query(None, description="Filter by entity type: park or ride"),
|
||||
entity_id: Optional[UUID] = Query(None, description="Filter by specific entity ID"),
|
||||
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
|
||||
rating: Optional[int] = Query(None, ge=1, le=5, description="Filter by rating"),
|
||||
moderation_status: Optional[str] = Query(None, description="Filter by moderation status"),
|
||||
ordering: Optional[str] = Query("-created", description="Sort by field")
|
||||
):
|
||||
"""
|
||||
List reviews with optional filtering.
|
||||
|
||||
**Authentication:** Optional (only approved reviews shown if not authenticated/not moderator)
|
||||
|
||||
**Filters:**
|
||||
- entity_type: park or ride
|
||||
- entity_id: Specific park/ride
|
||||
- user_id: Reviews by specific user
|
||||
- rating: Filter by star rating
|
||||
- moderation_status: pending/approved/rejected (moderators only)
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of reviews
|
||||
"""
|
||||
# Base query with optimizations
|
||||
queryset = Review.objects.select_related(
|
||||
'user',
|
||||
'moderated_by',
|
||||
'content_type'
|
||||
).prefetch_related('photos')
|
||||
|
||||
# Check if user is authenticated and is moderator
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
# Apply moderation filter
|
||||
if not is_moderator:
|
||||
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
|
||||
|
||||
# Apply entity type filter
|
||||
if entity_type:
|
||||
if entity_type == 'park':
|
||||
ct = ContentType.objects.get_for_model(Park)
|
||||
elif entity_type == 'ride':
|
||||
ct = ContentType.objects.get_for_model(Ride)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
queryset = queryset.filter(content_type=ct)
|
||||
|
||||
# Apply entity ID filter
|
||||
if entity_id:
|
||||
queryset = queryset.filter(object_id=entity_id)
|
||||
|
||||
# Apply user filter
|
||||
if user_id:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
|
||||
# Apply rating filter
|
||||
if rating:
|
||||
queryset = queryset.filter(rating=rating)
|
||||
|
||||
# Apply moderation status filter (moderators only)
|
||||
if moderation_status and is_moderator:
|
||||
queryset = queryset.filter(moderation_status=moderation_status)
|
||||
|
||||
# Apply ordering
|
||||
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
# Serialize reviews
|
||||
reviews = [_serialize_review(review, user) for review in queryset]
|
||||
return reviews
|
||||
|
||||
|
||||
@router.get("/{review_id}", response={200: ReviewOut, 404: ErrorResponse})
|
||||
def get_review(request, review_id: int):
|
||||
"""
|
||||
Get a specific review by ID.
|
||||
|
||||
**Authentication:** Optional
|
||||
|
||||
**Parameters:**
|
||||
- review_id: Review ID
|
||||
|
||||
**Returns:** Review details
|
||||
|
||||
**Note:** Only approved reviews are accessible to non-moderators.
|
||||
"""
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
is_owner = user and Review.objects.filter(id=review_id, user=user).exists() if user else False
|
||||
|
||||
review = get_object_or_404(
|
||||
Review.objects.select_related('user', 'moderated_by', 'content_type').prefetch_related('photos'),
|
||||
id=review_id
|
||||
)
|
||||
|
||||
# Check access
|
||||
if not review.is_approved and not is_moderator and not is_owner:
|
||||
return 404, {'detail': 'Review not found'}
|
||||
|
||||
review_data = _serialize_review(review, user)
|
||||
return 200, review_data
|
||||
|
||||
|
||||
@router.put("/{review_id}", response={200: ReviewOut, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def update_review(request, review_id: int, data: ReviewUpdateSchema):
|
||||
"""
|
||||
Update your own review.
|
||||
|
||||
**Authentication:** Required (must be review owner)
|
||||
|
||||
**Parameters:**
|
||||
- review_id: Review ID
|
||||
- data: Fields to update
|
||||
|
||||
**Returns:** Updated review
|
||||
|
||||
**Note:** Updating a review resets it to pending moderation.
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
review = get_object_or_404(
|
||||
Review.objects.select_related('user', 'content_type'),
|
||||
id=review_id
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if review.user != user:
|
||||
return 403, {'detail': 'You can only update your own reviews'}
|
||||
|
||||
# Update fields
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(review, key, value)
|
||||
|
||||
# Reset to pending moderation
|
||||
review.moderation_status = Review.MODERATION_PENDING
|
||||
review.moderated_at = None
|
||||
review.moderated_by = None
|
||||
review.moderation_notes = ''
|
||||
review.save()
|
||||
|
||||
logger.info(f"Review updated: {review.id} by {user.email}")
|
||||
|
||||
review_data = _serialize_review(review, user)
|
||||
return 200, review_data
|
||||
|
||||
|
||||
@router.delete("/{review_id}", response={204: None, 403: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def delete_review(request, review_id: int):
|
||||
"""
|
||||
Delete your own review.
|
||||
|
||||
**Authentication:** Required (must be review owner)
|
||||
|
||||
**Parameters:**
|
||||
- review_id: Review ID
|
||||
|
||||
**Returns:** No content (204)
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
# Check ownership
|
||||
if review.user != user:
|
||||
return 403, {'detail': 'You can only delete your own reviews'}
|
||||
|
||||
logger.info(f"Review deleted: {review.id} by {user.email}")
|
||||
review.delete()
|
||||
|
||||
return 204, None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Voting Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/{review_id}/vote", response={200: VoteResponse, 400: ErrorResponse, 404: ErrorResponse}, auth=jwt_auth)
|
||||
@require_auth
|
||||
def vote_on_review(request, review_id: int, data: VoteRequest):
|
||||
"""
|
||||
Vote on a review (helpful or not helpful).
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
**Parameters:**
|
||||
- review_id: Review ID
|
||||
- is_helpful: True if helpful, False if not helpful
|
||||
|
||||
**Returns:** Updated vote counts
|
||||
|
||||
**Note:** Users can change their vote but cannot vote on their own reviews.
|
||||
"""
|
||||
user = request.auth
|
||||
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
# Prevent self-voting
|
||||
if review.user == user:
|
||||
return 400, {'detail': 'You cannot vote on your own review'}
|
||||
|
||||
# Create or update vote
|
||||
vote, created = ReviewHelpfulVote.objects.update_or_create(
|
||||
review=review,
|
||||
user=user,
|
||||
defaults={'is_helpful': data.is_helpful}
|
||||
)
|
||||
|
||||
# Refresh review to get updated counts
|
||||
review.refresh_from_db()
|
||||
|
||||
return 200, {
|
||||
'success': True,
|
||||
'review_id': review.id,
|
||||
'helpful_votes': review.helpful_votes,
|
||||
'total_votes': review.total_votes,
|
||||
'helpful_percentage': review.helpful_percentage,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Entity-Specific Review Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/parks/{park_id}", response={200: List[ReviewOut]})
|
||||
@paginate(ReviewPagination)
|
||||
def get_park_reviews(
|
||||
request,
|
||||
park_id: UUID,
|
||||
rating: Optional[int] = Query(None, ge=1, le=5),
|
||||
ordering: Optional[str] = Query("-created")
|
||||
):
|
||||
"""
|
||||
Get all reviews for a specific park.
|
||||
|
||||
**Parameters:**
|
||||
- park_id: Park UUID
|
||||
- rating: Optional rating filter
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of park reviews
|
||||
"""
|
||||
park = get_object_or_404(Park, id=park_id)
|
||||
content_type = ContentType.objects.get_for_model(Park)
|
||||
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
queryset = Review.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=park.id
|
||||
).select_related('user', 'moderated_by').prefetch_related('photos')
|
||||
|
||||
if not is_moderator:
|
||||
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
|
||||
|
||||
if rating:
|
||||
queryset = queryset.filter(rating=rating)
|
||||
|
||||
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
reviews = [_serialize_review(review, user) for review in queryset]
|
||||
return reviews
|
||||
|
||||
|
||||
@router.get("/rides/{ride_id}", response={200: List[ReviewOut]})
|
||||
@paginate(ReviewPagination)
|
||||
def get_ride_reviews(
|
||||
request,
|
||||
ride_id: UUID,
|
||||
rating: Optional[int] = Query(None, ge=1, le=5),
|
||||
ordering: Optional[str] = Query("-created")
|
||||
):
|
||||
"""
|
||||
Get all reviews for a specific ride.
|
||||
|
||||
**Parameters:**
|
||||
- ride_id: Ride UUID
|
||||
- rating: Optional rating filter
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of ride reviews
|
||||
"""
|
||||
ride = get_object_or_404(Ride, id=ride_id)
|
||||
content_type = ContentType.objects.get_for_model(Ride)
|
||||
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
queryset = Review.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=ride.id
|
||||
).select_related('user', 'moderated_by').prefetch_related('photos')
|
||||
|
||||
if not is_moderator:
|
||||
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
|
||||
|
||||
if rating:
|
||||
queryset = queryset.filter(rating=rating)
|
||||
|
||||
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
reviews = [_serialize_review(review, user) for review in queryset]
|
||||
return reviews
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response={200: List[ReviewOut]})
|
||||
@paginate(ReviewPagination)
|
||||
def get_user_reviews(
|
||||
request,
|
||||
user_id: UUID,
|
||||
entity_type: Optional[str] = Query(None),
|
||||
ordering: Optional[str] = Query("-created")
|
||||
):
|
||||
"""
|
||||
Get all reviews by a specific user.
|
||||
|
||||
**Parameters:**
|
||||
- user_id: User UUID
|
||||
- entity_type: Optional filter (park or ride)
|
||||
- ordering: Sort field (default: -created)
|
||||
|
||||
**Returns:** Paginated list of user's reviews
|
||||
|
||||
**Note:** Only approved reviews visible unless viewing own reviews or moderator.
|
||||
"""
|
||||
user = request.auth if hasattr(request, 'auth') else None
|
||||
is_owner = user and str(user.id) == str(user_id) if user else False
|
||||
is_moderator = user and hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
queryset = Review.objects.filter(
|
||||
user_id=user_id
|
||||
).select_related('user', 'moderated_by', 'content_type').prefetch_related('photos')
|
||||
|
||||
# Filter by moderation status
|
||||
if not is_owner and not is_moderator:
|
||||
queryset = queryset.filter(moderation_status=Review.MODERATION_APPROVED)
|
||||
|
||||
# Apply entity type filter
|
||||
if entity_type:
|
||||
if entity_type == 'park':
|
||||
ct = ContentType.objects.get_for_model(Park)
|
||||
elif entity_type == 'ride':
|
||||
ct = ContentType.objects.get_for_model(Ride)
|
||||
else:
|
||||
queryset = queryset.none()
|
||||
queryset = queryset.filter(content_type=ct)
|
||||
|
||||
# Apply ordering
|
||||
valid_order_fields = ['created', 'modified', 'rating', 'helpful_votes', 'visit_date']
|
||||
order_field = ordering.lstrip('-')
|
||||
if order_field in valid_order_fields:
|
||||
queryset = queryset.order_by(ordering)
|
||||
else:
|
||||
queryset = queryset.order_by('-created')
|
||||
|
||||
reviews = [_serialize_review(review, user) for review in queryset]
|
||||
return reviews
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Statistics Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/stats/{entity_type}/{entity_id}", response={200: ReviewStatsOut, 404: ErrorResponse})
|
||||
def get_review_stats(request, entity_type: str, entity_id: UUID):
|
||||
"""
|
||||
Get review statistics for a park or ride.
|
||||
|
||||
**Parameters:**
|
||||
- entity_type: "park" or "ride"
|
||||
- entity_id: Entity UUID
|
||||
|
||||
**Returns:** Statistics including average rating and distribution
|
||||
"""
|
||||
try:
|
||||
entity, content_type = _get_entity(entity_type, entity_id)
|
||||
except ValidationError as e:
|
||||
return 404, {'detail': str(e)}
|
||||
|
||||
# Get approved reviews only
|
||||
reviews = Review.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=entity.id,
|
||||
moderation_status=Review.MODERATION_APPROVED
|
||||
)
|
||||
|
||||
# Calculate stats
|
||||
stats = reviews.aggregate(
|
||||
average_rating=Avg('rating'),
|
||||
total_reviews=Count('id')
|
||||
)
|
||||
|
||||
# Get rating distribution
|
||||
distribution = {}
|
||||
for rating in range(1, 6):
|
||||
distribution[rating] = reviews.filter(rating=rating).count()
|
||||
|
||||
return 200, {
|
||||
'average_rating': stats['average_rating'] or 0.0,
|
||||
'total_reviews': stats['total_reviews'] or 0,
|
||||
'rating_distribution': distribution,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# History Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/',
|
||||
response={200: HistoryListResponse, 404: ErrorSchema},
|
||||
summary="Get review history",
|
||||
description="Get historical changes for a review"
|
||||
)
|
||||
def get_review_history(
|
||||
request,
|
||||
review_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)")
|
||||
):
|
||||
"""Get history for a review."""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify review exists
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
# Parse dates if provided
|
||||
date_from_obj = datetime.fromisoformat(date_from).date() if date_from else None
|
||||
date_to_obj = datetime.fromisoformat(date_to).date() if date_to else None
|
||||
|
||||
# Get history
|
||||
offset = (page - 1) * page_size
|
||||
events, accessible_count = HistoryService.get_history(
|
||||
'review', str(review_id), request.user,
|
||||
date_from=date_from_obj, date_to=date_to_obj,
|
||||
limit=page_size, offset=offset
|
||||
)
|
||||
|
||||
# Format events
|
||||
formatted_events = []
|
||||
for event in events:
|
||||
formatted_events.append({
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'change_summary': event.get('change_summary', ''),
|
||||
'can_rollback': HistoryService.can_rollback(request.user)
|
||||
})
|
||||
|
||||
# Calculate pagination
|
||||
total_pages = (accessible_count + page_size - 1) // page_size
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'total_events': accessible_count,
|
||||
'accessible_events': accessible_count,
|
||||
'access_limited': HistoryService.is_access_limited(request.user),
|
||||
'access_reason': HistoryService.get_access_reason(request.user),
|
||||
'events': formatted_events,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': total_pages,
|
||||
'total_items': accessible_count
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/{event_id}/',
|
||||
response={200: HistoryEventDetailSchema, 404: ErrorSchema},
|
||||
summary="Get specific review history event",
|
||||
description="Get detailed information about a specific historical event"
|
||||
)
|
||||
def get_review_history_event(request, review_id: int, event_id: int):
|
||||
"""Get a specific history event for a review."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
event = HistoryService.get_event('review', event_id, request.user)
|
||||
|
||||
if not event:
|
||||
return 404, {"error": "Event not found or not accessible"}
|
||||
|
||||
return {
|
||||
'id': event['id'],
|
||||
'timestamp': event['timestamp'],
|
||||
'operation': event['operation'],
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'snapshot': event['snapshot'],
|
||||
'changed_fields': event.get('changed_fields'),
|
||||
'metadata': event.get('metadata', {}),
|
||||
'can_rollback': HistoryService.can_rollback(request.user),
|
||||
'rollback_preview': None
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/compare/',
|
||||
response={200: HistoryComparisonSchema, 400: ErrorSchema, 404: ErrorSchema},
|
||||
summary="Compare two review history events",
|
||||
description="Compare two historical events for a review"
|
||||
)
|
||||
def compare_review_history(
|
||||
request,
|
||||
review_id: int,
|
||||
event1: int = Query(..., description="First event ID"),
|
||||
event2: int = Query(..., description="Second event ID")
|
||||
):
|
||||
"""Compare two historical events for a review."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
try:
|
||||
comparison = HistoryService.compare_events(
|
||||
'review', event1, event2, request.user
|
||||
)
|
||||
|
||||
if not comparison:
|
||||
return 404, {"error": "One or both events not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'event1': comparison['event1'],
|
||||
'event2': comparison['event2'],
|
||||
'differences': comparison['differences'],
|
||||
'changed_field_count': comparison['changed_field_count'],
|
||||
'unchanged_field_count': comparison['unchanged_field_count'],
|
||||
'time_between': comparison['time_between']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/{event_id}/diff-current/',
|
||||
response={200: HistoryDiffCurrentSchema, 404: ErrorSchema},
|
||||
summary="Compare historical event with current state",
|
||||
description="Compare a historical event with the current review state"
|
||||
)
|
||||
def diff_review_history_with_current(request, review_id: int, event_id: int):
|
||||
"""Compare historical event with current review state."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
try:
|
||||
diff = HistoryService.compare_with_current(
|
||||
'review', event_id, review, request.user
|
||||
)
|
||||
|
||||
if not diff:
|
||||
return 404, {"error": "Event not found"}
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'event': diff['event'],
|
||||
'current_state': diff['current_state'],
|
||||
'differences': diff['differences'],
|
||||
'changed_field_count': diff['changed_field_count'],
|
||||
'time_since': diff['time_since']
|
||||
}
|
||||
except ValueError as e:
|
||||
return 404, {"error": str(e)}
|
||||
|
||||
|
||||
@router.post(
|
||||
'/{review_id}/history/{event_id}/rollback/',
|
||||
response={200: RollbackResponseSchema, 400: ErrorSchema, 403: ErrorSchema},
|
||||
summary="Rollback review to historical state",
|
||||
description="Rollback review to a historical state (Moderators/Admins only)"
|
||||
)
|
||||
def rollback_review(request, review_id: int, event_id: int, payload: RollbackRequestSchema):
|
||||
"""
|
||||
Rollback review to a historical state.
|
||||
|
||||
**Permission:** Moderators, Admins, Superusers only
|
||||
"""
|
||||
# Check authentication
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return 401, {"error": "Authentication required"}
|
||||
|
||||
# Check rollback permission
|
||||
if not HistoryService.can_rollback(request.user):
|
||||
return 403, {"error": "Only moderators and administrators can perform rollbacks"}
|
||||
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
try:
|
||||
result = HistoryService.rollback_to_event(
|
||||
review, 'review', event_id, request.user,
|
||||
fields=payload.fields,
|
||||
comment=payload.comment,
|
||||
create_backup=payload.create_backup
|
||||
)
|
||||
return result
|
||||
except (ValueError, PermissionError) as e:
|
||||
return 400, {"error": str(e)}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/field/{field_name}/',
|
||||
response={200: FieldHistorySchema, 404: ErrorSchema},
|
||||
summary="Get field-specific history",
|
||||
description="Get history of changes to a specific review field"
|
||||
)
|
||||
def get_review_field_history(request, review_id: int, field_name: str):
|
||||
"""Get history of changes to a specific review field."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
history = HistoryService.get_field_history(
|
||||
'review', str(review_id), field_name, request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
'field': field_name,
|
||||
'field_type': 'CharField', # Could introspect this
|
||||
**history
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{review_id}/history/summary/',
|
||||
response={200: HistoryActivitySummarySchema, 404: ErrorSchema},
|
||||
summary="Get review activity summary",
|
||||
description="Get activity summary for a review"
|
||||
)
|
||||
def get_review_activity_summary(request, review_id: int):
|
||||
"""Get activity summary for a review."""
|
||||
review = get_object_or_404(Review, id=review_id)
|
||||
|
||||
summary = HistoryService.get_activity_summary(
|
||||
'review', str(review_id), request.user
|
||||
)
|
||||
|
||||
return {
|
||||
'entity_id': str(review_id),
|
||||
'entity_type': 'review',
|
||||
'entity_name': f"Review by {review.user.username}",
|
||||
**summary
|
||||
}
|
||||
Reference in New Issue
Block a user