Add comprehensive tests for Parks API and models

- Implemented extensive test cases for the Parks API, covering endpoints for listing, retrieving, creating, updating, and deleting parks.
- Added tests for filtering, searching, and ordering parks in the API.
- Created tests for error handling in the API, including malformed JSON and unsupported methods.
- Developed model tests for Park, ParkArea, Company, and ParkReview models, ensuring validation and constraints are enforced.
- Introduced utility mixins for API and model testing to streamline assertions and enhance test readability.
- Included integration tests to validate complete workflows involving park creation, retrieval, updating, and deletion.
This commit is contained in:
pacnpal
2025-08-17 19:36:20 -04:00
parent 17228e9935
commit c26414ff74
210 changed files with 24155 additions and 833 deletions

View File

@@ -165,6 +165,8 @@ class EditSubmission(TrackedModel):
if self.submission_type == "CREATE":
# Create new object
obj = model_class(**prepared_data)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
obj.full_clean()
obj.save()
# Update object_id after creation
self.object_id = getattr(obj, "id", None)
@@ -174,8 +176,12 @@ class EditSubmission(TrackedModel):
raise ValueError("Content object not found")
for field, value in prepared_data.items():
setattr(obj, field, value)
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
obj.full_clean()
obj.save()
# CRITICAL STYLEGUIDE FIX: Call full_clean before save
self.full_clean()
self.save()
return obj
except Exception as e:

305
moderation/selectors.py Normal file
View File

@@ -0,0 +1,305 @@
"""
Selectors for moderation-related data retrieval.
Following Django styleguide pattern for separating data access from business logic.
"""
from typing import Optional, Dict, Any
from django.db.models import QuerySet, Q, Count
from django.utils import timezone
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from .models import EditSubmission
def pending_submissions_for_review(
*,
content_type: Optional[str] = None,
limit: int = 50
) -> QuerySet[EditSubmission]:
"""
Get pending submissions that need moderation review.
Args:
content_type: Optional filter by content type name
limit: Maximum number of submissions to return
Returns:
QuerySet of pending submissions ordered by submission date
"""
queryset = EditSubmission.objects.filter(
status='PENDING'
).select_related(
'submitted_by',
'content_type'
).prefetch_related(
'content_object'
)
if content_type:
queryset = queryset.filter(content_type__model=content_type.lower())
return queryset.order_by('submitted_at')[:limit]
def submissions_by_user(
*,
user_id: int,
status: Optional[str] = None
) -> QuerySet[EditSubmission]:
"""
Get submissions created by a specific user.
Args:
user_id: ID of the user who submitted
status: Optional filter by submission status
Returns:
QuerySet of user's submissions
"""
queryset = EditSubmission.objects.filter(
submitted_by_id=user_id
).select_related(
'content_type',
'handled_by'
)
if status:
queryset = queryset.filter(status=status)
return queryset.order_by('-submitted_at')
def submissions_handled_by_moderator(
*,
moderator_id: int,
days: int = 30
) -> QuerySet[EditSubmission]:
"""
Get submissions handled by a specific moderator in the last N days.
Args:
moderator_id: ID of the moderator
days: Number of days to look back
Returns:
QuerySet of submissions handled by the moderator
"""
cutoff_date = timezone.now() - timedelta(days=days)
return EditSubmission.objects.filter(
handled_by_id=moderator_id,
handled_at__gte=cutoff_date
).select_related(
'submitted_by',
'content_type'
).order_by('-handled_at')
def recent_submissions(*, days: int = 7) -> QuerySet[EditSubmission]:
"""
Get recent submissions from the last N days.
Args:
days: Number of days to look back
Returns:
QuerySet of recent submissions
"""
cutoff_date = timezone.now() - timedelta(days=days)
return EditSubmission.objects.filter(
submitted_at__gte=cutoff_date
).select_related(
'submitted_by',
'content_type',
'handled_by'
).order_by('-submitted_at')
def submissions_by_content_type(
*,
content_type: str,
status: Optional[str] = None
) -> QuerySet[EditSubmission]:
"""
Get submissions for a specific content type.
Args:
content_type: Name of the content type (e.g., 'park', 'ride')
status: Optional filter by submission status
Returns:
QuerySet of submissions for the content type
"""
queryset = EditSubmission.objects.filter(
content_type__model=content_type.lower()
).select_related(
'submitted_by',
'handled_by'
)
if status:
queryset = queryset.filter(status=status)
return queryset.order_by('-submitted_at')
def moderation_queue_summary() -> Dict[str, Any]:
"""
Get summary statistics for the moderation queue.
Returns:
Dictionary containing queue statistics
"""
pending_count = EditSubmission.objects.filter(status='PENDING').count()
approved_today = EditSubmission.objects.filter(
status='APPROVED',
handled_at__date=timezone.now().date()
).count()
rejected_today = EditSubmission.objects.filter(
status='REJECTED',
handled_at__date=timezone.now().date()
).count()
# Submissions by content type
submissions_by_type = EditSubmission.objects.filter(
status='PENDING'
).values('content_type__model').annotate(
count=Count('id')
).order_by('-count')
return {
'pending_count': pending_count,
'approved_today': approved_today,
'rejected_today': rejected_today,
'submissions_by_type': list(submissions_by_type)
}
def moderation_statistics_summary(
*,
days: int = 30,
moderator: Optional[User] = None
) -> Dict[str, Any]:
"""
Get comprehensive moderation statistics for a time period.
Args:
days: Number of days to analyze
moderator: Optional filter by specific moderator
Returns:
Dictionary containing detailed moderation statistics
"""
cutoff_date = timezone.now() - timedelta(days=days)
base_queryset = EditSubmission.objects.filter(
submitted_at__gte=cutoff_date
)
if moderator:
handled_queryset = base_queryset.filter(handled_by=moderator)
else:
handled_queryset = base_queryset
total_submissions = base_queryset.count()
pending_submissions = base_queryset.filter(status='PENDING').count()
approved_submissions = handled_queryset.filter(status='APPROVED').count()
rejected_submissions = handled_queryset.filter(status='REJECTED').count()
# Response time analysis (only for handled submissions)
handled_with_times = handled_queryset.exclude(
handled_at__isnull=True
).extra(
select={
'response_hours': 'EXTRACT(EPOCH FROM (handled_at - submitted_at)) / 3600'
}
).values_list('response_hours', flat=True)
avg_response_time = None
if handled_with_times:
avg_response_time = sum(handled_with_times) / len(handled_with_times)
return {
'period_days': days,
'total_submissions': total_submissions,
'pending_submissions': pending_submissions,
'approved_submissions': approved_submissions,
'rejected_submissions': rejected_submissions,
'approval_rate': (approved_submissions / (approved_submissions + rejected_submissions) * 100) if (approved_submissions + rejected_submissions) > 0 else 0,
'average_response_time_hours': avg_response_time,
'moderator': moderator.username if moderator else None
}
def submissions_needing_attention(*, hours: int = 24) -> QuerySet[EditSubmission]:
"""
Get pending submissions that have been waiting for more than N hours.
Args:
hours: Number of hours threshold for attention
Returns:
QuerySet of submissions needing attention
"""
cutoff_time = timezone.now() - timedelta(hours=hours)
return EditSubmission.objects.filter(
status='PENDING',
submitted_at__lte=cutoff_time
).select_related(
'submitted_by',
'content_type'
).order_by('submitted_at')
def top_contributors(*, days: int = 30, limit: int = 10) -> QuerySet[User]:
"""
Get users who have submitted the most content in the last N days.
Args:
days: Number of days to analyze
limit: Maximum number of users to return
Returns:
QuerySet of top contributing users
"""
cutoff_date = timezone.now() - timedelta(days=days)
return User.objects.filter(
edit_submissions__submitted_at__gte=cutoff_date
).annotate(
submission_count=Count('edit_submissions')
).filter(
submission_count__gt=0
).order_by('-submission_count')[:limit]
def moderator_workload_summary(*, days: int = 30) -> Dict[str, Any]:
"""
Get workload distribution among moderators.
Args:
days: Number of days to analyze
Returns:
Dictionary containing moderator workload statistics
"""
cutoff_date = timezone.now() - timedelta(days=days)
moderator_stats = User.objects.filter(
handled_submissions__handled_at__gte=cutoff_date
).annotate(
handled_count=Count('handled_submissions')
).filter(
handled_count__gt=0
).order_by('-handled_count').values(
'username', 'handled_count'
)
return {
'period_days': days,
'moderator_stats': list(moderator_stats)
}

244
moderation/services.py Normal file
View File

@@ -0,0 +1,244 @@
"""
Services for moderation functionality.
Following Django styleguide pattern for business logic encapsulation.
"""
from typing import Optional, Dict, Any, Union
from django.db import transaction
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
from django.db.models import QuerySet
from .models import EditSubmission
class ModerationService:
"""Service for handling content moderation workflows."""
@staticmethod
def approve_submission(
*,
submission_id: int,
moderator: User,
notes: Optional[str] = None
) -> Union[object, None]:
"""
Approve a content submission and apply changes.
Args:
submission_id: ID of the submission to approve
moderator: User performing the approval
notes: Optional notes about the approval
Returns:
The created/updated object or None if approval failed
Raises:
EditSubmission.DoesNotExist: If submission doesn't exist
ValidationError: If submission data is invalid
ValueError: If submission cannot be processed
"""
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
if submission.status != 'PENDING':
raise ValueError(f"Submission {submission_id} is not pending approval")
try:
# Call the model's approve method which handles the business logic
obj = submission.approve(moderator)
# Add moderator notes if provided
if notes:
if submission.notes:
submission.notes += f"\n[Moderator]: {notes}"
else:
submission.notes = f"[Moderator]: {notes}"
submission.save()
return obj
except Exception as e:
# Mark as rejected on any error
submission.status = 'REJECTED'
submission.handled_by = moderator
submission.handled_at = timezone.now()
submission.notes = f"Approval failed: {str(e)}"
submission.save()
raise
@staticmethod
def reject_submission(
*,
submission_id: int,
moderator: User,
reason: str
) -> EditSubmission:
"""
Reject a content submission.
Args:
submission_id: ID of the submission to reject
moderator: User performing the rejection
reason: Reason for rejection
Returns:
Updated submission object
Raises:
EditSubmission.DoesNotExist: If submission doesn't exist
ValueError: If submission cannot be rejected
"""
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
if submission.status != 'PENDING':
raise ValueError(f"Submission {submission_id} is not pending review")
submission.status = 'REJECTED'
submission.handled_by = moderator
submission.handled_at = timezone.now()
submission.notes = f"Rejected: {reason}"
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
submission.full_clean()
submission.save()
return submission
@staticmethod
def create_edit_submission(
*,
content_object: object,
changes: Dict[str, Any],
submitter: User,
submission_type: str = "UPDATE",
notes: Optional[str] = None
) -> EditSubmission:
"""
Create a new edit submission for moderation.
Args:
content_object: The object being edited
changes: Dictionary of field changes
submitter: User submitting the changes
submission_type: Type of submission ("CREATE" or "UPDATE")
notes: Optional notes about the submission
Returns:
Created EditSubmission object
Raises:
ValidationError: If submission data is invalid
"""
submission = EditSubmission(
content_object=content_object,
changes=changes,
submitted_by=submitter,
submission_type=submission_type,
notes=notes or ""
)
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
submission.full_clean()
submission.save()
return submission
@staticmethod
def update_submission_changes(
*,
submission_id: int,
moderator_changes: Dict[str, Any],
moderator: User
) -> EditSubmission:
"""
Update submission with moderator changes before approval.
Args:
submission_id: ID of the submission to update
moderator_changes: Dictionary of moderator modifications
moderator: User making the changes
Returns:
Updated submission object
Raises:
EditSubmission.DoesNotExist: If submission doesn't exist
ValueError: If submission cannot be modified
"""
with transaction.atomic():
submission = EditSubmission.objects.select_for_update().get(
id=submission_id
)
if submission.status != 'PENDING':
raise ValueError(f"Submission {submission_id} is not pending review")
submission.moderator_changes = moderator_changes
# Add note about moderator changes
note = f"[Moderator changes by {moderator.username}]"
if submission.notes:
submission.notes += f"\n{note}"
else:
submission.notes = note
# Call full_clean before saving - CRITICAL STYLEGUIDE FIX
submission.full_clean()
submission.save()
return submission
@staticmethod
def get_pending_submissions_for_moderator(
*,
moderator: User,
content_type: Optional[str] = None,
limit: Optional[int] = None,
) -> QuerySet:
"""
Get pending submissions for a moderator to review.
Args:
moderator: The moderator user
content_type: Optional filter by content type
limit: Maximum number of submissions to return
Returns:
QuerySet of pending submissions
"""
from .selectors import pending_submissions_for_review
return pending_submissions_for_review(
content_type=content_type,
limit=limit
)
@staticmethod
def get_submission_statistics(
*,
days: int = 30,
moderator: Optional[User] = None
) -> Dict[str, Any]:
"""
Get moderation statistics for a time period.
Args:
days: Number of days to analyze
moderator: Optional filter by specific moderator
Returns:
Dictionary containing moderation statistics
"""
from .selectors import moderation_statistics_summary
return moderation_statistics_summary(
days=days,
moderator=moderator
)