image library migration step 1
All checks were successful
Build and Deploy / build (push) Successful in 1m7s
Django Tests / test (push) Successful in 1m10s
Build and Deploy / deploy (push) Successful in 6s

This commit is contained in:
Tobias Brunner 2025-07-04 17:26:09 +02:00
parent 07bea333bc
commit 1a2bbb1c35
No known key found for this signature in database
23 changed files with 413 additions and 57 deletions

View file

@ -61,12 +61,47 @@ class ArticleAdmin(admin.ModelAdmin):
readonly_fields = ("created_at", "updated_at")
ordering = ("-article_date",)
fieldsets = (
(None, {"fields": ("title", "slug", "excerpt", "content", "meta_keywords")}),
(
"Images",
{
"fields": (
"image_library",
"image",
), # New image library field and legacy field
"description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
},
),
(
"Publishing",
{"fields": ("author", "article_date", "is_published", "is_featured")},
),
(
"Relations",
{
"fields": (
"related_service",
"related_consulting_partner",
"related_cloud_provider",
),
"classes": ("collapse",),
},
),
(
"Metadata",
{
"fields": ("created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
def image_preview(self, obj):
"""Display image preview in admin list view"""
if obj.image:
return format_html(
'<img src="{}" style="max-height: 50px;"/>', obj.image.url
)
image = obj.get_image
if image:
return format_html('<img src="{}" style="max-height: 50px;"/>', image.url)
return "No image"
image_preview.short_description = "Image"

View file

@ -47,12 +47,30 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
inlines = [OfferingInline]
ordering = ("order",)
fieldsets = (
(None, {"fields": ("name", "slug", "description", "order")}),
(
"Images",
{
"fields": (
"image_library",
"logo",
), # New image library field and legacy field
"description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
},
),
(
"Contact Information",
{"fields": ("website", "linkedin", "phone", "email", "address")},
),
("Settings", {"fields": ("is_featured", "disable_listing")}),
)
def logo_preview(self, obj):
"""Display logo preview in admin list view"""
if obj.logo:
return format_html(
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
)
logo = obj.get_logo
if logo:
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
return "No logo"
logo_preview.short_description = "Logo"
@ -75,12 +93,34 @@ class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin):
filter_horizontal = ("services", "cloud_providers")
ordering = ("order",)
fieldsets = (
(None, {"fields": ("name", "slug", "description", "order")}),
(
"Images",
{
"fields": (
"image_library",
"logo",
), # New image library field and legacy field
"description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
},
),
(
"Contact Information",
{"fields": ("website", "linkedin", "phone", "email", "address")},
),
(
"Relations",
{"fields": ("services", "cloud_providers"), "classes": ("collapse",)},
),
("Settings", {"fields": ("is_featured", "disable_listing")}),
)
def logo_preview(self, obj):
"""Display logo preview in admin list view"""
if obj.logo:
return format_html(
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
)
logo = obj.get_logo
if logo:
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
return "No logo"
logo_preview.short_description = "Logo"

View file

@ -93,12 +93,37 @@ class ServiceAdmin(admin.ModelAdmin):
filter_horizontal = ("categories",)
inlines = [ExternalLinkInline, OfferingInline]
fieldsets = (
(None, {"fields": ("name", "slug", "description", "tagline")}),
(
"Images",
{
"fields": (
"image_library",
"logo",
), # New image library field and legacy field
"description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
},
),
(
"Configuration",
{
"fields": (
"categories",
"features",
"is_featured",
"is_coming_soon",
"disable_listing",
)
},
),
)
def logo_preview(self, obj):
"""Display logo preview in admin list view"""
if obj.logo:
return format_html(
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
)
logo = obj.get_logo
if logo:
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
return "No logo"
logo_preview.short_description = "Logo"

View file

@ -0,0 +1,57 @@
# Generated by Django 5.2 on 2025-07-04 15:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0040_add_image_library"),
]
operations = [
migrations.AddField(
model_name="cloudprovider",
name="image",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="services.imagelibrary",
),
),
migrations.AddField(
model_name="consultingpartner",
name="image",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="services.imagelibrary",
),
),
migrations.AddField(
model_name="service",
name="image",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="services.imagelibrary",
),
),
migrations.AlterField(
model_name="article",
name="image",
field=models.ImageField(
blank=True,
help_text="Title picture for the article",
null=True,
upload_to="article_images/",
),
),
]

View file

@ -0,0 +1,74 @@
# Generated by Django 5.2 on 2025-07-04 15:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0041_add_image_library_references"),
]
operations = [
migrations.RemoveField(
model_name="cloudprovider",
name="image",
),
migrations.RemoveField(
model_name="consultingpartner",
name="image",
),
migrations.RemoveField(
model_name="service",
name="image",
),
migrations.AddField(
model_name="article",
name="image_library",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_references",
to="services.imagelibrary",
),
),
migrations.AddField(
model_name="cloudprovider",
name="image_library",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_references",
to="services.imagelibrary",
),
),
migrations.AddField(
model_name="consultingpartner",
name="image_library",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_references",
to="services.imagelibrary",
),
),
migrations.AddField(
model_name="service",
name="image_library",
field=models.ForeignKey(
blank=True,
help_text="Select an image from the library",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_references",
to="services.imagelibrary",
),
),
]

View file

@ -7,9 +7,10 @@ from django.utils import timezone
from .base import validate_image_size
from .services import Service
from .providers import CloudProvider, ConsultingPartner
from .images import ImageReference
class Article(models.Model):
class Article(ImageReference):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=250, unique=True)
excerpt = models.TextField(
@ -19,9 +20,12 @@ class Article(models.Model):
meta_keywords = models.CharField(
max_length=255, blank=True, help_text="SEO keywords separated by commas"
)
# Original image field - keep temporarily for migration
image = models.ImageField(
upload_to="article_images/",
help_text="Title picture for the article",
null=True,
blank=True,
)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")
article_date = models.DateField(
@ -86,6 +90,13 @@ class Article(models.Model):
def get_absolute_url(self):
return reverse("services:article_detail", kwargs={"slug": self.slug})
@property
def get_image(self):
"""Returns the image from library or falls back to legacy image"""
if self.image_library and self.image_library.image:
return self.image_library.image
return self.image
@property
def related_to(self):
"""Returns a string describing what this article is related to"""

View file

@ -182,12 +182,13 @@ class ImageReference(models.Model):
This helps track usage and provides a consistent interface.
"""
image = models.ForeignKey(
image_library = models.ForeignKey(
ImageLibrary,
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Select an image from the library",
related_name="%(class)s_references",
)
class Meta:
@ -202,23 +203,23 @@ class ImageReference(models.Model):
if self.pk:
try:
old_instance = self.__class__.objects.get(pk=self.pk)
old_image = old_instance.image
old_image = old_instance.image_library
except self.__class__.DoesNotExist:
pass
super().save(*args, **kwargs)
# Update usage counts
if old_image and old_image != self.image:
if old_image and old_image != self.image_library:
old_image.decrement_usage()
if self.image and self.image != old_image:
self.image.increment_usage()
if self.image_library and self.image_library != old_image:
self.image_library.increment_usage()
def delete(self, *args, **kwargs):
"""
Override delete to update usage count.
"""
if self.image:
self.image.decrement_usage()
if self.image_library:
self.image_library.decrement_usage()
super().delete(*args, **kwargs)

View file

@ -4,9 +4,10 @@ from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
from .base import validate_image_size
from .images import ImageReference
class CloudProvider(models.Model):
class CloudProvider(ImageReference):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = ProseEditorField()
@ -15,6 +16,7 @@ class CloudProvider(models.Model):
phone = models.CharField(max_length=25, blank=True, null=True)
email = models.EmailField(max_length=254, blank=True, null=True)
address = models.TextField(max_length=250, blank=True, null=True)
# Original logo field - keep temporarily for migration
logo = models.ImageField(
upload_to="cloud_provider_logos/",
validators=[validate_image_size],
@ -39,11 +41,19 @@ class CloudProvider(models.Model):
def get_absolute_url(self):
return reverse("services:provider_detail", kwargs={"slug": self.slug})
@property
def get_logo(self):
"""Returns the logo from library or falls back to legacy logo"""
if self.image_library and self.image_library.image:
return self.image_library.image
return self.logo
class ConsultingPartner(models.Model):
class ConsultingPartner(ImageReference):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
description = ProseEditorField()
# Original logo field - keep temporarily for migration
logo = models.ImageField(
upload_to="partner_logos/",
validators=[validate_image_size],
@ -83,3 +93,10 @@ class ConsultingPartner(models.Model):
def get_absolute_url(self):
return reverse("services:partner_detail", kwargs={"slug": self.slug})
@property
def get_logo(self):
"""Returns the logo from library or falls back to legacy logo"""
if self.image_library and self.image_library.image:
return self.image_library.image
return self.logo

View file

@ -13,13 +13,15 @@ from .base import (
Currency,
)
from .providers import CloudProvider
from .images import ImageReference
class Service(models.Model):
class Service(ImageReference):
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=250, unique=True)
description = ProseEditorField()
tagline = models.TextField(max_length=500, blank=True, null=True)
# Original logo field - keep temporarily for migration
logo = models.ImageField(
upload_to="service_logos/",
validators=[validate_image_size],
@ -58,6 +60,13 @@ class Service(models.Model):
def get_absolute_url(self):
return reverse("services:service_detail", kwargs={"slug": self.slug})
@property
def get_logo(self):
"""Returns the logo from library or falls back to legacy logo"""
if self.image_library and self.image_library.image:
return self.image_library.image
return self.logo
class ServiceOffering(models.Model):
service = models.ForeignKey(

View file

@ -48,9 +48,15 @@
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ service.get_absolute_url }}" class="clickable-link">
<img src="{{ service.logo.url }}"
{% if service.get_logo %}
<img src="{{ service.get_logo.url }}"
alt="{{ service.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
{% else %}
<div class="text-muted" style="height: 100px; width: 250px; display: flex; align-items: center; justify-content: center; border: 1px solid #dee2e6; border-radius: 0.375rem;">
{{ service.name }}
</div>
{% endif %}
</a>
</div>
</div>
@ -105,7 +111,7 @@
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ provider.get_absolute_url }}" class="clickable-link">
<img src="{{ provider.logo.url }}"
<img src="{{ provider.get_logo.url }}"
alt="{{ provider.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>
@ -159,7 +165,7 @@
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ partner.get_absolute_url }}" class="clickable-link">
<img src="{{ partner.logo.url }}"
<img src="{{ partner.get_logo.url }}"
alt="{{ partner.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>

View file

@ -43,7 +43,7 @@
<h5 class="card-title">Service</h5>
{% if article.related_service.logo %}
<div class="mb-3 d-flex" style="height: 60px;">
<img src="{{ article.related_service.logo.url }}" alt="{{ article.related_service.name }} logo"
<img src="{{ article.related_service.get_logo.url }}" alt="{{ article.related_service.name }} logo"
class="img-fluid" style="max-height: 50px; object-fit: contain;">
</div>
{% endif %}
@ -60,7 +60,7 @@
<h5 class="card-title">Partner</h5>
{% if article.related_consulting_partner.logo %}
<div class="mb-3 d-flex" style="height: 60px;">
<img src="{{ article.related_consulting_partner.logo.url }}" alt="{{ article.related_consulting_partner.name }} logo"
<img src="{{ article.related_consulting_partner.get_logo.url }}" alt="{{ article.related_consulting_partner.name }} logo"
class="img-fluid" style="max-height: 50px; object-fit: contain;">
</div>
{% endif %}
@ -77,7 +77,7 @@
<h5 class="card-title">Provider</h5>
{% if article.related_cloud_provider.logo %}
<div class="mb-3 d-flex" style="height: 60px;">
<img src="{{ article.related_cloud_provider.logo.url }}" alt="{{ article.related_cloud_provider.name }} logo"
<img src="{{ article.related_cloud_provider.get_logo.url }}" alt="{{ article.related_cloud_provider.name }} logo"
class="img-fluid" style="max-height: 50px; object-fit: contain;">
</div>
{% endif %}
@ -100,7 +100,7 @@
<div class="col-12 col-md-4 mb-4">
<div class="card h-100 clickable-card" onclick="cardClicked(event, '{{ related_article.get_absolute_url }}')">
{% if related_article.image %}
<img src="{{ related_article.image.url }}" class="card-img-top mb-2" alt="{{ related_article.title }}" style="height: 200px; object-fit: cover;">
<img src="{{ related_article.get_image.url }}" class="card-img-top mb-2" alt="{{ related_article.title }}" style="height: 200px; object-fit: cover;">
{% endif %}
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ related_article.title }}</h5>

View file

@ -149,7 +149,7 @@
<div class="d-flex justify-content-between mb-3">
{% if article.image %}
<div class="card__image flex-shrink-0">
<img src="{{ article.image.url }}" alt="{{ article.title }}" class="img-fluid">
<img src="{{ article.get_image.url }}" alt="{{ article.title }}" class="img-fluid">
</div>
{% endif %}
{% if article.is_featured %}

View file

@ -78,7 +78,7 @@
<div class="d-flex align-items-center mb-24">
<div class="card__image mb-0">
{% if selected_offering.service.logo %}
<img class="img-fluid" src="{{ selected_offering.service.logo.url }}" alt="Service Logo">
<img class="img-fluid" src="{{ selected_offering.service.get_logo.url }}" alt="Service Logo">
{% endif %}
</div>
<div class="card__header ps-16">

View file

@ -34,7 +34,7 @@
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if offering.service.logo %}
<a href="{{ offering.service.get_absolute_url }}">
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.logo.url }}"
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.get_logo.url }}"
alt="{{ offering.service.name }} logo" style="max-height: 120px; object-fit: contain;">
</a>
{% endif %}
@ -50,7 +50,7 @@
<div class="mb-40">
<h3 class="fw-semibold mb-12">Runs on</h3>
<a href="{{ offering.cloud_provider.get_absolute_url }}">
<img class="img-fluid" src="{{ offering.cloud_provider.logo.url }}" alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;">
<img class="img-fluid" src="{{ offering.cloud_provider.get_logo.url }}" alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;">
</a>
</div>

View file

@ -151,7 +151,7 @@
<div class="d-flex align-items-start mb-3">
<div class="me-3">
{% if offering.service.logo %}
<img src="{{ offering.service.logo.url }}"
<img src="{{ offering.service.get_logo.url }}"
alt="{{ offering.service.name }}"
style="max-height: 50px; max-width: 100px; object-fit: contain;">
{% endif %}
@ -165,7 +165,7 @@
<div class="d-flex align-items-center">
{% if offering.cloud_provider.logo %}
<a href="{{ offering.get_absolute_url }}" class="me-2">
<img src="{{ offering.cloud_provider.logo.url }}"
<img src="{{ offering.cloud_provider.get_logo.url }}"
alt="{{ offering.cloud_provider.name }}"
style="max-height: 30px; max-width: 100px; object-fit: contain;">
</a>

View file

@ -24,7 +24,7 @@
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if partner.logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ partner.logo.url }}" alt="{{ partner.name }} logo" style="max-height: 120px; object-fit: contain;">
<img class="img-fluid w-100 w-lg-auto" src="{{ partner.get_logo.url }}" alt="{{ partner.name }} logo" style="max-height: 120px; object-fit: contain;">
{% endif %}
</div>
@ -178,7 +178,7 @@
{% if service.logo %}
<div class="card__image flex-shrink-0">
<a href="{{ service.get_absolute_url }}">
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
</a>
</div>
{% endif %}

View file

@ -115,7 +115,7 @@
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
<div class="me-3">
<a href="{{ partner.get_absolute_url }}" class="clickable-link">
<img src="{{ partner.logo.url }}"
<img src="{{ partner.get_logo.url }}"
alt="{{ partner.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>

View file

@ -24,7 +24,7 @@
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if provider.logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ provider.logo.url }}" alt="{{ provider.name }} logo" style="max-height: 120px; object-fit: contain;">
<img class="img-fluid w-100 w-lg-auto" src="{{ provider.get_logo.url }}" alt="{{ provider.name }} logo" style="max-height: 120px; object-fit: contain;">
{% endif %}
</div>
@ -178,7 +178,7 @@
{% if offering.service.logo %}
<div class="card__image flex-shrink-0">
<a href="{{ offering.get_absolute_url }}">
<img src="{{ offering.service.logo.url }}" alt="{{ offering.service.name }} logo" class="img-fluid">
<img src="{{ offering.service.get_logo.url }}" alt="{{ offering.service.name }} logo" class="img-fluid">
</a>
</div>
{% endif %}

View file

@ -99,7 +99,7 @@
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ provider.get_absolute_url }}" class="clickable-link">
{% if provider.logo %}
<img src="{{ provider.logo.url }}"
<img src="{{ provider.get_logo.url }}"
alt="{{ provider.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>

View file

@ -23,7 +23,7 @@
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if service.logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ service.logo.url }}" alt="{{ service.name }} logo" style="max-height: 120px; object-fit: contain;">
<img class="img-fluid w-100 w-lg-auto" src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" style="max-height: 120px; object-fit: contain;">
{% endif %}
</div>
@ -184,7 +184,7 @@
<div class="card-body text-center">
{% if offering.cloud_provider.logo %}
<div class="mb-3 d-flex align-items-center justify-content-center" style="height: 80px;">
<img src="{{ offering.cloud_provider.logo.url }}" alt="{{ offering.cloud_provider.name }} logo"
<img src="{{ offering.cloud_provider.get_logo.url }}" alt="{{ offering.cloud_provider.name }} logo"
class="img-fluid" style="max-height: 60px; object-fit: contain;">
</div>
{% else %}

View file

@ -156,7 +156,7 @@
<div class="d-flex justify-content-between mb-3">
{% if service.logo %}
<div class="card__image flex-shrink-0">
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
</div>
{% endif %}
{% if service.is_featured %}

View file

@ -119,8 +119,8 @@ def json_ld_structured_data(context):
}
# Add image if available
if hasattr(service, "logo") and service.logo:
data["image"] = request.build_absolute_uri(service.logo.url)
if hasattr(service, "get_logo") and service.get_logo:
data["image"] = request.build_absolute_uri(service.get_logo.url)
# Add offerings if available
if hasattr(service, "offerings") and service.offerings.exists():
@ -143,8 +143,8 @@ def json_ld_structured_data(context):
}
# Add image if available
if hasattr(provider, "logo") and provider.logo:
data["logo"] = request.build_absolute_uri(provider.logo.url)
if hasattr(provider, "get_logo") and provider.get_logo:
data["logo"] = request.build_absolute_uri(provider.get_logo.url)
# Add contact information if available
contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"}
@ -179,8 +179,8 @@ def json_ld_structured_data(context):
}
# Add image if available
if hasattr(partner, "logo") and partner.logo:
data["logo"] = request.build_absolute_uri(partner.logo.url)
if hasattr(partner, "get_logo") and partner.get_logo:
data["logo"] = request.build_absolute_uri(partner.get_logo.url)
# Add contact information if available
contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"}
@ -219,8 +219,8 @@ def json_ld_structured_data(context):
data["brand"] = {"@type": "Brand", "name": offering.service.name}
# Add image if available
if hasattr(offering.service, "logo") and offering.service.logo:
data["image"] = request.build_absolute_uri(offering.service.logo.url)
if hasattr(offering.service, "get_logo") and offering.service.get_logo:
data["image"] = request.build_absolute_uri(offering.service.get_logo.url)
# Add offers if available
if hasattr(offering, "plans") and offering.plans.exists():