mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 11:45:18 -05:00
Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69a4d393b0 |
@@ -55,45 +55,3 @@ 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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Django admin configuration for the Moderation application.
|
||||
|
||||
This module provides comprehensive admin interfaces for content moderation
|
||||
including edit submissions and state transition logs.
|
||||
including edit submissions, photo 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
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
|
||||
|
||||
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"] = EditSubmission.objects.filter(submission_type="PHOTO", status="PENDING").count()
|
||||
extra_context["pending_photos"] = PhotoSubmission.objects.filter(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"] = EditSubmission.objects.filter(submission_type="PHOTO").select_related("user", "handled_by").order_by(
|
||||
extra_context["recent_photos"] = PhotoSubmission.objects.select_related("user", "handled_by").order_by(
|
||||
"-created_at"
|
||||
)[:5]
|
||||
|
||||
@@ -307,6 +307,198 @@ 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):
|
||||
"""
|
||||
@@ -562,6 +754,7 @@ 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
|
||||
|
||||
@@ -25,6 +25,7 @@ class ModerationConfig(AppConfig):
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
# Apply FSM to all models with their respective choice groups
|
||||
@@ -52,6 +53,12 @@ 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."""
|
||||
@@ -71,6 +78,7 @@ class ModerationConfig(AppConfig):
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
# EditSubmission callbacks (transitions from CLAIMED state)
|
||||
@@ -80,6 +88,14 @@ 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())
|
||||
|
||||
@@ -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
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
|
||||
minutes = options["minutes"]
|
||||
dry_run = options["dry_run"]
|
||||
@@ -47,9 +47,8 @@ class Command(BaseCommand):
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).select_related("claimed_by")
|
||||
# Also find PHOTO type EditSubmissions
|
||||
stale_photo = EditSubmission.objects.filter(
|
||||
submission_type="PHOTO",
|
||||
|
||||
stale_photo = PhotoSubmission.objects.filter(
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).select_related("claimed_by")
|
||||
@@ -67,7 +66,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 PHOTO submission claims:")
|
||||
self.stdout.write(f"Found {stale_photo_count} stale PhotoSubmission claims:")
|
||||
for sub in stale_photo:
|
||||
self.stdout.write(
|
||||
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
|
||||
@@ -85,6 +84,10 @@ 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:"))
|
||||
|
||||
@@ -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
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
|
||||
@@ -218,38 +218,40 @@ class Command(BaseCommand):
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Create PHOTO submissions using EditSubmission with submission_type=PHOTO
|
||||
# Create PhotoSubmissions with detailed captions
|
||||
|
||||
# Park photo submission
|
||||
EditSubmission.objects.create(
|
||||
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(
|
||||
user=user,
|
||||
content_type=park_ct,
|
||||
object_id=test_park.id,
|
||||
submission_type="PHOTO",
|
||||
changes={}, # No field changes for photos
|
||||
photo=dummy_image,
|
||||
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
|
||||
EditSubmission.objects.create(
|
||||
dummy_image2 = SimpleUploadedFile("coaster_track.gif", image_data, content_type="image/gif")
|
||||
PhotoSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=ride_ct,
|
||||
object_id=test_ride.id,
|
||||
submission_type="PHOTO",
|
||||
changes={}, # No field changes for photos
|
||||
photo=dummy_image2,
|
||||
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"))
|
||||
|
||||
@@ -9,6 +9,7 @@ from apps.moderation.models import (
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
|
||||
@@ -27,7 +28,8 @@ class Command(BaseCommand):
|
||||
type=str,
|
||||
help=(
|
||||
"Validate only specific model "
|
||||
"(editsubmission, moderationreport, moderationqueue, bulkoperation)"
|
||||
"(editsubmission, moderationreport, moderationqueue, "
|
||||
"bulkoperation, photosubmission)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -63,7 +65,11 @@ class Command(BaseCommand):
|
||||
"bulk_operation_statuses",
|
||||
"moderation",
|
||||
),
|
||||
# Note: PhotoSubmission removed - photos now handled via EditSubmission
|
||||
"photosubmission": (
|
||||
PhotoSubmission,
|
||||
"photo_submission_statuses",
|
||||
"moderation",
|
||||
),
|
||||
}
|
||||
|
||||
# Filter by model name if specified
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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",
|
||||
),
|
||||
]
|
||||
@@ -13,7 +13,7 @@ from django.http import (
|
||||
)
|
||||
from django.views.generic import DetailView
|
||||
|
||||
from .models import EditSubmission, UserType
|
||||
from .models import EditSubmission, PhotoSubmission, UserType
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -146,8 +146,6 @@ 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
|
||||
@@ -179,25 +177,19 @@ class PhotoSubmissionMixin(DetailView):
|
||||
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
# Create EditSubmission with PHOTO type
|
||||
submission = EditSubmission(
|
||||
submission = PhotoSubmission(
|
||||
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.save()
|
||||
submission.claim(user=request.user)
|
||||
submission.approve(cast(UserType, request.user))
|
||||
submission.auto_approve()
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
|
||||
@@ -427,35 +427,13 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
resolved_changes = self._resolve_foreign_keys(final_changes)
|
||||
|
||||
try:
|
||||
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":
|
||||
if self.submission_type == "CREATE":
|
||||
# Create new object
|
||||
obj = model_class(**resolved_changes)
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
else:
|
||||
# Update existing object (EDIT type)
|
||||
# Update existing object
|
||||
if not self.content_object:
|
||||
raise ValueError("Cannot update: content object not found")
|
||||
|
||||
@@ -845,8 +823,242 @@ class BulkOperation(StateMachineMixin, TrackedModel):
|
||||
return round((self.processed_items / self.total_items) * 100, 2)
|
||||
|
||||
|
||||
# NOTE: PhotoSubmission model removed - photos are now handled via
|
||||
# EditSubmission with submission_type="PHOTO". See migration for details.
|
||||
@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()
|
||||
|
||||
|
||||
class ModerationAuditLog(models.Model):
|
||||
|
||||
@@ -23,6 +23,7 @@ from .models import (
|
||||
ModerationAction,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -75,10 +76,6 @@ 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
|
||||
@@ -105,8 +102,6 @@ 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",
|
||||
]
|
||||
@@ -122,8 +117,6 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"status_display",
|
||||
"content_type_name",
|
||||
"time_since_created",
|
||||
"photo_url",
|
||||
"cloudflare_image_id",
|
||||
]
|
||||
|
||||
def get_status_color(self, obj) -> str:
|
||||
@@ -162,16 +155,6 @@ 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."""
|
||||
@@ -229,8 +212,6 @@ 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
|
||||
@@ -239,25 +220,10 @@ 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 = {
|
||||
@@ -280,17 +246,16 @@ 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:
|
||||
@@ -303,16 +268,6 @@ 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
|
||||
|
||||
@@ -343,120 +298,6 @@ 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
|
||||
# ============================================================================
|
||||
@@ -1142,6 +983,90 @@ 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
|
||||
|
||||
@@ -13,7 +13,7 @@ from django_fsm import TransitionNotAllowed
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
from .models import EditSubmission, ModerationQueue
|
||||
from .models import EditSubmission, ModerationQueue, PhotoSubmission
|
||||
|
||||
|
||||
class ModerationService:
|
||||
@@ -444,9 +444,9 @@ class ModerationService:
|
||||
return queue_item
|
||||
|
||||
@staticmethod
|
||||
def _create_queue_item_for_photo_submission(*, submission: EditSubmission, submitter: User) -> ModerationQueue:
|
||||
def _create_queue_item_for_photo_submission(*, submission: PhotoSubmission, submitter: User) -> ModerationQueue:
|
||||
"""
|
||||
Create a moderation queue item for a photo submission (EditSubmission with type=PHOTO).
|
||||
Create a moderation queue item for a photo submission.
|
||||
|
||||
Args:
|
||||
submission: The photo submission
|
||||
@@ -587,9 +587,8 @@ class ModerationService:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
elif "photo_submission" in queue_item.tags:
|
||||
# Find PHOTO EditSubmission
|
||||
submissions = EditSubmission.objects.filter(
|
||||
submission_type="PHOTO",
|
||||
# Find PhotoSubmission
|
||||
submissions = PhotoSubmission.objects.filter(
|
||||
user=queue_item.flagged_by,
|
||||
content_type=queue_item.content_type,
|
||||
object_id=queue_item.entity_id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Signal handlers for moderation-related FSM state transitions.
|
||||
|
||||
This module provides signal handlers that execute when moderation
|
||||
models (EditSubmission, ModerationReport, etc.)
|
||||
models (EditSubmission, PhotoSubmission, ModerationReport, etc.)
|
||||
undergo state transitions.
|
||||
|
||||
Includes:
|
||||
@@ -114,7 +114,6 @@ 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.
|
||||
@@ -131,19 +130,6 @@ 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):
|
||||
"""
|
||||
@@ -391,13 +377,18 @@ def register_moderation_signal_handlers():
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
# EditSubmission handlers (handles both EDIT and PHOTO types now)
|
||||
# EditSubmission handlers
|
||||
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")
|
||||
@@ -412,6 +403,9 @@ 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")
|
||||
|
||||
|
||||
@@ -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
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
|
||||
if lock_duration_minutes is None:
|
||||
lock_duration_minutes = DEFAULT_LOCK_DURATION_MINUTES
|
||||
@@ -52,6 +52,7 @@ 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(),
|
||||
}
|
||||
@@ -94,7 +95,44 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
||||
source="task",
|
||||
)
|
||||
|
||||
# Process EditSubmission with PHOTO type (unified model)
|
||||
# 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)
|
||||
stale_photo_edit_ids = list(
|
||||
EditSubmission.objects.filter(
|
||||
submission_type="PHOTO",
|
||||
@@ -131,8 +169,8 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
||||
source="task",
|
||||
)
|
||||
|
||||
total_released = result["edit_submissions"]["released"]
|
||||
total_failed = result["edit_submissions"]["failed"]
|
||||
total_released = result["edit_submissions"]["released"] + result["photo_submissions"]["released"]
|
||||
total_failed = result["edit_submissions"]["failed"] + result["photo_submissions"]["failed"]
|
||||
|
||||
logger.info(
|
||||
"Completed stale claims expiration: %s released, %s failed",
|
||||
@@ -151,7 +189,7 @@ def _release_claim(submission):
|
||||
and clear the claimed_by and claimed_at fields.
|
||||
|
||||
Args:
|
||||
submission: EditSubmission instance
|
||||
submission: EditSubmission or PhotoSubmission instance
|
||||
"""
|
||||
# Store info for logging before clearing
|
||||
claimed_by = submission.claimed_by
|
||||
@@ -167,49 +205,3 @@ 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)}",
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ from django.test import RequestFactory, TestCase
|
||||
from apps.moderation.admin import (
|
||||
EditSubmissionAdmin,
|
||||
HistoryEventAdmin,
|
||||
PhotoSubmissionAdmin,
|
||||
StateLogAdmin,
|
||||
moderation_site,
|
||||
)
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -100,7 +101,32 @@ class TestEditSubmissionAdmin(TestCase):
|
||||
assert "bulk_escalate" in actions
|
||||
|
||||
|
||||
# PhotoSubmissionAdmin tests removed - model consolidated into EditSubmission
|
||||
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
|
||||
|
||||
|
||||
class TestStateLogAdmin(TestCase):
|
||||
@@ -174,7 +200,9 @@ class TestRegisteredModels(TestCase):
|
||||
"""Verify EditSubmission is registered with moderation site."""
|
||||
assert EditSubmission in moderation_site._registry
|
||||
|
||||
# PhotoSubmission registration test removed - model consolidated into EditSubmission
|
||||
def test_photo_submission_registered(self):
|
||||
"""Verify PhotoSubmission is registered with moderation site."""
|
||||
assert PhotoSubmission in moderation_site._registry
|
||||
|
||||
def test_state_log_registered(self):
|
||||
"""Verify StateLog is registered with moderation site."""
|
||||
|
||||
@@ -3,7 +3,7 @@ Comprehensive tests for the moderation app.
|
||||
|
||||
This module contains tests for:
|
||||
- EditSubmission state machine transitions
|
||||
- EditSubmission with submission_type="PHOTO" (photo submissions)
|
||||
- PhotoSubmission state machine transitions
|
||||
- ModerationReport state machine transitions
|
||||
- ModerationQueue state machine transitions
|
||||
- BulkOperation state machine transitions
|
||||
@@ -39,6 +39,7 @@ from ..models import (
|
||||
ModerationAction,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -1131,17 +1132,14 @@ class ModerationActionTests(TestCase):
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EditSubmission PHOTO Type FSM Transition Tests
|
||||
# PhotoSubmission FSM Transition Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PhotoEditSubmissionTransitionTests(TestCase):
|
||||
"""Comprehensive tests for EditSubmission with submission_type='PHOTO' FSM transitions.
|
||||
class PhotoSubmissionTransitionTests(TestCase):
|
||||
"""Comprehensive tests for PhotoSubmission 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):
|
||||
@@ -1171,15 +1169,13 @@ class PhotoEditSubmissionTransitionTests(TestCase):
|
||||
)
|
||||
|
||||
def _create_submission(self, status="PENDING"):
|
||||
"""Helper to create an EditSubmission with submission_type='PHOTO' and proper CloudflareImage."""
|
||||
submission = EditSubmission.objects.create(
|
||||
"""Helper to create a PhotoSubmission with proper CloudflareImage."""
|
||||
submission = PhotoSubmission.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
|
||||
)
|
||||
|
||||
|
||||
@@ -83,17 +83,13 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
|
||||
def test_photo_submission_approval_workflow(self):
|
||||
"""
|
||||
Test complete photo submission approval workflow using EditSubmission.
|
||||
Test complete photo submission approval workflow.
|
||||
|
||||
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 EditSubmission
|
||||
from apps.moderation.models import PhotoSubmission
|
||||
from apps.parks.models import Company, Park
|
||||
|
||||
# Create target park
|
||||
@@ -109,21 +105,18 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
expires_at=timezone.now() + timedelta(days=365),
|
||||
)
|
||||
|
||||
# User submits a photo using unified EditSubmission model
|
||||
# User submits a photo
|
||||
content_type = ContentType.objects.get_for_model(park)
|
||||
submission = EditSubmission.objects.create(
|
||||
submission = PhotoSubmission.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)
|
||||
|
||||
@@ -45,16 +45,23 @@ class SubmissionListView(TemplateView):
|
||||
template_name = "moderation/partials/dashboard_content.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
from .models import EditSubmission
|
||||
from itertools import chain
|
||||
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
status = self.request.GET.get("status", "PENDING")
|
||||
|
||||
# Get filtered submissions (EditSubmission now handles all types including PHOTO)
|
||||
# Get filtered submissions
|
||||
edit_submissions = EditSubmission.objects.filter(status=status).select_related("user")
|
||||
photo_submissions = PhotoSubmission.objects.filter(status=status).select_related("user")
|
||||
|
||||
# Sort by created_at descending
|
||||
context["submissions"] = edit_submissions.order_by("-created_at")
|
||||
# Combine and sort
|
||||
context["submissions"] = sorted(
|
||||
chain(edit_submissions, photo_submissions),
|
||||
key=lambda x: x.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
@@ -71,10 +78,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 - handles all submission types (EDIT, CREATE, PHOTO)
|
||||
# EditSubmission - register under both names for compatibility
|
||||
router.register(r"submissions", EditSubmissionViewSet, basename="submissions")
|
||||
router.register(r"edit-submissions", EditSubmissionViewSet, basename="edit-submissions")
|
||||
# PhotoSubmissionViewSet - now queries EditSubmission with type=PHOTO, kept for API compatibility
|
||||
# PhotoSubmission - register under both names for compatibility
|
||||
router.register(r"photos", PhotoSubmissionViewSet, basename="photos")
|
||||
router.register(r"photo-submissions", PhotoSubmissionViewSet, basename="photo-submissions")
|
||||
|
||||
@@ -91,12 +98,12 @@ fsm_transition_patterns = [
|
||||
{"app_label": "moderation", "model_name": "editsubmission"},
|
||||
name="submission_transition",
|
||||
),
|
||||
# PhotoSubmission transitions (now use editsubmission model since photos are EditSubmission with type=PHOTO)
|
||||
# PhotoSubmission transitions
|
||||
# URL: /api/moderation/photos/<pk>/transition/<transition_name>/
|
||||
path(
|
||||
"photos/<int:pk>/transition/<str:transition_name>/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "editsubmission"},
|
||||
{"app_label": "moderation", "model_name": "photosubmission"},
|
||||
name="photo_transition",
|
||||
),
|
||||
# ModerationReport transitions
|
||||
@@ -143,23 +150,23 @@ fsm_transition_patterns = [
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
|
||||
name="escalate_submission",
|
||||
),
|
||||
# Photo transition aliases (use editsubmission model since photos are EditSubmission with type=PHOTO)
|
||||
# Backward compatibility aliases for PhotoSubmission actions
|
||||
path(
|
||||
"photos/<int:pk>/approve/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_approved"},
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_approved"},
|
||||
name="approve_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:pk>/reject/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_rejected"},
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_rejected"},
|
||||
name="reject_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:pk>/escalate/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_escalated"},
|
||||
name="escalate_photo",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -20,13 +20,11 @@ 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, serializers as drf_serializers, status, viewsets
|
||||
from rest_framework import permissions, 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,
|
||||
@@ -46,6 +44,7 @@ from .models import (
|
||||
ModerationAction,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
from .permissions import (
|
||||
CanViewModerationData,
|
||||
@@ -60,12 +59,12 @@ from .serializers import (
|
||||
CreateEditSubmissionSerializer,
|
||||
CreateModerationActionSerializer,
|
||||
CreateModerationReportSerializer,
|
||||
CreatePhotoSubmissionSerializer,
|
||||
EditSubmissionListSerializer,
|
||||
EditSubmissionSerializer,
|
||||
ModerationActionSerializer,
|
||||
ModerationQueueSerializer,
|
||||
ModerationReportSerializer,
|
||||
PhotoSubmissionSerializer,
|
||||
UpdateModerationReportSerializer,
|
||||
UserModerationProfileSerializer,
|
||||
)
|
||||
@@ -1567,30 +1566,6 @@ 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):
|
||||
"""
|
||||
@@ -1671,18 +1646,6 @@ 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):
|
||||
"""
|
||||
@@ -1720,17 +1683,6 @@ 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()
|
||||
@@ -1742,20 +1694,6 @@ 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()
|
||||
@@ -1768,20 +1706,6 @@ 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()
|
||||
@@ -2221,13 +2145,6 @@ 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")
|
||||
@@ -2241,26 +2158,6 @@ 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):
|
||||
"""
|
||||
@@ -2353,7 +2250,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
event_type="submission_unclaimed",
|
||||
message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}",
|
||||
context={
|
||||
"model": "EditSubmission",
|
||||
"model": "PhotoSubmission",
|
||||
"object_id": submission.id,
|
||||
"unclaimed_by": request.user.username,
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ dependencies = [
|
||||
# =============================================================================
|
||||
# Image Processing & Media
|
||||
# =============================================================================
|
||||
"Pillow>=12.0",
|
||||
"Pillow>=10.4.0,<11.2",
|
||||
"django-cleanup>=8.1.0",
|
||||
"piexif>=1.1.3",
|
||||
"django-cloudflareimages-toolkit>=1.0.6",
|
||||
@@ -193,7 +193,6 @@ 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
1082
backend/uv.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,6 @@ 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
107
uv.lock
generated
@@ -837,7 +837,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-typer"
|
||||
version = "3.5.1"
|
||||
version = "3.5.0"
|
||||
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/3a/69/ad177aa9acd536f4b6f45a46cf25c4549e2f88169115492a87666e2c84ac/django_typer-3.5.1.tar.gz", hash = "sha256:9e9a6e9093b97fcb61c003a192f4398249830346c64f60276485f4c8b1e331f8", size = 3083716, upload-time = "2026-01-14T15:42:17.499Z" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -919,26 +919,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "40.1.2"
|
||||
version = "40.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fido2"
|
||||
version = "2.1.0"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1284,11 +1284,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.0.3"
|
||||
version = "1.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1302,60 +1302,29 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.0"
|
||||
version = "11.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2198,7 +2167,7 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "nplusone", specifier = ">=1.0.0" },
|
||||
{ name = "piexif", specifier = ">=1.1.3" },
|
||||
{ name = "pillow", specifier = ">=12.0" },
|
||||
{ name = "pillow", specifier = ">=10.4.0,<11.2" },
|
||||
{ name = "psutil", specifier = ">=7.0.0" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
|
||||
{ name = "pycountry", specifier = ">=24.6.1" },
|
||||
@@ -2286,15 +2255,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typer-slim"
|
||||
version = "0.21.1"
|
||||
version = "0.20.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user