Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
30 changes: 22 additions & 8 deletions backend/contributions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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')
Expand All @@ -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'),
Expand Down
29 changes: 29 additions & 0 deletions backend/templates/admin/widgets/cloudinary_upload.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div class="cloudinary-upload-widget" style="display: flex; flex-direction: column; gap: 8px;">
{% if current_url %}
<div class="cloudinary-preview" style="margin-bottom: 4px;">
<img src="{{ current_url }}" alt="Current image" style="max-width: 200px; max-height: 120px; border-radius: 4px; border: 1px solid #ddd; object-fit: cover;">
</div>
{% endif %}

<div style="display: flex; flex-direction: column; gap: 6px;">
<div>
<label style="font-weight: bold; font-size: 0.85em; color: #666;">Upload new image:</label><br>
<input type="file" name="{{ file_field_name }}" accept="image/*" style="margin-top: 2px;">
</div>

<div>
<label style="font-weight: bold; font-size: 0.85em; color: #666;">Or paste URL directly:</label><br>
<input type="{{ url_input_type }}" name="{{ url_field_name }}" value="{{ current_url|default:'' }}"
style="width: 100%; max-width: 500px; padding: 4px 6px; margin-top: 2px;"
placeholder="https://res.cloudinary.com/...">
</div>

{% if current_url %}
<div>
<label style="font-size: 0.85em;">
<input type="checkbox" name="{{ clear_field_name }}"> Clear current image
</label>
</div>
{% endif %}
</div>
</div>
40 changes: 40 additions & 0 deletions backend/users/cloudinary_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
101 changes: 101 additions & 0 deletions backend/utils/admin_mixins.py
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 28 additions & 0 deletions backend/utils/admin_widgets.py
Original file line number Diff line number Diff line change
@@ -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, '')
Loading