Compare commits

..

2 Commits

Author SHA1 Message Date
pacnpal
fbfda9a3d8 chore(deps): upgrade Pillow to 12.1.0
- Pillow 11.1.0 → 12.1.0
- Add Django 6 upgrade notes (blocked by CheckConstraint API change)
- Update uv.lock
2026-01-14 15:43:43 -05:00
pacnpal
4140a0d8e7 Add @extend_schema decorators to moderation ViewSet actions
- Add drf_spectacular imports (extend_schema, OpenApiResponse, inline_serializer)
- Annotate claim action with response schemas for 200/404/409/400
- Annotate unclaim action with response schemas for 200/403/400
- Annotate approve action with request=None and response schemas
- Annotate reject action with reason request body schema
- Annotate escalate action with reason request body schema
- All actions tagged with 'Moderation' for API docs grouping
2026-01-13 19:34:41 -05:00
22 changed files with 1392 additions and 1025 deletions

View File

@@ -55,3 +55,45 @@ def get_direct_upload_url(user_id=None):
raise e
return result.get("result", {})
def delete_cloudflare_image(image_id: str) -> bool:
"""
Delete an image from Cloudflare Images.
Used to cleanup orphaned images when submissions are rejected or deleted.
Args:
image_id: The Cloudflare image ID to delete.
Returns:
bool: True if deletion succeeded, False otherwise.
"""
account_id = getattr(settings, "CLOUDFLARE_IMAGES_ACCOUNT_ID", None)
api_token = getattr(settings, "CLOUDFLARE_IMAGES_API_TOKEN", None)
if not account_id or not api_token:
logger.error("Cloudflare settings missing, cannot delete image %s", image_id)
return False
url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}"
headers = {
"Authorization": f"Bearer {api_token}",
}
try:
response = requests.delete(url, headers=headers)
response.raise_for_status()
result = response.json()
if result.get("success"):
logger.info("Successfully deleted Cloudflare image: %s", image_id)
return True
else:
error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message")
logger.warning("Failed to delete Cloudflare image %s: %s", image_id, error_msg)
return False
except requests.RequestException as e:
capture_and_log(e, f"Delete Cloudflare image {image_id}", source="service")
return False

View File

@@ -2,7 +2,7 @@
Django admin configuration for the Moderation application.
This module provides comprehensive admin interfaces for content moderation
including edit submissions, photo submissions, and state transition logs.
including edit submissions and state transition logs.
Includes a custom moderation admin site for dedicated moderation workflows.
Performance targets:
@@ -18,7 +18,7 @@ from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django_fsm_log.models import StateLog
from .models import EditSubmission, PhotoSubmission
from .models import EditSubmission
class ModerationAdminSite(AdminSite):
@@ -52,13 +52,13 @@ class ModerationAdminSite(AdminSite):
# Get pending counts
extra_context["pending_edits"] = EditSubmission.objects.filter(status="PENDING").count()
extra_context["pending_photos"] = PhotoSubmission.objects.filter(status="PENDING").count()
extra_context["pending_photos"] = EditSubmission.objects.filter(submission_type="PHOTO", status="PENDING").count()
# Get recent activity
extra_context["recent_edits"] = EditSubmission.objects.select_related("user", "handled_by").order_by(
"-created_at"
)[:5]
extra_context["recent_photos"] = PhotoSubmission.objects.select_related("user", "handled_by").order_by(
extra_context["recent_photos"] = EditSubmission.objects.filter(submission_type="PHOTO").select_related("user", "handled_by").order_by(
"-created_at"
)[:5]
@@ -307,198 +307,6 @@ class EditSubmissionAdmin(admin.ModelAdmin):
return actions
class PhotoSubmissionAdmin(admin.ModelAdmin):
"""
Admin interface for photo submission moderation.
Provides photo submission management with:
- Image preview in list view
- Bulk approve/reject actions
- FSM-aware status handling
- User and content linking
Query optimizations:
- select_related: user, content_type, handled_by
"""
list_display = (
"id",
"user_link",
"content_type_display",
"content_link",
"photo_preview",
"status_badge",
"created_at",
"handled_by_link",
)
list_filter = ("status", "content_type", "created_at")
list_select_related = ["user", "content_type", "handled_by"]
search_fields = ("user__username", "caption", "notes", "object_id")
readonly_fields = (
"user",
"content_type",
"object_id",
"photo_preview",
"created_at",
)
list_per_page = 50
show_full_result_count = False
ordering = ("-created_at",)
date_hierarchy = "created_at"
fieldsets = (
(
"Submission Details",
{
"fields": ("user", "content_type", "object_id"),
"description": "Who submitted what.",
},
),
(
"Photo",
{
"fields": ("photo", "photo_preview", "caption"),
"description": "The submitted photo.",
},
),
(
"Status",
{
"fields": ("status", "handled_by", "notes"),
"description": "Current status and moderation notes.",
},
),
(
"Metadata",
{
"fields": ("created_at",),
"classes": ("collapse",),
},
),
)
@admin.display(description="User")
def user_link(self, obj):
"""Display user as clickable link."""
if obj.user:
try:
url = reverse("admin:accounts_customuser_change", args=[obj.user.id])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
except Exception:
return obj.user.username
return "-"
@admin.display(description="Type")
def content_type_display(self, obj):
"""Display content type in a readable format."""
if obj.content_type:
return f"{obj.content_type.app_label}.{obj.content_type.model}"
return "-"
@admin.display(description="Content")
def content_link(self, obj):
"""Display content object as clickable link."""
try:
content_obj = obj.content_object
if content_obj:
if hasattr(content_obj, "get_absolute_url"):
url = content_obj.get_absolute_url()
return format_html('<a href="{}">{}</a>', url, str(content_obj)[:30])
return str(content_obj)[:30]
except Exception:
pass
return format_html('<span style="color: red;">Not found</span>')
@admin.display(description="Preview")
def photo_preview(self, obj):
"""Display photo preview thumbnail."""
if obj.photo:
return format_html(
'<img src="{}" style="max-height: 80px; max-width: 150px; '
'border-radius: 4px; object-fit: cover;" loading="lazy" />',
obj.photo.url,
)
return format_html('<span style="color: gray;">No photo</span>')
@admin.display(description="Status")
def status_badge(self, obj):
"""Display status with color-coded badge."""
colors = {
"PENDING": "orange",
"APPROVED": "green",
"REJECTED": "red",
}
color = colors.get(obj.status, "gray")
return format_html(
'<span style="background-color: {}; color: white; padding: 2px 8px; '
'border-radius: 4px; font-size: 11px;">{}</span>',
color,
obj.status,
)
@admin.display(description="Handled By")
def handled_by_link(self, obj):
"""Display handler as clickable link."""
if obj.handled_by:
try:
url = reverse("admin:accounts_customuser_change", args=[obj.handled_by.id])
return format_html('<a href="{}">{}</a>', url, obj.handled_by.username)
except Exception:
return obj.handled_by.username
return "-"
def save_model(self, request, obj, form, change):
"""Handle FSM transitions on status change."""
if "status" in form.changed_data:
try:
if obj.status == "APPROVED":
obj.approve(request.user, obj.notes)
elif obj.status == "REJECTED":
obj.reject(request.user, obj.notes)
except Exception as e:
messages.error(request, f"Status transition failed: {str(e)}")
return
super().save_model(request, obj, form, change)
@admin.action(description="Approve selected photos")
def bulk_approve(self, request, queryset):
"""Approve all selected pending photo submissions."""
count = 0
for submission in queryset.filter(status="PENDING"):
try:
submission.approve(request.user, "Bulk approved")
count += 1
except Exception:
pass
self.message_user(request, f"Approved {count} photo submissions.")
@admin.action(description="Reject selected photos")
def bulk_reject(self, request, queryset):
"""Reject all selected pending photo submissions."""
count = 0
for submission in queryset.filter(status="PENDING"):
try:
submission.reject(request.user, "Bulk rejected")
count += 1
except Exception:
pass
self.message_user(request, f"Rejected {count} photo submissions.")
def get_actions(self, request):
"""Add moderation actions."""
actions = super().get_actions(request)
actions["bulk_approve"] = (
self.bulk_approve,
"bulk_approve",
"Approve selected photos",
)
actions["bulk_reject"] = (
self.bulk_reject,
"bulk_reject",
"Reject selected photos",
)
return actions
class StateLogAdmin(admin.ModelAdmin):
"""
@@ -754,7 +562,6 @@ class HistoryEventAdmin(admin.ModelAdmin):
# Register with moderation site only
moderation_site.register(EditSubmission, EditSubmissionAdmin)
moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin)
moderation_site.register(StateLog, StateLogAdmin)
# Note: Concrete pghistory event models would be registered as they are created

View File

@@ -25,7 +25,6 @@ class ModerationConfig(AppConfig):
EditSubmission,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
# Apply FSM to all models with their respective choice groups
@@ -53,12 +52,6 @@ class ModerationConfig(AppConfig):
choice_group="bulk_operation_statuses",
domain="moderation",
)
apply_state_machine(
PhotoSubmission,
field_name="status",
choice_group="photo_submission_statuses",
domain="moderation",
)
def _register_callbacks(self):
"""Register FSM transition callbacks for moderation models."""
@@ -78,7 +71,6 @@ class ModerationConfig(AppConfig):
EditSubmission,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
# EditSubmission callbacks (transitions from CLAIMED state)
@@ -88,14 +80,6 @@ class ModerationConfig(AppConfig):
register_callback(EditSubmission, "status", "CLAIMED", "REJECTED", ModerationCacheInvalidation())
register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", SubmissionEscalatedNotification())
register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", ModerationCacheInvalidation())
# PhotoSubmission callbacks (transitions from CLAIMED state)
register_callback(PhotoSubmission, "status", "CLAIMED", "APPROVED", SubmissionApprovedNotification())
register_callback(PhotoSubmission, "status", "CLAIMED", "APPROVED", ModerationCacheInvalidation())
register_callback(PhotoSubmission, "status", "CLAIMED", "REJECTED", SubmissionRejectedNotification())
register_callback(PhotoSubmission, "status", "CLAIMED", "REJECTED", ModerationCacheInvalidation())
register_callback(PhotoSubmission, "status", "CLAIMED", "ESCALATED", SubmissionEscalatedNotification())
# ModerationReport callbacks
register_callback(ModerationReport, "status", "*", "*", ModerationNotificationCallback())
register_callback(ModerationReport, "status", "*", "*", ModerationCacheInvalidation())

View File

@@ -33,7 +33,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
from datetime import timedelta
from django.utils import timezone
from apps.moderation.models import EditSubmission, PhotoSubmission
from apps.moderation.models import EditSubmission
minutes = options["minutes"]
dry_run = options["dry_run"]
@@ -47,8 +47,9 @@ class Command(BaseCommand):
status="CLAIMED",
claimed_at__lt=cutoff_time,
).select_related("claimed_by")
stale_photo = PhotoSubmission.objects.filter(
# Also find PHOTO type EditSubmissions
stale_photo = EditSubmission.objects.filter(
submission_type="PHOTO",
status="CLAIMED",
claimed_at__lt=cutoff_time,
).select_related("claimed_by")
@@ -66,7 +67,7 @@ class Command(BaseCommand):
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
)
self.stdout.write(f"Found {stale_photo_count} stale PhotoSubmission claims:")
self.stdout.write(f"Found {stale_photo_count} stale PHOTO submission claims:")
for sub in stale_photo:
self.stdout.write(
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
@@ -84,10 +85,6 @@ class Command(BaseCommand):
f" EditSubmissions: {result['edit_submissions']['released']} released, "
f"{result['edit_submissions']['failed']} failed"
)
self.stdout.write(
f" PhotoSubmissions: {result['photo_submissions']['released']} released, "
f"{result['photo_submissions']['failed']} failed"
)
if result["failures"]:
self.stdout.write(self.style.ERROR("\nFailures:"))

View File

@@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management.base import BaseCommand
from apps.moderation.models import EditSubmission, PhotoSubmission
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from apps.rides.models import Ride
@@ -218,40 +218,38 @@ class Command(BaseCommand):
status="PENDING",
)
# Create PhotoSubmissions with detailed captions
# Create PHOTO submissions using EditSubmission with submission_type=PHOTO
# Park photo submission
image_data = (
b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
)
dummy_image = SimpleUploadedFile("park_entrance.gif", image_data, content_type="image/gif")
PhotoSubmission.objects.create(
EditSubmission.objects.create(
user=user,
content_type=park_ct,
object_id=test_park.id,
photo=dummy_image,
submission_type="PHOTO",
changes={}, # No field changes for photos
caption=(
"Main entrance plaza of Test Park showing the newly installed digital display board "
"and renovated ticketing area. Photo taken during morning park opening."
),
date_taken=date(2024, 1, 15),
status="PENDING",
reason="Photo of park entrance",
)
# Ride photo submission
dummy_image2 = SimpleUploadedFile("coaster_track.gif", image_data, content_type="image/gif")
PhotoSubmission.objects.create(
EditSubmission.objects.create(
user=user,
content_type=ride_ct,
object_id=test_ride.id,
photo=dummy_image2,
submission_type="PHOTO",
changes={}, # No field changes for photos
caption=(
"Test Coaster's first drop and loop element showing the new paint scheme. "
"Photo taken from the guest pathway near Station Alpha."
),
date_taken=date(2024, 1, 20),
status="PENDING",
reason="Photo of ride",
)
self.stdout.write(self.style.SUCCESS("Successfully seeded test submissions"))

View File

@@ -9,7 +9,6 @@ from apps.moderation.models import (
EditSubmission,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
@@ -28,8 +27,7 @@ class Command(BaseCommand):
type=str,
help=(
"Validate only specific model "
"(editsubmission, moderationreport, moderationqueue, "
"bulkoperation, photosubmission)"
"(editsubmission, moderationreport, moderationqueue, bulkoperation)"
),
)
parser.add_argument(
@@ -65,11 +63,7 @@ class Command(BaseCommand):
"bulk_operation_statuses",
"moderation",
),
"photosubmission": (
PhotoSubmission,
"photo_submission_statuses",
"moderation",
),
# Note: PhotoSubmission removed - photos now handled via EditSubmission
}
# Filter by model name if specified

View File

@@ -0,0 +1,47 @@
# Generated by Django 5.2.10 on 2026-01-13 01:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("moderation", "0012_migrate_photo_submissions"),
]
operations = [
migrations.RemoveField(
model_name="photosubmissionevent",
name="pgh_obj",
),
migrations.RemoveField(
model_name="photosubmissionevent",
name="claimed_by",
),
migrations.RemoveField(
model_name="photosubmissionevent",
name="content_type",
),
migrations.RemoveField(
model_name="photosubmissionevent",
name="handled_by",
),
migrations.RemoveField(
model_name="photosubmissionevent",
name="pgh_context",
),
migrations.RemoveField(
model_name="photosubmissionevent",
name="photo",
),
migrations.RemoveField(
model_name="photosubmissionevent",
name="user",
),
migrations.DeleteModel(
name="PhotoSubmission",
),
migrations.DeleteModel(
name="PhotoSubmissionEvent",
),
]

View File

@@ -13,7 +13,7 @@ from django.http import (
)
from django.views.generic import DetailView
from .models import EditSubmission, PhotoSubmission, UserType
from .models import EditSubmission, UserType
User = get_user_model()
@@ -146,6 +146,8 @@ class EditSubmissionMixin(DetailView):
class PhotoSubmissionMixin(DetailView):
"""
Mixin for handling photo submissions with proper moderation.
Photos are now handled via EditSubmission with submission_type='PHOTO'.
"""
model: type[models.Model] | None = None
@@ -177,19 +179,25 @@ class PhotoSubmissionMixin(DetailView):
content_type = ContentType.objects.get_for_model(obj)
submission = PhotoSubmission(
# Create EditSubmission with PHOTO type
submission = EditSubmission(
user=request.user,
content_type=content_type,
object_id=getattr(obj, "id", None),
submission_type="PHOTO",
changes={}, # No field changes for photos
photo=request.FILES["photo"],
caption=request.POST.get("caption", ""),
date_taken=request.POST.get("date_taken"),
reason="Photo submission",
)
# Auto-approve for moderators and above
user_role = getattr(request.user, "role", None)
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
submission.auto_approve()
submission.save()
submission.claim(user=request.user)
submission.approve(cast(UserType, request.user))
return JsonResponse(
{
"status": "success",

View File

@@ -427,13 +427,35 @@ class EditSubmission(StateMachineMixin, TrackedModel):
resolved_changes = self._resolve_foreign_keys(final_changes)
try:
if self.submission_type == "CREATE":
if self.submission_type == "PHOTO":
# Handle photo submissions - create ParkPhoto or RidePhoto
from apps.parks.models.media import ParkPhoto
from apps.rides.models.media import RidePhoto
# Determine the correct photo model based on content type
model_name = model_class.__name__
if model_name == "Park":
PhotoModel = ParkPhoto
elif model_name == "Ride":
PhotoModel = RidePhoto
else:
raise ValueError(f"Unsupported content type for photo: {model_name}")
# Create the approved photo
obj = PhotoModel.objects.create(
uploaded_by=self.user,
content_object=self.content_object,
image=self.photo,
caption=self.caption or "",
is_approved=True,
)
elif self.submission_type == "CREATE":
# Create new object
obj = model_class(**resolved_changes)
obj.full_clean()
obj.save()
else:
# Update existing object
# Update existing object (EDIT type)
if not self.content_object:
raise ValueError("Cannot update: content object not found")
@@ -823,242 +845,8 @@ class BulkOperation(StateMachineMixin, TrackedModel):
return round((self.processed_items / self.total_items) * 100, 2)
@pghistory.track() # Track all changes by default
class PhotoSubmission(StateMachineMixin, TrackedModel):
"""Photo submission model with FSM-managed status transitions."""
state_field_name = "status"
# Who submitted the photo
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="photo_submissions",
help_text="User who submitted this photo",
)
# What the photo is for (Park or Ride)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
help_text="Type of object this photo is for",
)
object_id = models.PositiveIntegerField(help_text="ID of object this photo is for")
content_object = GenericForeignKey("content_type", "object_id")
# The photo itself
photo = models.ForeignKey(
"django_cloudflareimages_toolkit.CloudflareImage",
on_delete=models.CASCADE,
help_text="Photo submission stored on Cloudflare Images",
)
caption = models.CharField(max_length=255, blank=True, help_text="Photo caption")
date_taken = models.DateField(null=True, blank=True, help_text="Date the photo was taken")
# Metadata
status = RichFSMField(
choice_group="photo_submission_statuses", domain="moderation", max_length=20, default="PENDING"
)
created_at = models.DateTimeField(auto_now_add=True)
# Review details
handled_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="handled_photos",
help_text="Moderator who handled this submission",
)
handled_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was handled")
notes = models.TextField(
blank=True,
help_text="Notes from the moderator about this photo submission",
)
# Claim tracking for concurrency control
claimed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="claimed_photo_submissions",
help_text="Moderator who has claimed this submission for review",
)
claimed_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was claimed")
class Meta(TrackedModel.Meta):
verbose_name = "Photo Submission"
verbose_name_plural = "Photo Submissions"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
models.Index(fields=["status"]),
]
def __str__(self) -> str:
return f"Photo submission by {self.user.username} for {self.content_object}"
def claim(self, user: UserType) -> None:
"""
Claim this photo submission for review.
Transition: PENDING -> CLAIMED
Args:
user: The moderator claiming this submission
Raises:
ValidationError: If submission is not in PENDING state
"""
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(f"Cannot claim submission: current status is {self.status}, expected PENDING")
# Set status directly (similar to unclaim method)
# The transition_to_claimed FSM method was never defined
self.status = "CLAIMED"
self.claimed_by = user
self.claimed_at = timezone.now()
self.save()
def unclaim(self, user: UserType = None) -> None:
"""
Release claim on this photo submission.
Transition: CLAIMED -> PENDING
Args:
user: The user initiating the unclaim (for audit)
Raises:
ValidationError: If submission is not in CLAIMED state
"""
from django.core.exceptions import ValidationError
if self.status != "CLAIMED":
raise ValidationError(f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED")
# Set status directly (not via FSM transition to avoid cycle)
# This is intentional - the unclaim action is a special "rollback" operation
self.status = "PENDING"
self.claimed_by = None
self.claimed_at = None
self.save()
def approve(self, moderator: UserType = None, notes: str = "", user=None) -> None:
"""
Approve the photo submission.
Wrapper method that preserves business logic while using FSM.
Args:
moderator: The user approving the submission
notes: Optional approval notes
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
from apps.parks.models.media import ParkPhoto
from apps.rides.models.media import RidePhoto
# Use user parameter if provided (FSM convention)
approver = user or moderator
# Validate state - must be CLAIMED before approval
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot approve photo submission: must be CLAIMED first (current status: {self.status})"
)
# Determine the correct photo model based on the content type
model_class = self.content_type.model_class()
if model_class.__name__ == "Park":
PhotoModel = ParkPhoto
elif model_class.__name__ == "Ride":
PhotoModel = RidePhoto
else:
raise ValueError(f"Unsupported content type: {model_class.__name__}")
# Create the approved photo
PhotoModel.objects.create(
uploaded_by=self.user,
content_object=self.content_object,
image=self.photo,
caption=self.caption,
is_approved=True,
)
# Use FSM transition to update status
self.transition_to_approved(user=approver)
self.handled_by = approver # type: ignore
self.handled_at = timezone.now()
self.notes = notes
self.save()
def reject(self, moderator: UserType = None, notes: str = "", user=None) -> None:
"""
Reject the photo submission.
Wrapper method that preserves business logic while using FSM.
Args:
moderator: The user rejecting the submission
notes: Rejection reason
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
rejecter = user or moderator
# Validate state - must be CLAIMED before rejection
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot reject photo submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_rejected(user=rejecter)
self.handled_by = rejecter # type: ignore
self.handled_at = timezone.now()
self.notes = notes
self.save()
def auto_approve(self) -> None:
"""Auto-approve submissions from moderators."""
# Get user role safely
user_role = getattr(self.user, "role", None)
# If user is moderator or above, claim then approve
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
self.claim(user=self.user)
self.approve(self.user)
def escalate(self, moderator: UserType = None, notes: str = "", user=None) -> None:
"""
Escalate the photo submission to admin.
Wrapper method that preserves business logic while using FSM.
Args:
moderator: The user escalating the submission
notes: Escalation reason
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
escalator = user or moderator
# Validate state - must be CLAIMED before escalation
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot escalate photo submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_escalated(user=escalator)
self.handled_by = escalator # type: ignore
self.handled_at = timezone.now()
self.notes = notes
self.save()
# NOTE: PhotoSubmission model removed - photos are now handled via
# EditSubmission with submission_type="PHOTO". See migration for details.
class ModerationAuditLog(models.Model):

View File

@@ -23,7 +23,6 @@ from .models import (
ModerationAction,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
User = get_user_model()
@@ -76,6 +75,10 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
status_icon = serializers.SerializerMethodField()
status_display = serializers.CharField(source="get_status_display", read_only=True)
time_since_created = serializers.SerializerMethodField()
# Photo URL for frontend compatibility (Cloudflare Images)
photo_url = serializers.SerializerMethodField()
cloudflare_image_id = serializers.SerializerMethodField()
class Meta:
model = EditSubmission
@@ -102,6 +105,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"time_since_created",
# Photo fields (used when submission_type="PHOTO")
"photo",
"photo_url", # Cloudflare image URL for frontend
"cloudflare_image_id",
"caption",
"date_taken",
]
@@ -117,6 +122,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"status_display",
"content_type_name",
"time_since_created",
"photo_url",
"cloudflare_image_id",
]
def get_status_color(self, obj) -> str:
@@ -155,6 +162,16 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
minutes = diff.seconds // 60
return f"{minutes} minutes ago"
def get_photo_url(self, obj) -> str | None:
"""Return Cloudflare image URL for photo submissions."""
if obj.photo:
return getattr(obj.photo, "image_url", None) or getattr(obj.photo, "url", None)
return None
def get_cloudflare_image_id(self, obj) -> str | None:
"""Expose Cloudflare image id for clients expecting Supabase-like fields."""
return getattr(obj.photo, "id", None) if obj.photo else None
class EditSubmissionListSerializer(serializers.ModelSerializer):
"""Optimized serializer for EditSubmission lists."""
@@ -212,6 +229,8 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
"""
entity_type = serializers.CharField(write_only=True, help_text="Entity type: park, ride, company, ride_model")
caption = serializers.CharField(required=False, allow_blank=True)
date_taken = serializers.DateField(required=False, allow_null=True)
class Meta:
model = EditSubmission
@@ -220,10 +239,25 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
"object_id",
"submission_type",
"changes",
"photo",
"caption",
"date_taken",
"reason",
"source",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add photo field with lazy import to avoid app loading cycles
from django_cloudflareimages_toolkit.models import CloudflareImage
self.fields["photo"] = serializers.PrimaryKeyRelatedField(
queryset=CloudflareImage.objects.all(),
required=False,
allow_null=True,
help_text="CloudflareImage id for photo submissions",
)
def validate_entity_type(self, value):
"""Convert entity_type string to ContentType."""
entity_type_map = {
@@ -246,16 +280,17 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
def validate_changes(self, value):
"""Validate changes is a proper JSON object."""
if value is None:
return {}
if not isinstance(value, dict):
raise serializers.ValidationError("Changes must be a JSON object")
if not value:
raise serializers.ValidationError("Changes cannot be empty")
return value
def validate(self, attrs):
"""Cross-field validation."""
submission_type = attrs.get("submission_type", "EDIT")
object_id = attrs.get("object_id")
changes = attrs.get("changes") or {}
# For EDIT submissions, object_id is required
if submission_type == "EDIT" and not object_id:
@@ -268,6 +303,16 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
{"object_id": "object_id must be null for CREATE submissions"}
)
# For PHOTO submissions, enforce required fields and allow empty changes
if submission_type == "PHOTO":
if not object_id:
raise serializers.ValidationError({"object_id": "object_id is required for PHOTO submissions"})
if not attrs.get("photo"):
raise serializers.ValidationError({"photo": "photo is required for PHOTO submissions"})
else:
if not changes:
raise serializers.ValidationError({"changes": "Changes cannot be empty"})
return attrs
@@ -298,6 +343,120 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
return super().create(validated_data)
class CreatePhotoSubmissionSerializer(serializers.ModelSerializer):
"""
Serializer for creating photo submissions with backward compatibility.
This is a specialized serializer for the /photos endpoint that:
- Makes entity_type optional (can be inferred from content_type_id if provided)
- Automatically sets submission_type to "PHOTO"
- Allows empty changes (photos don't have field changes)
Supports both new format (entity_type) and legacy format (content_type_id + object_id).
"""
entity_type = serializers.CharField(
write_only=True,
required=False, # Optional for backward compatibility
allow_blank=True,
help_text="Entity type: park, ride, company, ride_model (optional if content_type provided)"
)
content_type_id = serializers.IntegerField(
write_only=True,
required=False,
help_text="Legacy: ContentType ID (alternative to entity_type)"
)
caption = serializers.CharField(required=False, allow_blank=True, default="")
date_taken = serializers.DateField(required=False, allow_null=True)
reason = serializers.CharField(required=False, allow_blank=True, default="Photo submission")
class Meta:
model = EditSubmission
fields = [
"entity_type",
"content_type_id",
"object_id",
"photo",
"caption",
"date_taken",
"reason",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add photo field with lazy import to avoid app loading cycles
from django_cloudflareimages_toolkit.models import CloudflareImage
self.fields["photo"] = serializers.PrimaryKeyRelatedField(
queryset=CloudflareImage.objects.all(),
required=True, # Photo is required for photo submissions
help_text="CloudflareImage id for photo submissions",
)
def validate(self, attrs):
"""Validate and resolve content_type."""
entity_type = attrs.get("entity_type")
content_type_id = attrs.get("content_type_id")
object_id = attrs.get("object_id")
# Must have object_id
if not object_id:
raise serializers.ValidationError({"object_id": "object_id is required for photo submissions"})
# Must have either entity_type or content_type_id
if not entity_type and not content_type_id:
raise serializers.ValidationError({
"entity_type": "Either entity_type or content_type_id is required"
})
return attrs
def create(self, validated_data):
"""Create a photo submission."""
entity_type = validated_data.pop("entity_type", None)
content_type_id = validated_data.pop("content_type_id", None)
# Resolve ContentType
if entity_type:
# Map entity_type to ContentType
entity_type_map = {
"park": ("parks", "park"),
"ride": ("rides", "ride"),
"company": ("parks", "company"),
"ride_model": ("rides", "ridemodel"),
"manufacturer": ("parks", "company"),
"designer": ("parks", "company"),
"operator": ("parks", "company"),
"property_owner": ("parks", "company"),
}
entity_lower = entity_type.lower()
if entity_lower not in entity_type_map:
raise serializers.ValidationError({
"entity_type": f"Invalid entity_type. Must be one of: {', '.join(entity_type_map.keys())}"
})
app_label, model_name = entity_type_map[entity_lower]
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
elif content_type_id:
# Legacy: Use content_type_id directly
try:
content_type = ContentType.objects.get(pk=content_type_id)
except ContentType.DoesNotExist:
raise serializers.ValidationError({"content_type_id": "Invalid content_type_id"})
else:
raise serializers.ValidationError({"entity_type": "entity_type or content_type_id is required"})
# Set automatic fields for photo submission
validated_data["user"] = self.context["request"].user
validated_data["content_type"] = content_type
validated_data["submission_type"] = "PHOTO"
validated_data["changes"] = {} # Photos don't have field changes
validated_data["status"] = "PENDING"
return super().create(validated_data)
# ============================================================================
# Moderation Report Serializers
# ============================================================================
@@ -983,90 +1142,6 @@ class StateLogSerializer(serializers.ModelSerializer):
read_only_fields = fields
class PhotoSubmissionSerializer(serializers.ModelSerializer):
"""Serializer for PhotoSubmission."""
submitted_by = UserBasicSerializer(source="user", read_only=True)
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
photo_url = serializers.SerializerMethodField()
# UI Metadata
status_display = serializers.CharField(source="get_status_display", read_only=True)
status_color = serializers.SerializerMethodField()
status_icon = serializers.SerializerMethodField()
time_since_created = serializers.SerializerMethodField()
class Meta:
model = PhotoSubmission
fields = [
"id",
"status",
"status_display",
"status_color",
"status_icon",
"content_type",
"content_type_name",
"object_id",
"photo",
"photo_url",
"caption",
"date_taken",
"submitted_by",
"handled_by",
"handled_at",
"notes",
"created_at",
"time_since_created",
]
read_only_fields = [
"id",
"created_at",
"submitted_by",
"handled_by",
"handled_at",
"status_display",
"status_color",
"status_icon",
"content_type_name",
"photo_url",
"time_since_created",
]
def get_photo_url(self, obj) -> str | None:
if obj.photo:
return obj.photo.image_url
return None
def get_status_color(self, obj) -> str:
colors = {
"PENDING": "#f59e0b",
"APPROVED": "#10b981",
"REJECTED": "#ef4444",
}
return colors.get(obj.status, "#6b7280")
def get_status_icon(self, obj) -> str:
icons = {
"PENDING": "heroicons:clock",
"APPROVED": "heroicons:check-circle",
"REJECTED": "heroicons:x-circle",
}
return icons.get(obj.status, "heroicons:question-mark-circle")
def get_time_since_created(self, obj) -> str:
"""Human-readable time since creation."""
now = timezone.now()
diff = now - obj.created_at
if diff.days > 0:
return f"{diff.days} days ago"
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} hours ago"
else:
minutes = diff.seconds // 60
return f"{minutes} minutes ago"
# ============================================================================
# Moderation Audit Log Serializers

View File

@@ -13,7 +13,7 @@ from django_fsm import TransitionNotAllowed
from apps.accounts.models import User
from .models import EditSubmission, ModerationQueue, PhotoSubmission
from .models import EditSubmission, ModerationQueue
class ModerationService:
@@ -444,9 +444,9 @@ class ModerationService:
return queue_item
@staticmethod
def _create_queue_item_for_photo_submission(*, submission: PhotoSubmission, submitter: User) -> ModerationQueue:
def _create_queue_item_for_photo_submission(*, submission: EditSubmission, submitter: User) -> ModerationQueue:
"""
Create a moderation queue item for a photo submission.
Create a moderation queue item for a photo submission (EditSubmission with type=PHOTO).
Args:
submission: The photo submission
@@ -587,8 +587,9 @@ class ModerationService:
raise ValueError(f"Unknown action: {action}")
elif "photo_submission" in queue_item.tags:
# Find PhotoSubmission
submissions = PhotoSubmission.objects.filter(
# Find PHOTO EditSubmission
submissions = EditSubmission.objects.filter(
submission_type="PHOTO",
user=queue_item.flagged_by,
content_type=queue_item.content_type,
object_id=queue_item.entity_id,

View File

@@ -2,7 +2,7 @@
Signal handlers for moderation-related FSM state transitions.
This module provides signal handlers that execute when moderation
models (EditSubmission, PhotoSubmission, ModerationReport, etc.)
models (EditSubmission, ModerationReport, etc.)
undergo state transitions.
Includes:
@@ -114,6 +114,7 @@ def handle_submission_rejected(instance, source, target, user, context=None, **k
Handle submission rejection transitions.
Called when an EditSubmission or PhotoSubmission is rejected.
For photo submissions, queues Cloudflare image cleanup to prevent orphaned assets.
Args:
instance: The submission instance.
@@ -130,6 +131,19 @@ def handle_submission_rejected(instance, source, target, user, context=None, **k
f"Submission {instance.pk} rejected by {user if user else 'system'}" f"{f': {reason}' if reason else ''}"
)
# Cleanup Cloudflare image for rejected photo submissions
if getattr(instance, "submission_type", None) == "PHOTO" and instance.photo:
try:
from apps.moderation.tasks import cleanup_cloudflare_image
# Get image ID from the CloudflareImage model
image_id = getattr(instance.photo, "image_id", None) or str(instance.photo.id)
if image_id:
cleanup_cloudflare_image.delay(image_id)
logger.info(f"Queued Cloudflare image cleanup for rejected submission {instance.pk}")
except Exception as e:
logger.warning(f"Failed to queue Cloudflare image cleanup for submission {instance.pk}: {e}")
def handle_submission_escalated(instance, source, target, user, context=None, **kwargs):
"""
@@ -377,18 +391,13 @@ def register_moderation_signal_handlers():
EditSubmission,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
# EditSubmission handlers
# EditSubmission handlers (handles both EDIT and PHOTO types now)
register_transition_handler(EditSubmission, "*", "APPROVED", handle_submission_approved, stage="post")
register_transition_handler(EditSubmission, "*", "REJECTED", handle_submission_rejected, stage="post")
register_transition_handler(EditSubmission, "*", "ESCALATED", handle_submission_escalated, stage="post")
# PhotoSubmission handlers
register_transition_handler(PhotoSubmission, "*", "APPROVED", handle_submission_approved, stage="post")
register_transition_handler(PhotoSubmission, "*", "REJECTED", handle_submission_rejected, stage="post")
register_transition_handler(PhotoSubmission, "*", "ESCALATED", handle_submission_escalated, stage="post")
# ModerationReport handlers
register_transition_handler(ModerationReport, "*", "RESOLVED", handle_report_resolved, stage="post")
@@ -403,9 +412,6 @@ def register_moderation_signal_handlers():
register_transition_handler(EditSubmission, "PENDING", "CLAIMED", handle_submission_claimed, stage="post")
register_transition_handler(EditSubmission, "CLAIMED", "PENDING", handle_submission_unclaimed, stage="post")
# Claim/Unclaim handlers for PhotoSubmission
register_transition_handler(PhotoSubmission, "PENDING", "CLAIMED", handle_submission_claimed, stage="post")
register_transition_handler(PhotoSubmission, "CLAIMED", "PENDING", handle_submission_unclaimed, stage="post")
logger.info("Registered moderation signal handlers")

View File

@@ -40,7 +40,7 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
Returns:
dict: Summary with counts of processed, succeeded, and failed releases
"""
from apps.moderation.models import EditSubmission, PhotoSubmission
from apps.moderation.models import EditSubmission
if lock_duration_minutes is None:
lock_duration_minutes = DEFAULT_LOCK_DURATION_MINUTES
@@ -52,7 +52,6 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
result = {
"edit_submissions": {"processed": 0, "released": 0, "failed": 0},
"photo_submissions": {"processed": 0, "released": 0, "failed": 0},
"failures": [],
"cutoff_time": cutoff_time.isoformat(),
}
@@ -95,44 +94,7 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
source="task",
)
# Process PhotoSubmissions with stale claims (legacy model - until removed)
stale_photo_ids = list(
PhotoSubmission.objects.filter(
status="CLAIMED",
claimed_at__lt=cutoff_time,
).values_list("id", flat=True)
)
for submission_id in stale_photo_ids:
result["photo_submissions"]["processed"] += 1
try:
with transaction.atomic():
# Lock and fetch the specific row
submission = PhotoSubmission.objects.select_for_update(skip_locked=True).filter(
id=submission_id,
status="CLAIMED", # Re-verify status in case it changed
).first()
if submission:
_release_claim(submission)
result["photo_submissions"]["released"] += 1
logger.info(
"Released stale claim on PhotoSubmission %s (claimed by %s at %s)",
submission_id,
submission.claimed_by,
submission.claimed_at,
)
except Exception as e:
result["photo_submissions"]["failed"] += 1
error_msg = f"PhotoSubmission {submission_id}: {str(e)}"
result["failures"].append(error_msg)
capture_and_log(
e,
f"Release stale claim on PhotoSubmission {submission_id}",
source="task",
)
# Also process EditSubmission with PHOTO type (new unified model)
# Process EditSubmission with PHOTO type (unified model)
stale_photo_edit_ids = list(
EditSubmission.objects.filter(
submission_type="PHOTO",
@@ -169,8 +131,8 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
source="task",
)
total_released = result["edit_submissions"]["released"] + result["photo_submissions"]["released"]
total_failed = result["edit_submissions"]["failed"] + result["photo_submissions"]["failed"]
total_released = result["edit_submissions"]["released"]
total_failed = result["edit_submissions"]["failed"]
logger.info(
"Completed stale claims expiration: %s released, %s failed",
@@ -189,7 +151,7 @@ def _release_claim(submission):
and clear the claimed_by and claimed_at fields.
Args:
submission: EditSubmission or PhotoSubmission instance
submission: EditSubmission instance
"""
# Store info for logging before clearing
claimed_by = submission.claimed_by
@@ -205,3 +167,49 @@ def _release_claim(submission):
claimed_by,
claimed_at,
)
@shared_task(name="moderation.cleanup_cloudflare_image", bind=True, max_retries=3)
def cleanup_cloudflare_image(self, image_id: str) -> dict:
"""
Delete an orphaned or rejected Cloudflare image.
This task is called when a photo submission is rejected to cleanup
the associated Cloudflare image and prevent orphaned assets.
Args:
image_id: The Cloudflare image ID to delete.
Returns:
dict: Result with success status and message.
"""
from apps.core.utils.cloudflare import delete_cloudflare_image
logger.info("Cleaning up Cloudflare image: %s", image_id)
try:
success = delete_cloudflare_image(image_id)
if success:
return {
"image_id": image_id,
"success": True,
"message": "Image deleted successfully",
}
else:
# Retry on failure (may be transient API issue)
raise Exception(f"Failed to delete Cloudflare image {image_id}")
except Exception as e:
logger.warning("Cloudflare image cleanup failed: %s (attempt %d)", str(e), self.request.retries + 1)
# Retry with exponential backoff
try:
self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
except self.MaxRetriesExceededError:
logger.error("Max retries exceeded for Cloudflare image cleanup: %s", image_id)
return {
"image_id": image_id,
"success": False,
"message": f"Failed after {self.request.retries + 1} attempts: {str(e)}",
}

View File

@@ -13,11 +13,10 @@ from django.test import RequestFactory, TestCase
from apps.moderation.admin import (
EditSubmissionAdmin,
HistoryEventAdmin,
PhotoSubmissionAdmin,
StateLogAdmin,
moderation_site,
)
from apps.moderation.models import EditSubmission, PhotoSubmission
from apps.moderation.models import EditSubmission
User = get_user_model()
@@ -101,32 +100,7 @@ class TestEditSubmissionAdmin(TestCase):
assert "bulk_escalate" in actions
class TestPhotoSubmissionAdmin(TestCase):
"""Tests for PhotoSubmissionAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = PhotoSubmissionAdmin(model=PhotoSubmission, admin_site=self.site)
def test_list_display_includes_preview(self):
"""Verify photo preview is in list_display."""
assert "photo_preview" in self.admin.list_display
def test_list_select_related(self):
"""Verify select_related is configured."""
assert "user" in self.admin.list_select_related
assert "content_type" in self.admin.list_select_related
assert "handled_by" in self.admin.list_select_related
def test_moderation_actions_registered(self):
"""Verify moderation actions are registered."""
request = self.factory.get("/admin/")
request.user = User(is_superuser=True)
actions = self.admin.get_actions(request)
assert "bulk_approve" in actions
assert "bulk_reject" in actions
# PhotoSubmissionAdmin tests removed - model consolidated into EditSubmission
class TestStateLogAdmin(TestCase):
@@ -200,9 +174,7 @@ class TestRegisteredModels(TestCase):
"""Verify EditSubmission is registered with moderation site."""
assert EditSubmission in moderation_site._registry
def test_photo_submission_registered(self):
"""Verify PhotoSubmission is registered with moderation site."""
assert PhotoSubmission in moderation_site._registry
# PhotoSubmission registration test removed - model consolidated into EditSubmission
def test_state_log_registered(self):
"""Verify StateLog is registered with moderation site."""

View File

@@ -3,7 +3,7 @@ Comprehensive tests for the moderation app.
This module contains tests for:
- EditSubmission state machine transitions
- PhotoSubmission state machine transitions
- EditSubmission with submission_type="PHOTO" (photo submissions)
- ModerationReport state machine transitions
- ModerationQueue state machine transitions
- BulkOperation state machine transitions
@@ -39,7 +39,6 @@ from ..models import (
ModerationAction,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
User = get_user_model()
@@ -1132,14 +1131,17 @@ class ModerationActionTests(TestCase):
# ============================================================================
# PhotoSubmission FSM Transition Tests
# EditSubmission PHOTO Type FSM Transition Tests
# ============================================================================
class PhotoSubmissionTransitionTests(TestCase):
"""Comprehensive tests for PhotoSubmission FSM transitions.
class PhotoEditSubmissionTransitionTests(TestCase):
"""Comprehensive tests for EditSubmission with submission_type='PHOTO' FSM transitions.
Note: All approve/reject/escalate transitions require CLAIMED state first.
These tests validate that photo submissions (using the unified EditSubmission model)
have correct FSM behavior.
"""
def setUp(self):
@@ -1169,13 +1171,15 @@ class PhotoSubmissionTransitionTests(TestCase):
)
def _create_submission(self, status="PENDING"):
"""Helper to create a PhotoSubmission with proper CloudflareImage."""
submission = PhotoSubmission.objects.create(
"""Helper to create an EditSubmission with submission_type='PHOTO' and proper CloudflareImage."""
submission = EditSubmission.objects.create(
user=self.user,
content_type=self.content_type,
object_id=self.operator.id,
submission_type="PHOTO", # Unified model
photo=self.mock_image,
caption="Test Photo",
changes={}, # Photos use empty changes
status="PENDING", # Always create as PENDING first
)

View File

@@ -83,13 +83,17 @@ class SubmissionApprovalWorkflowTests(TestCase):
def test_photo_submission_approval_workflow(self):
"""
Test complete photo submission approval workflow.
Test complete photo submission approval workflow using EditSubmission.
Flow: User submits photo → Moderator reviews → Moderator approves → Photo created
Note: Photos now use EditSubmission with submission_type="PHOTO" (unified model).
"""
from datetime import timedelta
from django_cloudflareimages_toolkit.models import CloudflareImage
from apps.moderation.models import PhotoSubmission
from apps.moderation.models import EditSubmission
from apps.parks.models import Company, Park
# Create target park
@@ -105,18 +109,21 @@ class SubmissionApprovalWorkflowTests(TestCase):
expires_at=timezone.now() + timedelta(days=365),
)
# User submits a photo
# User submits a photo using unified EditSubmission model
content_type = ContentType.objects.get_for_model(park)
submission = PhotoSubmission.objects.create(
submission = EditSubmission.objects.create(
user=self.regular_user,
content_type=content_type,
object_id=park.id,
submission_type="PHOTO", # Unified model with PHOTO type
status="PENDING",
photo=mock_image,
caption="Beautiful park entrance",
changes={}, # Photos use empty changes dict
)
self.assertEqual(submission.status, "PENDING")
self.assertEqual(submission.submission_type, "PHOTO")
# Moderator claims the submission first (required FSM step)
submission.claim(user=self.moderator)

View File

@@ -45,23 +45,16 @@ class SubmissionListView(TemplateView):
template_name = "moderation/partials/dashboard_content.html"
def get_context_data(self, **kwargs):
from itertools import chain
from .models import EditSubmission, PhotoSubmission
from .models import EditSubmission
context = super().get_context_data(**kwargs)
status = self.request.GET.get("status", "PENDING")
# Get filtered submissions
# Get filtered submissions (EditSubmission now handles all types including PHOTO)
edit_submissions = EditSubmission.objects.filter(status=status).select_related("user")
photo_submissions = PhotoSubmission.objects.filter(status=status).select_related("user")
# Combine and sort
context["submissions"] = sorted(
chain(edit_submissions, photo_submissions),
key=lambda x: x.created_at,
reverse=True,
)
# Sort by created_at descending
context["submissions"] = edit_submissions.order_by("-created_at")
return context
@@ -78,10 +71,10 @@ router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue")
router.register(r"actions", ModerationActionViewSet, basename="moderation-actions")
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
router.register(r"users", UserModerationViewSet, basename="user-moderation")
# EditSubmission - register under both names for compatibility
# EditSubmission - handles all submission types (EDIT, CREATE, PHOTO)
router.register(r"submissions", EditSubmissionViewSet, basename="submissions")
router.register(r"edit-submissions", EditSubmissionViewSet, basename="edit-submissions")
# PhotoSubmission - register under both names for compatibility
# PhotoSubmissionViewSet - now queries EditSubmission with type=PHOTO, kept for API compatibility
router.register(r"photos", PhotoSubmissionViewSet, basename="photos")
router.register(r"photo-submissions", PhotoSubmissionViewSet, basename="photo-submissions")
@@ -98,12 +91,12 @@ fsm_transition_patterns = [
{"app_label": "moderation", "model_name": "editsubmission"},
name="submission_transition",
),
# PhotoSubmission transitions
# PhotoSubmission transitions (now use editsubmission model since photos are EditSubmission with type=PHOTO)
# URL: /api/moderation/photos/<pk>/transition/<transition_name>/
path(
"photos/<int:pk>/transition/<str:transition_name>/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "photosubmission"},
{"app_label": "moderation", "model_name": "editsubmission"},
name="photo_transition",
),
# ModerationReport transitions
@@ -150,23 +143,23 @@ fsm_transition_patterns = [
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
name="escalate_submission",
),
# Backward compatibility aliases for PhotoSubmission actions
# Photo transition aliases (use editsubmission model since photos are EditSubmission with type=PHOTO)
path(
"photos/<int:pk>/approve/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_approved"},
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_approved"},
name="approve_photo",
),
path(
"photos/<int:pk>/reject/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_rejected"},
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_rejected"},
name="reject_photo",
),
path(
"photos/<int:pk>/escalate/",
FSMTransitionView.as_view(),
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_escalated"},
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
name="escalate_photo",
),
]

View File

@@ -20,11 +20,13 @@ from django.shortcuts import render
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from django_fsm import TransitionNotAllowed, can_proceed
from rest_framework import permissions, status, viewsets
from rest_framework import permissions, serializers as drf_serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from apps.core.logging import log_business_event
from apps.core.state_machine.exceptions import (
TransitionPermissionDenied,
@@ -44,7 +46,6 @@ from .models import (
ModerationAction,
ModerationQueue,
ModerationReport,
PhotoSubmission,
)
from .permissions import (
CanViewModerationData,
@@ -59,12 +60,12 @@ from .serializers import (
CreateEditSubmissionSerializer,
CreateModerationActionSerializer,
CreateModerationReportSerializer,
CreatePhotoSubmissionSerializer,
EditSubmissionListSerializer,
EditSubmissionSerializer,
ModerationActionSerializer,
ModerationQueueSerializer,
ModerationReportSerializer,
PhotoSubmissionSerializer,
UpdateModerationReportSerializer,
UserModerationProfileSerializer,
)
@@ -1566,6 +1567,30 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
return Response({"items": [item]})
@extend_schema(
summary="Claim a submission for review",
description="Claim a submission for review with concurrency protection using database row locking. "
"Prevents race conditions when multiple moderators try to claim the same submission.",
request=None,
responses={
200: inline_serializer(
name="ClaimSuccessResponse",
fields={
"success": drf_serializers.BooleanField(),
"locked_until": drf_serializers.DateTimeField(),
"submission_id": drf_serializers.CharField(),
"claimed_by": drf_serializers.CharField(),
"claimed_at": drf_serializers.DateTimeField(allow_null=True),
"status": drf_serializers.CharField(),
"lock_duration_minutes": drf_serializers.IntegerField(),
},
),
404: OpenApiResponse(description="Submission not found"),
409: OpenApiResponse(description="Submission already claimed or being claimed by another moderator"),
400: OpenApiResponse(description="Invalid state for claiming (not PENDING)"),
},
tags=["Moderation"],
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
@@ -1646,6 +1671,18 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except ValidationError as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Release claim on a submission",
description="Release the current user's claim on a submission. "
"Only the claiming moderator or an admin can unclaim.",
request=None,
responses={
200: EditSubmissionSerializer,
403: OpenApiResponse(description="Only the claiming moderator or admin can unclaim"),
400: OpenApiResponse(description="Submission is not claimed"),
},
tags=["Moderation"],
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def unclaim(self, request, pk=None):
"""
@@ -1683,6 +1720,17 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Approve a submission",
description="Approve an edit submission and apply the proposed changes. "
"Only moderators and admins can approve submissions.",
request=None,
responses={
200: EditSubmissionSerializer,
400: OpenApiResponse(description="Approval failed due to validation error"),
},
tags=["Moderation"],
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def approve(self, request, pk=None):
submission = self.get_object()
@@ -1694,6 +1742,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Reject a submission",
description="Reject an edit submission with an optional reason. "
"The submitter will be notified of the rejection.",
request=inline_serializer(
name="RejectSubmissionRequest",
fields={"reason": drf_serializers.CharField(required=False, allow_blank=True)},
),
responses={
200: EditSubmissionSerializer,
400: OpenApiResponse(description="Rejection failed due to validation error"),
},
tags=["Moderation"],
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def reject(self, request, pk=None):
submission = self.get_object()
@@ -1706,6 +1768,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Escalate a submission",
description="Escalate an edit submission to senior moderators or admins with a reason. "
"Used for complex or controversial submissions requiring higher-level review.",
request=inline_serializer(
name="EscalateSubmissionRequest",
fields={"reason": drf_serializers.CharField(required=False, allow_blank=True)},
),
responses={
200: EditSubmissionSerializer,
400: OpenApiResponse(description="Escalation failed due to validation error"),
},
tags=["Moderation"],
)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def escalate(self, request, pk=None):
submission = self.get_object()
@@ -2145,6 +2221,13 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
ordering = ["-created_at"]
permission_classes = [CanViewModerationData]
def get_serializer_class(self):
if self.action == "list":
return EditSubmissionListSerializer
if self.action == "create":
return CreatePhotoSubmissionSerializer # Use photo-specific serializer
return EditSubmissionSerializer
def get_queryset(self):
queryset = EditSubmission.objects.filter(submission_type="PHOTO")
status_param = self.request.query_params.get("status")
@@ -2158,6 +2241,26 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
return queryset
def create(self, request, *args, **kwargs):
"""
Create a photo submission.
Backward-compatible: Uses CreatePhotoSubmissionSerializer for input
validation which supports both new format (entity_type) and legacy
format (content_type_id). Returns full submission data via EditSubmissionSerializer.
"""
# Use CreatePhotoSubmissionSerializer for input validation
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
# Return the created instance using EditSubmissionSerializer for full output
# This includes id, status, timestamps, etc. that clients need
instance = serializer.instance
response_serializer = EditSubmissionSerializer(instance, context={"request": request})
headers = self.get_success_headers(response_serializer.data)
return Response(response_serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
@@ -2250,7 +2353,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
event_type="submission_unclaimed",
message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}",
context={
"model": "PhotoSubmission",
"model": "EditSubmission",
"object_id": submission.id,
"unclaimed_by": request.user.username,
},

View File

@@ -30,7 +30,7 @@ dependencies = [
# =============================================================================
# Image Processing & Media
# =============================================================================
"Pillow>=10.4.0,<11.2",
"Pillow>=12.0",
"django-cleanup>=8.1.0",
"piexif>=1.1.3",
"django-cloudflareimages-toolkit>=1.0.6",
@@ -193,6 +193,7 @@ output = "coverage.xml"
[tool.uv.sources]
python-json-logger = { url = "https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl" }
# Removed django-celery-beat git source - using constraint override in workspace root
# =============================================================================
# Ruff Configuration

1082
backend/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,13 @@ readme = "docs/README.md"
[tool.uv.workspace]
members = ["backend"]
# Django 6 upgrade commented out - requires CheckConstraint API migration
# See: https://docs.djangoproject.com/en/6.0/releases/6.0/#features-removed-in-6-0
# CheckConstraint(check=...) → CheckConstraint(condition=...) in 50+ files
# [tool.uv]
# override-dependencies = ["django>=6.0"]
# =============================================================================
# Ruff Configuration (shared across workspace)
# =============================================================================

107
uv.lock generated
View File

@@ -837,7 +837,7 @@ wheels = [
[[package]]
name = "django-typer"
version = "3.5.0"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -845,9 +845,9 @@ dependencies = [
{ name = "shellingham" },
{ name = "typer-slim" },
]
sdist = { url = "https://files.pythonhosted.org/packages/29/08/08b7ed6d33dcc07538b522dea121fd65412cc5fb57716da1a58158a272ba/django_typer-3.5.0.tar.gz", hash = "sha256:48e1c0296979eae9e76d3bce6ed9bbbff0ca40fc0753eaa66f7826919416be9a", size = 3074197, upload-time = "2025-11-22T17:26:07.62Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/69/ad177aa9acd536f4b6f45a46cf25c4549e2f88169115492a87666e2c84ac/django_typer-3.5.1.tar.gz", hash = "sha256:9e9a6e9093b97fcb61c003a192f4398249830346c64f60276485f4c8b1e331f8", size = 3083716, upload-time = "2026-01-14T15:42:17.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/88/ed897a5d38be8b0dd6f9d9d482e86f7eeb48a5503250cbd217461c37e04d/django_typer-3.5.0-py3-none-any.whl", hash = "sha256:ffb0222f915bbdcfb24ef179aa374a3d7ab61454a4043dd4c7f97cb6a1b79950", size = 295611, upload-time = "2025-11-22T17:26:05.488Z" },
{ url = "https://files.pythonhosted.org/packages/2c/1d/891bffc8d93000f91e06d598bee625d7a47df6fd7c7908b746070d5660d6/django_typer-3.5.1-py3-none-any.whl", hash = "sha256:e8a68aa6f95bf40f8fff66a2af16d90e6a21c7963564c3aecb25a0e37c47033d", size = 295789, upload-time = "2026-01-14T15:42:15.804Z" },
]
[[package]]
@@ -919,26 +919,26 @@ wheels = [
[[package]]
name = "faker"
version = "40.1.0"
version = "40.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d7/1d/aa43ef59589ddf3647df918143f1bac9eb004cce1c43124ee3347061797d/faker-40.1.0.tar.gz", hash = "sha256:c402212a981a8a28615fea9120d789e3f6062c0c259a82bfb8dff5d273e539d2", size = 1948784, upload-time = "2025-12-29T18:06:00.659Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/77/1c3ff07b6739b9a1d23ca01ec0a90a309a33b78e345a3eb52f9ce9240e36/faker-40.1.2.tar.gz", hash = "sha256:b76a68163aa5f171d260fc24827a8349bc1db672f6a665359e8d0095e8135d30", size = 1949802, upload-time = "2026-01-13T20:51:49.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/23/e22da510e1ec1488966330bf76d8ff4bd535cbfc93660eeb7657761a1bb2/faker-40.1.0-py3-none-any.whl", hash = "sha256:a616d35818e2a2387c297de80e2288083bc915e24b7e39d2fb5bc66cce3a929f", size = 1985317, upload-time = "2025-12-29T18:05:58.831Z" },
{ url = "https://files.pythonhosted.org/packages/46/ec/91a434c8a53d40c3598966621dea9c50512bec6ce8e76fa1751015e74cef/faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42", size = 1985633, upload-time = "2026-01-13T20:51:47.982Z" },
]
[[package]]
name = "fido2"
version = "2.0.0"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/b9/6ec8d8ec5715efc6ae39e8694bd48d57c189906f0628558f56688d0447b2/fido2-2.0.0.tar.gz", hash = "sha256:3061cd05e73b3a0ef6afc3b803d57c826aa2d6a9732d16abd7277361f58e7964", size = 274942, upload-time = "2025-05-20T09:45:00.974Z" }
sdist = { url = "https://files.pythonhosted.org/packages/43/f0/4f99ff79569a8f59e629bd4c042d5f160781dae58a818bb98c1127e4a6b4/fido2-2.1.0.tar.gz", hash = "sha256:b84da93f9bf608a675feb8445cc1d0cec2b8a02d46d165a1ba2fc8a1ab3bdce1", size = 4455878, upload-time = "2026-01-14T14:04:04.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/7d/a1dba174d7ec4b6b8d6360eed0ac3a4a4e2aa45f234e903592d3184c6c3f/fido2-2.0.0-py3-none-any.whl", hash = "sha256:685f54a50a57e019c6156e2dd699802a603e3abf70bab334f26affdd4fb8d4f7", size = 224761, upload-time = "2025-05-20T09:44:59.029Z" },
{ url = "https://files.pythonhosted.org/packages/81/af/fa786b701282592747c4c52080e319e77a77be2cf2f2c685d4ebbfd6140a/fido2-2.1.0-py3-none-any.whl", hash = "sha256:1bcc68f5664c31a184eb7c54de4dbad3bdf3ed19705e565a778ec8fd1376658a", size = 226913, upload-time = "2026-01-14T14:03:59.086Z" },
]
[[package]]
@@ -1284,11 +1284,11 @@ wheels = [
[[package]]
name = "pathspec"
version = "1.0.2"
version = "1.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/b9/6eb731b52f132181a9144bbe77ff82117f6b2d2fbfba49aaab2c014c4760/pathspec-1.0.2.tar.gz", hash = "sha256:fa32b1eb775ed9ba8d599b22c5f906dc098113989da2c00bf8b210078ca7fb92", size = 130502, upload-time = "2026-01-08T04:33:27.613Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/6b/14fc9049d78435fd29e82846c777bd7ed9c470013dc8d0260fff3ff1c11e/pathspec-1.0.2-py3-none-any.whl", hash = "sha256:62f8558917908d237d399b9b338ef455a814801a4688bc41074b25feefd93472", size = 54844, upload-time = "2026-01-08T04:33:26.4Z" },
{ url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
]
[[package]]
@@ -1302,29 +1302,60 @@ wheels = [
[[package]]
name = "pillow"
version = "11.1.0"
version = "12.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20", size = 46742715, upload-time = "2025-01-02T08:13:58.407Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/31/9ca79cafdce364fd5c980cd3416c20ce1bebd235b470d262f9d24d810184/pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc", size = 3226640, upload-time = "2025-01-02T08:11:58.329Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0f/ff07ad45a1f172a497aa393b13a9d81a32e1477ef0e869d030e3c1532521/pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0", size = 3101437, upload-time = "2025-01-02T08:12:01.797Z" },
{ url = "https://files.pythonhosted.org/packages/08/2f/9906fca87a68d29ec4530be1f893149e0cb64a86d1f9f70a7cfcdfe8ae44/pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1", size = 4326605, upload-time = "2025-01-02T08:12:05.224Z" },
{ url = "https://files.pythonhosted.org/packages/b0/0f/f3547ee15b145bc5c8b336401b2d4c9d9da67da9dcb572d7c0d4103d2c69/pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec", size = 4411173, upload-time = "2025-01-02T08:12:08.281Z" },
{ url = "https://files.pythonhosted.org/packages/b1/df/bf8176aa5db515c5de584c5e00df9bab0713548fd780c82a86cba2c2fedb/pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5", size = 4369145, upload-time = "2025-01-02T08:12:11.411Z" },
{ url = "https://files.pythonhosted.org/packages/de/7c/7433122d1cfadc740f577cb55526fdc39129a648ac65ce64db2eb7209277/pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114", size = 4496340, upload-time = "2025-01-02T08:12:15.29Z" },
{ url = "https://files.pythonhosted.org/packages/25/46/dd94b93ca6bd555588835f2504bd90c00d5438fe131cf01cfa0c5131a19d/pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352", size = 4296906, upload-time = "2025-01-02T08:12:17.485Z" },
{ url = "https://files.pythonhosted.org/packages/a8/28/2f9d32014dfc7753e586db9add35b8a41b7a3b46540e965cb6d6bc607bd2/pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3", size = 4431759, upload-time = "2025-01-02T08:12:20.382Z" },
{ url = "https://files.pythonhosted.org/packages/33/48/19c2cbe7403870fbe8b7737d19eb013f46299cdfe4501573367f6396c775/pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9", size = 2291657, upload-time = "2025-01-02T08:12:23.922Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ad/285c556747d34c399f332ba7c1a595ba245796ef3e22eae190f5364bb62b/pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c", size = 2626304, upload-time = "2025-01-02T08:12:28.069Z" },
{ url = "https://files.pythonhosted.org/packages/e5/7b/ef35a71163bf36db06e9c8729608f78dedf032fc8313d19bd4be5c2588f3/pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65", size = 2375117, upload-time = "2025-01-02T08:12:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/77f54228401e84d6791354888549b45824ab0ffde659bafa67956303a09f/pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861", size = 3230060, upload-time = "2025-01-02T08:12:32.362Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b1/56723b74b07dd64c1010fee011951ea9c35a43d8020acd03111f14298225/pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081", size = 3106192, upload-time = "2025-01-02T08:12:34.361Z" },
{ url = "https://files.pythonhosted.org/packages/e1/cd/7bf7180e08f80a4dcc6b4c3a0aa9e0b0ae57168562726a05dc8aa8fa66b0/pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c", size = 4446805, upload-time = "2025-01-02T08:12:36.99Z" },
{ url = "https://files.pythonhosted.org/packages/97/42/87c856ea30c8ed97e8efbe672b58c8304dee0573f8c7cab62ae9e31db6ae/pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547", size = 4530623, upload-time = "2025-01-02T08:12:41.912Z" },
{ url = "https://files.pythonhosted.org/packages/ff/41/026879e90c84a88e33fb00cc6bd915ac2743c67e87a18f80270dfe3c2041/pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab", size = 4465191, upload-time = "2025-01-02T08:12:45.186Z" },
{ url = "https://files.pythonhosted.org/packages/e5/fb/a7960e838bc5df57a2ce23183bfd2290d97c33028b96bde332a9057834d3/pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9", size = 2295494, upload-time = "2025-01-02T08:12:47.098Z" },
{ url = "https://files.pythonhosted.org/packages/d7/6c/6ec83ee2f6f0fda8d4cf89045c6be4b0373ebfc363ba8538f8c999f63fcd/pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe", size = 2631595, upload-time = "2025-01-02T08:12:50.47Z" },
{ url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651, upload-time = "2025-01-02T08:12:53.356Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
{ url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
{ url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
{ url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
{ url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
{ url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
{ url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
{ url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
{ url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
{ url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
{ url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
{ url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
{ url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
{ url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
{ url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
{ url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
{ url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
{ url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
{ url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
{ url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
{ url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
{ url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
{ url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
{ url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
{ url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
{ url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
{ url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
{ url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
{ url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
{ url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
{ url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
{ url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
{ url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
]
[[package]]
@@ -2167,7 +2198,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "nplusone", specifier = ">=1.0.0" },
{ name = "piexif", specifier = ">=1.1.3" },
{ name = "pillow", specifier = ">=10.4.0,<11.2" },
{ name = "pillow", specifier = ">=12.0" },
{ name = "psutil", specifier = ">=7.0.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
{ name = "pycountry", specifier = ">=24.6.1" },
@@ -2255,15 +2286,15 @@ wheels = [
[[package]]
name = "typer-slim"
version = "0.20.1"
version = "0.21.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/3d/6a4ec47010e8de34dade20c8e7bce90502b173f62a6b41619523a3fcf562/typer_slim-0.20.1.tar.gz", hash = "sha256:bb9e4f7e6dc31551c8a201383df322b81b0ce37239a5ead302598a2ebb6f7c9c", size = 106113, upload-time = "2025-12-19T16:48:54.206Z" }
sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/f9/a273c8b57c69ac1b90509ebda204972265fdc978fbbecc25980786f8c038/typer_slim-0.20.1-py3-none-any.whl", hash = "sha256:8e89c5dbaffe87a4f86f4c7a9e2f7059b5b68c66f558f298969d42ce34f10122", size = 47440, upload-time = "2025-12-19T16:48:52.678Z" },
{ url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" },
]
[[package]]