diff --git a/CHANGELOG.md b/CHANGELOG.md index ec19074d..84ee7bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,4 +4,5 @@ All notable user-facing changes to this project will be documented in this file. ## Unreleased +- Direct Cloudinary image upload from Django admin for featured content (ce4c157) - Responsive hero banner images for tablet and mobile (e5c01b5) diff --git a/backend/contributions/admin.py b/backend/contributions/admin.py index 6a333043..7046a39a 100644 --- a/backend/contributions/admin.py +++ b/backend/contributions/admin.py @@ -14,6 +14,7 @@ from .models import Category, ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote, FeaturedContent, Alert from .validator_forms import CreateValidatorForm from leaderboard.models import GlobalLeaderboardMultiplier +from utils.admin_mixins import CloudinaryUploadMixin User = get_user_model() @@ -677,7 +678,26 @@ def get_status(self, obj): @admin.register(FeaturedContent) -class FeaturedContentAdmin(admin.ModelAdmin): +class FeaturedContentAdmin(CloudinaryUploadMixin, admin.ModelAdmin): + cloudinary_upload_fields = { + 'hero_image_url': { + 'public_id_field': 'hero_image_public_id', + 'folder': 'tally/featured', + }, + 'hero_image_url_tablet': { + 'public_id_field': 'hero_image_tablet_public_id', + 'folder': 'tally/featured', + }, + 'hero_image_url_mobile': { + 'public_id_field': 'hero_image_mobile_public_id', + 'folder': 'tally/featured', + }, + 'user_profile_image_url': { + 'public_id_field': 'user_profile_image_public_id', + 'folder': 'tally/featured/avatars', + }, + } + list_display = ('title', 'content_type', 'user', 'is_active', 'order', 'created_at') list_filter = ('content_type', 'is_active', 'created_at') search_fields = ('title', 'description', 'user__name', 'user__address') @@ -698,13 +718,7 @@ class FeaturedContentAdmin(admin.ModelAdmin): ('Links & Media', { 'fields': ('hero_image_url', 'hero_image_url_tablet', 'hero_image_url_mobile', 'user_profile_image_url', 'url'), - 'description': 'Paste Cloudinary URLs for images. Tablet/mobile hero images are optional — falls back to the main hero image.' - }), - ('Cloudinary Metadata', { - 'fields': ('hero_image_public_id', 'hero_image_tablet_public_id', - 'hero_image_mobile_public_id', 'user_profile_image_public_id'), - 'classes': ('collapse',), - 'description': 'Auto-managed Cloudinary public IDs (read-only)' + 'description': 'Upload images directly or paste Cloudinary URLs. Tablet/mobile hero images are optional — falls back to the main hero image.' }), ('Metadata', { 'fields': ('created_at', 'updated_at'), diff --git a/backend/templates/admin/widgets/cloudinary_upload.html b/backend/templates/admin/widgets/cloudinary_upload.html new file mode 100644 index 00000000..58d6ff7b --- /dev/null +++ b/backend/templates/admin/widgets/cloudinary_upload.html @@ -0,0 +1,29 @@ +
+ {% if current_url %} +
+ Current image +
+ {% endif %} + +
+
+
+ +
+ +
+
+ +
+ + {% if current_url %} +
+ +
+ {% endif %} +
+
diff --git a/backend/users/cloudinary_service.py b/backend/users/cloudinary_service.py index 1da9e361..a426bb67 100644 --- a/backend/users/cloudinary_service.py +++ b/backend/users/cloudinary_service.py @@ -229,6 +229,46 @@ def upload_featured_avatar(cls, image_file, featured_id) -> Dict: ) raise + @classmethod + def upload_image(cls, image_file, folder: str = 'tally/uploads') -> Dict: + """ + Generic image upload to Cloudinary. Used by the admin upload mixin. + + Args: + image_file: The image file to upload + folder: Cloudinary folder path + + Returns: + Dict with 'url' and 'public_id' + """ + try: + cls.configure() + + upload_preset = getattr(settings, 'CLOUDINARY_UPLOAD_PRESET', 'tally_unsigned') + timestamp = int(time.time()) + + with trace_external('cloudinary', 'upload_image'): + result = cloudinary.uploader.unsigned_upload( + image_file, + upload_preset, + public_id=f"admin_{timestamp}", + folder=folder, + ) + + return { + 'url': result.get('secure_url', ''), + 'public_id': result.get('public_id', ''), + } + + except Exception as e: + logger.error(f"Failed to upload image: {str(e)}") + if "Upload preset not found" in str(e): + raise Exception( + "Cloudinary upload preset not configured. Please create an unsigned upload preset " + "named 'tally_unsigned' in your Cloudinary dashboard (Settings > Upload > Upload presets)." + ) + raise + @classmethod def delete_image(cls, public_id: str) -> bool: """ diff --git a/backend/utils/admin_mixins.py b/backend/utils/admin_mixins.py new file mode 100644 index 00000000..7eee7c95 --- /dev/null +++ b/backend/utils/admin_mixins.py @@ -0,0 +1,101 @@ +from django.contrib import admin, messages + +from users.cloudinary_service import CloudinaryService +from utils.admin_widgets import CloudinaryUploadWidget + +from tally.middleware.logging_utils import get_app_logger + +logger = get_app_logger('admin') + + +class CloudinaryUploadMixin: + """ + Admin mixin that enables direct Cloudinary uploads from the Django admin. + + Configure via `cloudinary_upload_fields` on the ModelAdmin: + + cloudinary_upload_fields = { + 'image_url': { + 'public_id_field': 'image_public_id', + 'folder': 'tally/images', + }, + } + + Each key is a URL field on the model. The mixin will: + - Inject a file upload input next to each URL field + - On save, upload the file to Cloudinary and populate the URL + public_id + - Optionally delete the old image when replaced + - Support clearing the image via a checkbox + """ + + cloudinary_upload_fields = {} + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + for url_field in self.cloudinary_upload_fields: + if url_field in form.base_fields: + form.base_fields[url_field].widget = CloudinaryUploadWidget( + url_field_name=url_field, + ) + form.base_fields[url_field].required = False + return form + + def _get_writable_readonly_fields(self, request, obj=None): + """Return public_id fields that we manage but shouldn't be truly readonly during save.""" + fields = set() + for config in self.cloudinary_upload_fields.values(): + pid_field = config.get('public_id_field') + if pid_field: + fields.add(pid_field) + return fields + + def get_readonly_fields(self, request, obj=None): + readonly = list(super().get_readonly_fields(request, obj)) + managed_fields = self._get_writable_readonly_fields(request, obj) + return [f for f in readonly if f not in managed_fields] + + def get_exclude(self, request, obj=None): + exclude = list(super().get_exclude(request, obj) or []) + for config in self.cloudinary_upload_fields.values(): + pid_field = config.get('public_id_field') + if pid_field and pid_field not in exclude: + exclude.append(pid_field) + return exclude + + def save_model(self, request, obj, form, change): + for url_field, config in self.cloudinary_upload_fields.items(): + pid_field = config.get('public_id_field', '') + folder = config.get('folder', 'tally/uploads') + + file_input_name = f'{url_field}_upload' + clear_input_name = f'{url_field}_clear' + + uploaded_file = request.FILES.get(file_input_name) + should_clear = request.POST.get(clear_input_name) + + if should_clear and not uploaded_file: + old_pid = getattr(obj, pid_field, '') if pid_field else '' + if old_pid: + CloudinaryService.delete_image(old_pid) + setattr(obj, url_field, '') + if pid_field: + setattr(obj, pid_field, '') + continue + + if uploaded_file: + old_pid = getattr(obj, pid_field, '') if pid_field else '' + try: + result = CloudinaryService.upload_image( + uploaded_file, folder=folder, + ) + setattr(obj, url_field, result['url']) + if pid_field: + setattr(obj, pid_field, result['public_id']) + + if old_pid: + CloudinaryService.delete_image(old_pid) + except Exception as e: + logger.error(f"Cloudinary upload failed for {url_field}: {e}") + messages.error(request, f"Image upload failed for {url_field}: {e}") + + super().save_model(request, obj, form, change) diff --git a/backend/utils/admin_widgets.py b/backend/utils/admin_widgets.py new file mode 100644 index 00000000..72de92b0 --- /dev/null +++ b/backend/utils/admin_widgets.py @@ -0,0 +1,28 @@ +from django import forms +from django.template.loader import render_to_string + + +class CloudinaryUploadWidget(forms.Widget): + """ + A composite widget that renders a file upload input, a URL text input, + an image preview, and a clear checkbox for Cloudinary image fields. + """ + template_name = 'admin/widgets/cloudinary_upload.html' + needs_multipart_form = True + + def __init__(self, url_field_name='', attrs=None): + self.url_field_name = url_field_name + super().__init__(attrs=attrs) + + def render(self, name, value, attrs=None, renderer=None): + context = { + 'file_field_name': f'{name}_upload', + 'url_field_name': name, + 'clear_field_name': f'{name}_clear', + 'current_url': value or '', + 'url_input_type': 'url', + } + return render_to_string(self.template_name, context) + + def value_from_datadict(self, data, files, name): + return data.get(name, '')