Skip to content

Commit e8a2769

Browse files
committed
Convert featured content images from local storage to Cloudinary
The FeaturedContent model previously used Django ImageField for local file storage, inconsistent with the rest of the project which uses Cloudinary. All image fields now use URLField + public_id CharField, matching the User model pattern. ## Claude Implementation Notes - backend/contributions/models.py: Replace 4 ImageFields with URLField + public_id pairs (hero, tablet, mobile, avatar) - backend/contributions/serializers.py: Remove SerializerMethodField helpers, read URL fields directly from model - backend/contributions/admin.py: Show URL fields instead of file uploads, public_ids as read-only collapsed section - backend/contributions/management/commands/seed_featured_content.py: Remove local file copy logic, simplified to just create records - backend/contributions/migrations/0040_convert_featured_images_to_cloudinary.py: Migration for the field conversion
1 parent 8aed080 commit e8a2769

5 files changed

Lines changed: 101 additions & 108 deletions

File tree

backend/contributions/admin.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,9 @@ class FeaturedContentAdmin(admin.ModelAdmin):
683683
search_fields = ('title', 'description', 'user__name', 'user__address')
684684
list_editable = ('order', 'is_active')
685685
raw_id_fields = ('user', 'contribution')
686-
readonly_fields = ('created_at', 'updated_at')
686+
readonly_fields = ('created_at', 'updated_at', 'hero_image_public_id',
687+
'hero_image_tablet_public_id', 'hero_image_mobile_public_id',
688+
'user_profile_image_public_id')
687689
ordering = ('order', '-created_at')
688690

689691
fieldsets = (
@@ -694,8 +696,15 @@ class FeaturedContentAdmin(admin.ModelAdmin):
694696
'fields': ('user', 'contribution')
695697
}),
696698
('Links & Media', {
697-
'fields': ('hero_image', 'hero_image_tablet', 'hero_image_mobile', 'user_profile_image', 'url'),
698-
'description': 'Upload images directly. Django serves them from the media directory. Tablet/mobile hero images are optional — falls back to the main hero image.'
699+
'fields': ('hero_image_url', 'hero_image_url_tablet', 'hero_image_url_mobile',
700+
'user_profile_image_url', 'url'),
701+
'description': 'Paste Cloudinary URLs for images. Tablet/mobile hero images are optional — falls back to the main hero image.'
702+
}),
703+
('Cloudinary Metadata', {
704+
'fields': ('hero_image_public_id', 'hero_image_tablet_public_id',
705+
'hero_image_mobile_public_id', 'user_profile_image_public_id'),
706+
'classes': ('collapse',),
707+
'description': 'Auto-managed Cloudinary public IDs (read-only)'
699708
}),
700709
('Metadata', {
701710
'fields': ('created_at', 'updated_at'),
Lines changed: 9 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
import os
2-
import shutil
3-
4-
from django.conf import settings
51
from django.core.management.base import BaseCommand
62
from django.contrib.auth import get_user_model
73
from contributions.models import FeaturedContent
@@ -12,30 +8,7 @@
128
class Command(BaseCommand):
139
help = 'Seeds FeaturedContent entries for the portal home page (hero banner and featured builds).'
1410

15-
def _copy_to_media(self, source_path, relative_dest):
16-
"""
17-
Copy a file to MEDIA_ROOT if it doesn't already exist at the destination.
18-
Returns the relative path within MEDIA_ROOT, or None if the source doesn't exist.
19-
"""
20-
if not os.path.exists(source_path):
21-
self.stdout.write(self.style.WARNING(f" Image not found: {source_path}"))
22-
return None
23-
24-
dest_path = os.path.join(settings.MEDIA_ROOT, relative_dest)
25-
dest_dir = os.path.dirname(dest_path)
26-
os.makedirs(dest_dir, exist_ok=True)
27-
28-
if not os.path.exists(dest_path):
29-
shutil.copy2(source_path, dest_path)
30-
self.stdout.write(self.style.SUCCESS(f" Copied: {relative_dest}"))
31-
else:
32-
self.stdout.write(f" Already exists: {relative_dest}")
33-
34-
return relative_dest
35-
3611
def handle(self, *args, **options):
37-
media_root = settings.MEDIA_ROOT
38-
3912
# ----------------------------------------------------------------
4013
# 1. Ensure users exist (get_or_create with dummy email/address)
4114
# ----------------------------------------------------------------
@@ -76,29 +49,19 @@ def handle(self, *args, **options):
7649
# ----------------------------------------------------------------
7750
# 2. Hero banner
7851
# ----------------------------------------------------------------
79-
hero_defaults = {
80-
'description': 'Deploy intelligent contracts, run validators, and earn GenLayer Points on the latest testnet.',
81-
'author': 'cognocracy',
82-
'user': users['cognocracy'],
83-
'url': '',
84-
'is_active': True,
85-
'order': 0,
86-
}
87-
8852
obj, created = FeaturedContent.objects.update_or_create(
8953
content_type='hero',
9054
title='Argue.fun Launch',
91-
defaults=hero_defaults,
55+
defaults={
56+
'description': 'Deploy intelligent contracts, run validators, and earn GenLayer Points on the latest testnet.',
57+
'author': 'cognocracy',
58+
'user': users['cognocracy'],
59+
'url': '',
60+
'is_active': True,
61+
'order': 0,
62+
},
9263
)
9364

94-
# Copy hero image to media directory
95-
hero_source = os.path.join(media_root, 'featured', 'hero-bg.png')
96-
hero_rel = 'featured/hero-bg.png'
97-
result = self._copy_to_media(hero_source, hero_rel)
98-
if result:
99-
obj.hero_image = result
100-
obj.save()
101-
10265
self.stdout.write(
10366
self.style.SUCCESS(f" {'Created' if created else 'Updated'} hero: {obj.title}")
10467
)
@@ -110,30 +73,18 @@ def handle(self, *args, **options):
11073
{
11174
'title': 'Argue.fun',
11275
'user': users['cognocracy'],
113-
'hero_image_source': os.path.join(media_root, 'featured', 'argue-fun-bg.jpg'),
114-
'hero_image_rel': 'featured/argue-fun-bg.jpg',
115-
'avatar_source': os.path.join(media_root, 'featured', 'avatars', 'cognocracy-avatar.png'),
116-
'avatar_rel': 'featured/avatars/cognocracy-avatar.png',
11776
'url': '',
11877
'order': 0,
11978
},
12079
{
12180
'title': 'Internet Court',
12281
'user': users['raskovsky'],
123-
'hero_image_source': os.path.join(media_root, 'featured', 'internet-court-bg.jpg'),
124-
'hero_image_rel': 'featured/internet-court-bg.jpg',
125-
'avatar_source': os.path.join(media_root, 'featured', 'avatars', 'raskovsky-avatar.png'),
126-
'avatar_rel': 'featured/avatars/raskovsky-avatar.png',
12782
'url': '',
12883
'order': 1,
12984
},
13085
{
13186
'title': 'Rally',
13287
'user': users['GenLayer'],
133-
'hero_image_source': os.path.join(media_root, 'featured', 'rally-bg.jpg'),
134-
'hero_image_rel': 'featured/rally-bg.jpg',
135-
'avatar_source': os.path.join(media_root, 'featured', 'avatars', 'genlayer-avatar.png'),
136-
'avatar_rel': 'featured/avatars/genlayer-avatar.png',
13788
'url': '',
13889
'order': 2,
13990
},
@@ -153,25 +104,9 @@ def handle(self, *args, **options):
153104
},
154105
)
155106

156-
updated = False
157-
158-
# Copy hero image to media directory
159-
result = self._copy_to_media(build['hero_image_source'], build['hero_image_rel'])
160-
if result:
161-
obj.hero_image = result
162-
updated = True
163-
164-
# Copy avatar to media directory
165-
result = self._copy_to_media(build['avatar_source'], build['avatar_rel'])
166-
if result:
167-
obj.user_profile_image = result
168-
updated = True
169-
170-
if updated:
171-
obj.save()
172-
173107
self.stdout.write(
174108
self.style.SUCCESS(f" {'Created' if created else 'Updated'} build: {obj.title}")
175109
)
176110

177111
self.stdout.write(self.style.SUCCESS('\nFeatured content seeded successfully.'))
112+
self.stdout.write('Note: Upload images via Django admin or set hero_image_url / user_profile_image_url directly.')
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Generated by Django 6.0.3 on 2026-03-16 18:40
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('contributions', '0039_add_responsive_hero_images'),
10+
]
11+
12+
operations = [
13+
migrations.RemoveField(
14+
model_name='featuredcontent',
15+
name='hero_image',
16+
),
17+
migrations.RemoveField(
18+
model_name='featuredcontent',
19+
name='hero_image_mobile',
20+
),
21+
migrations.RemoveField(
22+
model_name='featuredcontent',
23+
name='hero_image_tablet',
24+
),
25+
migrations.RemoveField(
26+
model_name='featuredcontent',
27+
name='user_profile_image',
28+
),
29+
migrations.AddField(
30+
model_name='featuredcontent',
31+
name='hero_image_mobile_public_id',
32+
field=models.CharField(blank=True, help_text='Cloudinary public ID for mobile hero image', max_length=255),
33+
),
34+
migrations.AddField(
35+
model_name='featuredcontent',
36+
name='hero_image_public_id',
37+
field=models.CharField(blank=True, help_text='Cloudinary public ID for hero image', max_length=255),
38+
),
39+
migrations.AddField(
40+
model_name='featuredcontent',
41+
name='hero_image_tablet_public_id',
42+
field=models.CharField(blank=True, help_text='Cloudinary public ID for tablet hero image', max_length=255),
43+
),
44+
migrations.AddField(
45+
model_name='featuredcontent',
46+
name='hero_image_url',
47+
field=models.URLField(blank=True, help_text='Cloudinary URL for hero image', max_length=500),
48+
),
49+
migrations.AddField(
50+
model_name='featuredcontent',
51+
name='hero_image_url_mobile',
52+
field=models.URLField(blank=True, help_text='Cloudinary URL for mobile hero image (<768px). Falls back to hero_image_url if empty.', max_length=500),
53+
),
54+
migrations.AddField(
55+
model_name='featuredcontent',
56+
name='hero_image_url_tablet',
57+
field=models.URLField(blank=True, help_text='Cloudinary URL for tablet hero image (768-1023px). Falls back to hero_image_url if empty.', max_length=500),
58+
),
59+
migrations.AddField(
60+
model_name='featuredcontent',
61+
name='user_profile_image_public_id',
62+
field=models.CharField(blank=True, help_text='Cloudinary public ID for user profile image', max_length=255),
63+
),
64+
migrations.AddField(
65+
model_name='featuredcontent',
66+
name='user_profile_image_url',
67+
field=models.URLField(blank=True, help_text='Cloudinary URL for user profile image', max_length=500),
68+
),
69+
]

backend/contributions/models.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -663,10 +663,14 @@ class FeaturedContent(BaseModel):
663663
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
664664
related_name='featured_items'
665665
)
666-
hero_image = models.ImageField(upload_to='featured/', blank=True, null=True)
667-
hero_image_tablet = models.ImageField(upload_to='featured/', blank=True, null=True, help_text='Tablet variant (768-1023px). Falls back to hero_image if empty.')
668-
hero_image_mobile = models.ImageField(upload_to='featured/', blank=True, null=True, help_text='Mobile variant (<768px). Falls back to hero_image if empty.')
669-
user_profile_image = models.ImageField(upload_to='featured/avatars/', blank=True, null=True)
666+
hero_image_url = models.URLField(max_length=500, blank=True, help_text='Cloudinary URL for hero image')
667+
hero_image_public_id = models.CharField(max_length=255, blank=True, help_text='Cloudinary public ID for hero image')
668+
hero_image_url_tablet = models.URLField(max_length=500, blank=True, help_text='Cloudinary URL for tablet hero image (768-1023px). Falls back to hero_image_url if empty.')
669+
hero_image_tablet_public_id = models.CharField(max_length=255, blank=True, help_text='Cloudinary public ID for tablet hero image')
670+
hero_image_url_mobile = models.URLField(max_length=500, blank=True, help_text='Cloudinary URL for mobile hero image (<768px). Falls back to hero_image_url if empty.')
671+
hero_image_mobile_public_id = models.CharField(max_length=255, blank=True, help_text='Cloudinary public ID for mobile hero image')
672+
user_profile_image_url = models.URLField(max_length=500, blank=True, help_text='Cloudinary URL for user profile image')
673+
user_profile_image_public_id = models.CharField(max_length=255, blank=True, help_text='Cloudinary public ID for user profile image')
670674
url = models.URLField(max_length=500, blank=True)
671675
is_active = models.BooleanField(default=True)
672676
order = models.PositiveIntegerField(default=0)

backend/contributions/serializers.py

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -722,9 +722,6 @@ class FeaturedContentSerializer(serializers.ModelSerializer):
722722
user_name = serializers.CharField(source='user.name', read_only=True)
723723
user_address = serializers.CharField(source='user.address', read_only=True)
724724
user_profile_image_url = serializers.SerializerMethodField()
725-
hero_image_url = serializers.SerializerMethodField()
726-
hero_image_url_tablet = serializers.SerializerMethodField()
727-
hero_image_url_mobile = serializers.SerializerMethodField()
728725
link = serializers.SerializerMethodField()
729726

730727
class Meta:
@@ -735,31 +732,10 @@ class Meta:
735732
'user', 'user_name', 'user_address', 'user_profile_image_url',
736733
'contribution', 'is_active', 'order', 'created_at']
737734

738-
def _build_image_url(self, image_field):
739-
"""Return absolute URL for an image field if set."""
740-
if image_field:
741-
request = self.context.get('request')
742-
if request:
743-
return request.build_absolute_uri(image_field.url)
744-
return image_field.url
745-
return ''
746-
747-
def get_hero_image_url(self, obj):
748-
return self._build_image_url(obj.hero_image)
749-
750-
def get_hero_image_url_tablet(self, obj):
751-
return self._build_image_url(obj.hero_image_tablet)
752-
753-
def get_hero_image_url_mobile(self, obj):
754-
return self._build_image_url(obj.hero_image_mobile)
755-
756735
def get_user_profile_image_url(self, obj):
757-
"""Return the FeaturedContent's user_profile_image if set, otherwise fall back to user's profile_image_url."""
758-
if obj.user_profile_image:
759-
request = self.context.get('request')
760-
if request:
761-
return request.build_absolute_uri(obj.user_profile_image.url)
762-
return obj.user_profile_image.url
736+
"""Return the FeaturedContent's user_profile_image_url if set, otherwise fall back to user's profile_image_url."""
737+
if obj.user_profile_image_url:
738+
return obj.user_profile_image_url
763739
if obj.user and obj.user.profile_image_url:
764740
return obj.user.profile_image_url
765741
return ''

0 commit comments

Comments
 (0)