diff --git a/k8s/auth-service/values-prod.yaml b/k8s/auth-service/values-prod.yaml
index 4153720405..de099131fe 100644
--- a/k8s/auth-service/values-prod.yaml
+++ b/k8s/auth-service/values-prod.yaml
@@ -6,7 +6,7 @@ app:
replicaCount: 3
image:
repository: eu.gcr.io/airqo-250220/airqo-auth-api
- tag: prod-16e708ac-1733476956
+ tag: prod-db33421c-1733504207
nameOverride: ''
fullnameOverride: ''
podAnnotations: {}
diff --git a/k8s/exceedance/values-prod-airqo.yaml b/k8s/exceedance/values-prod-airqo.yaml
index 83eb989a0c..2fb997ba48 100644
--- a/k8s/exceedance/values-prod-airqo.yaml
+++ b/k8s/exceedance/values-prod-airqo.yaml
@@ -4,6 +4,6 @@ app:
configmap: env-exceedance-production
image:
repository: eu.gcr.io/airqo-250220/airqo-exceedance-job
- tag: prod-16e708ac-1733476956
+ tag: prod-db33421c-1733504207
nameOverride: ''
fullnameOverride: ''
diff --git a/k8s/exceedance/values-prod-kcca.yaml b/k8s/exceedance/values-prod-kcca.yaml
index d1371cf061..e78e6e6cab 100644
--- a/k8s/exceedance/values-prod-kcca.yaml
+++ b/k8s/exceedance/values-prod-kcca.yaml
@@ -4,6 +4,6 @@ app:
configmap: env-exceedance-production
image:
repository: eu.gcr.io/airqo-250220/kcca-exceedance-job
- tag: prod-16e708ac-1733476956
+ tag: prod-db33421c-1733504207
nameOverride: ''
fullnameOverride: ''
diff --git a/k8s/predict/values-prod.yaml b/k8s/predict/values-prod.yaml
index 60b5b95239..5587eeb7e6 100644
--- a/k8s/predict/values-prod.yaml
+++ b/k8s/predict/values-prod.yaml
@@ -7,7 +7,7 @@ images:
predictJob: eu.gcr.io/airqo-250220/airqo-predict-job
trainJob: eu.gcr.io/airqo-250220/airqo-train-job
predictPlaces: eu.gcr.io/airqo-250220/airqo-predict-places-air-quality
- tag: prod-16e708ac-1733476956
+ tag: prod-db33421c-1733504207
api:
name: airqo-prediction-api
label: prediction-api
diff --git a/k8s/spatial/values-prod.yaml b/k8s/spatial/values-prod.yaml
index 5d85e6ce4c..4ce34afd35 100644
--- a/k8s/spatial/values-prod.yaml
+++ b/k8s/spatial/values-prod.yaml
@@ -6,7 +6,7 @@ app:
replicaCount: 3
image:
repository: eu.gcr.io/airqo-250220/airqo-spatial-api
- tag: prod-16e708ac-1733476956
+ tag: prod-db33421c-1733504207
nameOverride: ''
fullnameOverride: ''
podAnnotations: {}
diff --git a/k8s/workflows/values-prod.yaml b/k8s/workflows/values-prod.yaml
index 49b7352ac7..b98e3d8090 100644
--- a/k8s/workflows/values-prod.yaml
+++ b/k8s/workflows/values-prod.yaml
@@ -10,7 +10,7 @@ images:
initContainer: eu.gcr.io/airqo-250220/airqo-workflows-xcom
redisContainer: eu.gcr.io/airqo-250220/airqo-redis
containers: eu.gcr.io/airqo-250220/airqo-workflows
- tag: prod-16e708ac-1733476956
+ tag: prod-db33421c-1733504207
nameOverride: ''
fullnameOverride: ''
podAnnotations: {}
diff --git a/src/website/Dockerfile b/src/website/Dockerfile
index d337097193..6a0f466bf7 100644
--- a/src/website/Dockerfile
+++ b/src/website/Dockerfile
@@ -1,5 +1,5 @@
# Use Python 3.9-slim as the base image
-FROM python:3.9-slim
+FROM python:3.11-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
diff --git a/src/website/apps/africancities/admin.py b/src/website/apps/africancities/admin.py
index 0263891cbc..2bf3c43b0d 100644
--- a/src/website/apps/africancities/admin.py
+++ b/src/website/apps/africancities/admin.py
@@ -43,7 +43,8 @@ def flag_preview(self, obj):
width, height = 60, 40
from django.utils.html import format_html
- flag_url = obj.get_country_flag_url()
- if flag_url:
- return format_html(f'')
+ if obj.country_flag:
+ return format_html(f'')
return '-'
+
+ flag_preview.short_description = "Country Flag"
diff --git a/src/website/apps/africancities/migrations/0011_alter_africancountry_country_flag_alter_image_image.py b/src/website/apps/africancities/migrations/0011_alter_africancountry_country_flag_alter_image_image.py
new file mode 100644
index 0000000000..ee9dca5550
--- /dev/null
+++ b/src/website/apps/africancities/migrations/0011_alter_africancountry_country_flag_alter_image_image.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.1.4 on 2024-12-06 16:34
+
+import cloudinary.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('africancities', '0010_alter_africancountry_country_flag_alter_image_image'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='africancountry',
+ name='country_flag',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AlterField(
+ model_name='image',
+ name='image',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True),
+ ),
+ ]
diff --git a/src/website/apps/africancities/models.py b/src/website/apps/africancities/models.py
index 99ad4d3a51..f0e19afee0 100644
--- a/src/website/apps/africancities/models.py
+++ b/src/website/apps/africancities/models.py
@@ -1,18 +1,17 @@
from django.conf import settings
from django.db import models
-from utils.fields import ConditionalImageField
+from cloudinary.models import CloudinaryField
from utils.models import BaseModel
class AfricanCountry(BaseModel):
country_name = models.CharField(max_length=100)
- country_flag = ConditionalImageField(
- local_upload_to='countries/flags/',
- cloudinary_folder='website/uploads/countries/flags',
+ country_flag = CloudinaryField(
+ folder='website/uploads/africancities/flags',
null=True,
- blank=True
+ blank=True,
+ resource_type='image'
)
-
order = models.IntegerField(default=1)
class Meta:
@@ -21,19 +20,12 @@ class Meta:
def __str__(self):
return self.country_name
- def get_country_flag_url(self, request=None):
+ def get_country_flag_url(self):
"""
- Return the full URL for the country flag.
- - For Cloudinary, return a secure HTTPS URL.
- - For local development, return the absolute URL using request.build_absolute_uri.
+ Return the secure URL for the country flag.
"""
if self.country_flag:
- if not settings.DEBUG:
- # Cloudinary secure URL
- return self.country_flag.url # Already provides secure URL by default
- else:
- # Local development, ensure full URL
- return request.build_absolute_uri(self.country_flag.url) if request else self.country_flag.url
+ return self.country_flag.url # Cloudinary already provides a secure URL
return None
@@ -44,7 +36,7 @@ class City(BaseModel):
AfricanCountry,
null=True,
related_name="city",
- on_delete=models.deletion.SET_NULL,
+ on_delete=models.SET_NULL,
)
class Meta:
@@ -61,7 +53,7 @@ class Content(BaseModel):
City,
null=True,
related_name="content",
- on_delete=models.deletion.SET_NULL,
+ on_delete=models.SET_NULL,
)
class Meta:
@@ -79,7 +71,7 @@ class Description(BaseModel):
null=True,
blank=True,
related_name="description",
- on_delete=models.deletion.SET_NULL,
+ on_delete=models.SET_NULL,
)
class Meta:
@@ -90,19 +82,19 @@ def __str__(self):
class Image(BaseModel):
- image = ConditionalImageField(
- local_upload_to='content/images/',
- cloudinary_folder='website/uploads/content/images',
+ image = CloudinaryField(
+ folder='website/uploads/africancities/images',
null=True,
- blank=True
+ blank=True,
+ resource_type='image'
)
order = models.IntegerField(default=1)
content = models.ForeignKey(
- 'Content',
+ Content,
null=True,
blank=True,
related_name="image",
- on_delete=models.deletion.SET_NULL,
+ on_delete=models.SET_NULL,
)
class Meta:
@@ -111,17 +103,10 @@ class Meta:
def __str__(self):
return f"Image-{self.id}"
- def get_image_url(self, request=None):
+ def get_image_url(self):
"""
- Return the full URL for the image.
- - For Cloudinary, return a secure HTTPS URL.
- - For local development, return the absolute URL using request.build_absolute_uri.
+ Return the secure URL for the image.
"""
if self.image:
- if not settings.DEBUG:
- # Cloudinary secure URL
- return self.image.url # Cloudinary already provides secure URL
- else:
- # Local development, ensure full URL
- return request.build_absolute_uri(self.image.url) if request else self.image.url
+ return self.image.url # Cloudinary already provides a secure URL
return None
diff --git a/src/website/apps/africancities/serializers.py b/src/website/apps/africancities/serializers.py
index ed21d16dcc..97cb7f4074 100644
--- a/src/website/apps/africancities/serializers.py
+++ b/src/website/apps/africancities/serializers.py
@@ -6,8 +6,7 @@ class ImageSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
def get_image_url(self, obj):
- request = self.context.get('request') # Get the request context for absolute URLs
- return obj.get_image_url(request)
+ return obj.get_image_url() # Cloudinary already provides a secure URL
class Meta:
fields = ('id', 'image_url')
@@ -42,8 +41,7 @@ class AfricanCitySerializer(serializers.ModelSerializer):
country_flag_url = serializers.SerializerMethodField()
def get_country_flag_url(self, obj):
- request = self.context.get('request') # Get the request context for absolute URLs
- return obj.get_country_flag_url(request)
+ return obj.get_country_flag_url()
class Meta:
fields = ('id', 'country_name', 'country_flag_url', 'city')
diff --git a/src/website/apps/board/admin.py b/src/website/apps/board/admin.py
index 3f01f4ade7..12c27af46e 100644
--- a/src/website/apps/board/admin.py
+++ b/src/website/apps/board/admin.py
@@ -31,11 +31,11 @@ class BoardMemberAdmin(nested_admin.NestedModelAdmin):
inlines = (BoardMemberBiographyInline,)
def image_tag(self, obj):
- width, height = 100, 200
+ width, height = 150, 200
from django.utils.html import format_html
if obj.picture:
- return format_html(f'')
+ return format_html(f'')
return '-'
image_tag.short_description = "Image Preview"
diff --git a/src/website/apps/board/migrations/0007_alter_boardmember_picture.py b/src/website/apps/board/migrations/0007_alter_boardmember_picture.py
new file mode 100644
index 0000000000..aa06c8d87b
--- /dev/null
+++ b/src/website/apps/board/migrations/0007_alter_boardmember_picture.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.1.4 on 2024-12-06 16:24
+
+import cloudinary.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('board', '0006_alter_boardmember_picture'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='boardmember',
+ name='picture',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True),
+ ),
+ ]
diff --git a/src/website/apps/board/models.py b/src/website/apps/board/models.py
index 0ed659447a..6d98913fb8 100644
--- a/src/website/apps/board/models.py
+++ b/src/website/apps/board/models.py
@@ -1,5 +1,5 @@
from django.db import models
-from utils.fields import ConditionalImageField
+from cloudinary.models import CloudinaryField
from utils.models import BaseModel
@@ -7,11 +7,11 @@ class BoardMember(BaseModel):
name = models.CharField(max_length=100)
title = models.CharField(max_length=100)
- picture = ConditionalImageField(
- local_upload_to='boardmembers/pictures/',
- cloudinary_folder='website/uploads/team/board_members',
+ picture = CloudinaryField(
+ folder='website/uploads/team/board_members',
null=True,
- blank=True
+ blank=True,
+ resource_type='image',
)
twitter = models.URLField(max_length=255, null=True, blank=True)
@@ -26,8 +26,7 @@ def __str__(self):
def get_picture_url(self):
if self.picture:
- # Secure URL is handled internally by Cloudinary or the file system
- return self.picture.url
+ return self.picture.url # Cloudinary provides the actual URL of the uploaded image
return None
diff --git a/src/website/apps/board/serializers.py b/src/website/apps/board/serializers.py
index 1ddc7aca4a..784952366f 100644
--- a/src/website/apps/board/serializers.py
+++ b/src/website/apps/board/serializers.py
@@ -17,4 +17,6 @@ class Meta:
fields = '__all__'
def get_picture_url(self, obj):
- return obj.get_picture_url() # Secure or local URL is handled inside the model
+ if obj.picture:
+ return obj.picture.url # Cloudinary automatically provides the correct URL
+ return None
diff --git a/src/website/apps/cleanair/admin.py b/src/website/apps/cleanair/admin.py
index bd863fe3ec..bf1e4d33f3 100644
--- a/src/website/apps/cleanair/admin.py
+++ b/src/website/apps/cleanair/admin.py
@@ -1,12 +1,26 @@
-from django.conf import settings
from django.contrib import admin
from django.utils.html import format_html
+from nested_admin import NestedTabularInline, NestedModelAdmin
from .models import (
- CleanAirResource, ForumEvent, Partner, Program, Session, Support,
- Person, Engagement, Objective, ForumResource, ResourceFile, ResourceSession
+ CleanAirResource,
+ ForumEvent,
+ ForumResource,
+ Partner,
+ Person,
+ Program,
+ Objective,
+ Engagement,
+ Session,
+ Support,
+ ResourceFile,
+ ResourceSession,
)
-from cloudinary.utils import cloudinary_url
-from nested_admin import NestedTabularInline, NestedModelAdmin
+import logging
+
+# Configure logger
+logger = logging.getLogger(__name__)
+
+# Inline Classes
class ObjectiveInline(NestedTabularInline):
@@ -20,13 +34,13 @@ class EngagementInline(NestedTabularInline):
extra = 0
-class SessionInline(NestedTabularInline):
- model = Session
+class SupportInline(NestedTabularInline):
+ model = Support
extra = 0
-class SupportInline(NestedTabularInline):
- model = Support
+class SessionInline(NestedTabularInline):
+ model = Session
extra = 0
@@ -41,6 +55,13 @@ class ResourceSessionInline(NestedTabularInline):
inlines = [ResourceFileInline]
+class ForumResourceInline(NestedTabularInline):
+ model = ForumResource
+ inlines = [ResourceSessionInline]
+ extra = 0
+
+
+# Admin Classes
@admin.register(CleanAirResource)
class CleanAirResourceAdmin(admin.ModelAdmin):
list_display = ('resource_title', 'resource_category',
@@ -53,68 +74,89 @@ class CleanAirResourceAdmin(admin.ModelAdmin):
@admin.register(ForumEvent)
class ForumEventAdmin(NestedModelAdmin):
- list_display = ('title', 'start_date', 'end_date', 'order')
+ list_display = ('title', 'start_date', 'end_date',
+ 'order', 'background_image_preview')
list_filter = ('start_date', 'end_date')
search_fields = ('title',)
readonly_fields = ()
list_per_page = 12
inlines = [EngagementInline, SupportInline]
+ def background_image_preview(self, obj):
+ """Preview background image."""
+ if obj.background_image and hasattr(obj.background_image, 'url'):
+ try:
+ return format_html(
+ '',
+ obj.background_image.url
+ )
+ except Exception as e:
+ logger.error(
+ f"Error loading background_image for ForumEvent '{obj.title}': {e}")
+ return "Error loading image."
+ elif isinstance(obj.background_image, str) and obj.background_image:
+ # Handle cases where background_image is a string path
+ try:
+ return format_html(
+ '',
+ obj.background_image
+ )
+ except Exception as e:
+ logger.error(
+ f"Error loading background_image path for ForumEvent '{obj.title}': {e}")
+ return "Error loading image."
+ return "No image uploaded."
+
+ background_image_preview.short_description = 'Background Image'
-@admin.register(Program)
-class ProgramAdmin(NestedModelAdmin):
- list_display = ('title', 'forum_event',)
+
+@admin.register(ForumResource)
+class ForumResourceAdmin(NestedModelAdmin):
+ inlines = [ResourceSessionInline]
+ list_display = ('resource_title', 'resource_authors',
+ 'order', 'forum_event')
+ search_fields = ('resource_title', 'resource_authors')
list_filter = ('forum_event',)
- search_fields = ('title', 'forum_event__title',)
list_per_page = 12
- inlines = [SessionInline]
-@admin.register(Person)
-class PersonAdmin(admin.ModelAdmin):
- list_display = ('name', 'forum_event', 'category', 'image_preview', 'order')
+@admin.register(Partner)
+class PartnerAdmin(admin.ModelAdmin):
+ list_display = ('name', 'forum_event', 'category', 'logo_preview', 'order')
list_filter = ('forum_event',)
search_fields = ('name', 'category', 'forum_event__title',)
list_per_page = 12
- def image_preview(self, obj):
- if obj.picture:
- # Use Cloudinary URL if in production
- if not settings.DEBUG:
- url = cloudinary_url(obj.picture.public_id, secure=True)[0]
- else:
- url = obj.picture.url # Use the local URL if in DEBUG mode
+ def logo_preview(self, obj):
+ if obj.partner_logo:
+ url = obj.partner_logo.url
height = 100
return format_html('', url, height)
return ""
- image_preview.short_description = 'Picture'
+ logo_preview.short_description = 'Logo'
-@admin.register(Partner)
-class PartnerAdmin(admin.ModelAdmin):
- list_display = ('name', 'forum_event', 'category', 'logo_preview', 'order')
+@admin.register(Person)
+class PersonAdmin(admin.ModelAdmin):
+ list_display = ('name', 'forum_event', 'category',
+ 'image_preview', 'order')
list_filter = ('forum_event',)
search_fields = ('name', 'category', 'forum_event__title',)
list_per_page = 12
- def logo_preview(self, obj):
- if obj.partner_logo:
- # Use Cloudinary URL if in production
- if not settings.DEBUG:
- url = cloudinary_url(obj.partner_logo.public_id, secure=True)[0]
- else:
- url = obj.partner_logo.url # Use the local URL if in DEBUG mode
+ def image_preview(self, obj):
+ if obj.picture:
+ url = obj.picture.url
height = 100
return format_html('', url, height)
return ""
- logo_preview.short_description = 'Logo'
-
+ image_preview.short_description = 'Picture'
-@admin.register(ForumResource)
-class ForumResourceAdmin(NestedModelAdmin):
- inlines = [ResourceSessionInline]
- list_display = ('resource_title', 'resource_authors',
- 'order', 'forum_event')
- search_fields = ('resource_title', 'resource_authors')
+@admin.register(Program)
+class ProgramAdmin(NestedModelAdmin):
+ list_display = ('title', 'forum_event',)
list_filter = ('forum_event',)
+ search_fields = ('title', 'forum_event__title',)
+ list_per_page = 12
+ inlines = [SessionInline]
diff --git a/src/website/apps/cleanair/migrations/0015_alter_cleanairresource_resource_file_and_more.py b/src/website/apps/cleanair/migrations/0015_alter_cleanairresource_resource_file_and_more.py
new file mode 100644
index 0000000000..e30e1e9ea1
--- /dev/null
+++ b/src/website/apps/cleanair/migrations/0015_alter_cleanairresource_resource_file_and_more.py
@@ -0,0 +1,39 @@
+# Generated by Django 5.1.4 on 2024-12-06 15:23
+
+import cloudinary.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('cleanair', '0014_alter_cleanairresource_resource_file_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='cleanairresource',
+ name='resource_file',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True, verbose_name='resource_file'),
+ ),
+ migrations.AlterField(
+ model_name='forumevent',
+ name='background_image',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True, verbose_name='background_image'),
+ ),
+ migrations.AlterField(
+ model_name='partner',
+ name='partner_logo',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True, verbose_name='partner_logo'),
+ ),
+ migrations.AlterField(
+ model_name='person',
+ name='picture',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True, verbose_name='picture'),
+ ),
+ migrations.AlterField(
+ model_name='resourcefile',
+ name='file',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True, verbose_name='file'),
+ ),
+ ]
diff --git a/src/website/apps/cleanair/migrations/0016_alter_engagement_forum_event_and_more.py b/src/website/apps/cleanair/migrations/0016_alter_engagement_forum_event_and_more.py
new file mode 100644
index 0000000000..cffcdd4c23
--- /dev/null
+++ b/src/website/apps/cleanair/migrations/0016_alter_engagement_forum_event_and_more.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.1.4 on 2024-12-06 16:06
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('cleanair', '0015_alter_cleanairresource_resource_file_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='engagement',
+ name='forum_event',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='engagement', to='cleanair.forumevent'),
+ ),
+ migrations.AlterField(
+ model_name='session',
+ name='session_details',
+ field=models.TextField(default='No details available yet.'),
+ ),
+ ]
diff --git a/src/website/apps/cleanair/migrations/0017_auto_20241206_1907.py b/src/website/apps/cleanair/migrations/0017_auto_20241206_1907.py
new file mode 100644
index 0000000000..4e9f8a8249
--- /dev/null
+++ b/src/website/apps/cleanair/migrations/0017_auto_20241206_1907.py
@@ -0,0 +1,74 @@
+from django.db import migrations
+import json
+from html.parser import HTMLParser
+
+
+def transform_quill_to_plain_text(apps, schema_editor):
+ """
+ Transform QuillField data stored in JSON or HTML format into plain text for TextField.
+ """
+ # Get the Session model
+ Session = apps.get_model('cleanair', 'Session')
+
+ class HTMLToTextParser(HTMLParser):
+ """Utility to convert HTML content to plain text."""
+
+ def __init__(self):
+ super().__init__()
+ self.text_parts = []
+
+ def handle_data(self, data):
+ self.text_parts.append(data)
+
+ def handle_starttag(self, tag, attrs):
+ if tag in ['br', 'p']:
+ self.text_parts.append("\n") # Add new line for certain tags
+
+ def get_text(self):
+ return ''.join(self.text_parts).strip()
+
+ def extract_plain_text(data):
+ """
+ Extract plain text from stored QuillField JSON or HTML data.
+ """
+ try:
+ # Attempt to parse JSON content
+ parsed = json.loads(data)
+ if "delta" in parsed:
+ # Handle Quill Delta JSON format
+ ops = json.loads(parsed["delta"]).get("ops", [])
+ plain_text = "".join(op.get("insert", "") for op in ops)
+ return plain_text.strip()
+ elif "html" in parsed:
+ # Handle Quill HTML content
+ html_parser = HTMLToTextParser()
+ html_parser.feed(parsed["html"])
+ return html_parser.get_text()
+ except (json.JSONDecodeError, KeyError, AttributeError):
+ pass # Fallback to HTML parsing below
+
+ # Handle plain HTML directly
+ html_parser = HTMLToTextParser()
+ html_parser.feed(data)
+ return html_parser.get_text()
+
+ # Process Session model
+ for session in Session.objects.all():
+ try:
+ session.session_details = extract_plain_text(
+ session.session_details
+ )
+ session.save()
+ except Exception as e:
+ print(f"Failed to process Session ID {session.id}: {e}")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('cleanair', '0016_alter_engagement_forum_event_and_more'),
+ ]
+
+ operations = [
+ migrations.RunPython(transform_quill_to_plain_text),
+ ]
diff --git a/src/website/apps/cleanair/models.py b/src/website/apps/cleanair/models.py
index bee0f53622..b247952744 100644
--- a/src/website/apps/cleanair/models.py
+++ b/src/website/apps/cleanair/models.py
@@ -1,20 +1,28 @@
+# models.py
+
from django.db import models
from django_quill.fields import QuillField
from utils.models import BaseModel
-from utils.fields import ConditionalFileField, ConditionalImageField
from django.db.models.signals import pre_save
from django.dispatch import receiver
from enum import Enum
from django.utils.text import slugify
+from cloudinary.models import CloudinaryField
class CleanAirResource(BaseModel):
resource_title = models.CharField(max_length=120)
resource_link = models.URLField(null=True, blank=True)
- resource_file = ConditionalFileField(
- local_upload_to='cleanair/resources/', cloudinary_folder='website/uploads/cleanair/resources/', null=True, blank=True)
+ resource_file = CloudinaryField(
+ 'resource_file',
+ resource_type='raw',
+ folder='website/uploads/cleanair/resources/',
+ null=True,
+ blank=True
+ )
author_title = models.CharField(
- max_length=40, null=True, blank=True, default="Created By")
+ max_length=40, null=True, blank=True, default="Created By"
+ )
class ResourceCategory(models.TextChoices):
TOOLKIT = "toolkit", "ToolKit"
@@ -23,8 +31,11 @@ class ResourceCategory(models.TextChoices):
RESEARCH_PUBLICATION = "research_publication", "Research Publication"
resource_category = models.CharField(
- max_length=40, default=ResourceCategory.TECHNICAL_REPORT,
- choices=ResourceCategory.choices, null=False, blank=False
+ max_length=40,
+ default=ResourceCategory.TECHNICAL_REPORT,
+ choices=ResourceCategory.choices,
+ null=False,
+ blank=False
)
resource_authors = models.CharField(max_length=200, default="AirQo")
order = models.IntegerField(default=1)
@@ -73,8 +84,13 @@ class ForumEvent(BaseModel):
glossary_details = QuillField(
blank=True, null=True, default="No details available yet.")
unique_title = models.CharField(max_length=100, blank=True)
- background_image = ConditionalImageField(
- local_upload_to='events/images/', cloudinary_folder='website/uploads/events/images', null=True, blank=True)
+ background_image = CloudinaryField(
+ 'background_image',
+ folder='website/uploads/events/images/',
+ resource_type='image',
+ null=True,
+ blank=True
+ )
location_name = models.CharField(max_length=100, blank=True)
location_link = models.URLField(blank=True)
order = models.IntegerField(default=1)
@@ -124,7 +140,7 @@ def choices(cls):
class Engagement(BaseModel):
title = models.CharField(max_length=200)
forum_event = models.OneToOneField(
- ForumEvent, null=True, blank=True, related_name="engagements", on_delete=models.SET_NULL,
+ ForumEvent, null=True, blank=True, related_name="engagement", on_delete=models.SET_NULL,
)
def __str__(self):
@@ -147,18 +163,26 @@ def __str__(self):
class Partner(BaseModel):
- partner_logo = ConditionalImageField(
- local_upload_to='cleanair/partners/', cloudinary_folder='website/uploads/cleanair/partners/', null=True, blank=True)
+ partner_logo = CloudinaryField(
+ 'partner_logo',
+ folder='website/uploads/cleanair/partners/',
+ resource_type='image',
+ null=True,
+ blank=True
+ )
name = models.CharField(max_length=70)
website_link = models.URLField(blank=True, null=True)
order = models.IntegerField(default=1)
category = models.CharField(
max_length=50, choices=PartnerCategoryChoices.choices(),
- default=PartnerCategoryChoices.FUNDING_PARTNER.value)
+ default=PartnerCategoryChoices.FUNDING_PARTNER.value
+ )
forum_event = models.ForeignKey(
- ForumEvent, null=True, blank=True, related_name="partners", on_delete=models.SET_NULL)
+ ForumEvent, null=True, blank=True, related_name="partners", on_delete=models.SET_NULL
+ )
authored_by = models.ForeignKey(
- 'auth.User', related_name='cleanair_partner_authored_by', null=True, blank=True, on_delete=models.SET_NULL)
+ 'auth.User', related_name='cleanair_partner_authored_by', null=True, blank=True, on_delete=models.SET_NULL
+ )
class Meta:
ordering = ['order']
@@ -173,9 +197,11 @@ class Program(BaseModel):
default="No details available yet.")
order = models.IntegerField(default=1)
forum_event = models.ForeignKey(
- ForumEvent, null=True, blank=True, related_name="programs", on_delete=models.SET_NULL)
+ ForumEvent, null=True, blank=True, related_name="programs", on_delete=models.SET_NULL
+ )
authored_by = models.ForeignKey(
- 'auth.User', related_name='cleanair_program_authored_by', null=True, blank=True, on_delete=models.SET_NULL)
+ 'auth.User', related_name='cleanair_program_authored_by', null=True, blank=True, on_delete=models.SET_NULL
+ )
class Meta:
ordering = ['order']
@@ -188,12 +214,14 @@ class Session(BaseModel):
start_time = models.TimeField(blank=True, null=True)
end_time = models.TimeField(blank=False, null=True)
session_title = models.CharField(max_length=150)
- session_details = QuillField(blank=False, null=True)
+ session_details = models.TextField(default="No details available yet.")
order = models.IntegerField(default=1)
program = models.ForeignKey(
- Program, null=True, blank=True, related_name="sessions", on_delete=models.SET_NULL)
+ Program, null=True, blank=True, related_name="sessions", on_delete=models.SET_NULL
+ )
authored_by = models.ForeignKey(
- 'auth.User', related_name='cleanair_session_authored_by', null=True, blank=True, on_delete=models.SET_NULL)
+ 'auth.User', related_name='cleanair_session_authored_by', null=True, blank=True, on_delete=models.SET_NULL
+ )
class Meta:
ordering = ['order']
@@ -226,9 +254,15 @@ class Person(BaseModel):
default="No details available yet.")
category = models.CharField(
max_length=50, choices=CategoryChoices.choices(),
- default=CategoryChoices.SPEAKER.value)
- picture = ConditionalImageField(
- local_upload_to='cleanair/persons/', cloudinary_folder='website/uploads/cleanair/persons/', null=True, blank=True)
+ default=CategoryChoices.SPEAKER.value
+ )
+ picture = CloudinaryField(
+ 'picture',
+ folder='website/uploads/cleanair/persons/',
+ resource_type='image',
+ null=True,
+ blank=True
+ )
twitter = models.URLField(blank=True)
linked_in = models.URLField(blank=True)
order = models.IntegerField(default=1)
@@ -274,17 +308,23 @@ def __str__(self):
class ResourceFile(BaseModel):
resource_summary = models.TextField(blank=True, null=True)
- file = ConditionalFileField(local_upload_to='cleanair/resources/',
- cloudinary_folder='website/uploads/cleanair/resources/')
+ file = CloudinaryField(
+ 'file',
+ resource_type='raw',
+ folder='website/uploads/cleanair/resources/',
+ null=True,
+ blank=True
+ )
session = models.ForeignKey(
- 'ResourceSession', related_name='resource_files', on_delete=models.CASCADE, null=True, blank=True, default=1)
+ 'ResourceSession', related_name='resource_files', on_delete=models.CASCADE, null=True, blank=True, default=1
+ )
order = models.IntegerField(default=1)
class Meta:
ordering = ['order', '-id']
def __str__(self):
- return self.file.name
+ return self.file.url if self.file else "No File"
# signals.py
diff --git a/src/website/apps/cleanair/serializers.py b/src/website/apps/cleanair/serializers.py
index b091c55505..5c80f26d2b 100644
--- a/src/website/apps/cleanair/serializers.py
+++ b/src/website/apps/cleanair/serializers.py
@@ -1,3 +1,5 @@
+# serializers.py
+
from django.conf import settings
from rest_framework import serializers
from cloudinary.utils import cloudinary_url
@@ -9,6 +11,13 @@
class CleanAirResourceSerializer(serializers.ModelSerializer):
+ resource_file_url = serializers.SerializerMethodField()
+
+ def get_resource_file_url(self, obj):
+ if obj.resource_file:
+ return obj.resource_file.url
+ return None
+
class Meta:
model = CleanAirResource
fields = '__all__'
@@ -29,14 +38,11 @@ class Meta:
class PartnerSerializer(serializers.ModelSerializer):
- partner_logo = serializers.SerializerMethodField()
+ partner_logo_url = serializers.SerializerMethodField()
- def get_partner_logo(self, obj):
+ def get_partner_logo_url(self, obj):
if obj.partner_logo:
- if not settings.DEBUG:
- return cloudinary_url(obj.partner_logo.public_id, secure=True)[0]
- else:
- return self.context['request'].build_absolute_uri(obj.partner_logo.url)
+ return obj.partner_logo.url
return None
class Meta:
@@ -67,14 +73,11 @@ class Meta:
class PersonSerializer(serializers.ModelSerializer):
- picture = serializers.SerializerMethodField()
+ picture_url = serializers.SerializerMethodField()
- def get_picture(self, obj):
+ def get_picture_url(self, obj):
if obj.picture:
- if not settings.DEBUG:
- return cloudinary_url(obj.picture.public_id, secure=True)[0]
- else:
- return self.context['request'].build_absolute_uri(obj.picture.url)
+ return obj.picture.url
return None
class Meta:
@@ -86,18 +89,9 @@ class ResourceFileSerializer(serializers.ModelSerializer):
file_url = serializers.SerializerMethodField()
def get_file_url(self, obj):
- file_url = obj.file.url
-
- # If the file is stored in Cloudinary, return the Cloudinary URL
- if hasattr(obj.file, 'public_id'):
- return cloudinary_url(obj.file.public_id, secure=not settings.DEBUG)[0]
-
- # For all other URLs, return the stored URL as is
- if file_url.startswith('http'):
- return file_url
-
- # Otherwise, assume it's a local file and construct the absolute URL
- return self.context['request'].build_absolute_uri(file_url)
+ if obj.file:
+ return obj.file.url
+ return None
class Meta:
model = ResourceFile
@@ -122,19 +116,16 @@ class Meta:
class ForumEventSerializer(serializers.ModelSerializer):
forum_resources = ForumResourceSerializer(many=True, read_only=True)
- engagements = EngagementSerializer(read_only=True)
+ engagement = EngagementSerializer(read_only=True)
partners = PartnerSerializer(many=True, read_only=True)
supports = SupportSerializer(many=True, read_only=True)
programs = CleanAirProgramSerializer(many=True, read_only=True)
persons = PersonSerializer(many=True, read_only=True)
- background_image = serializers.SerializerMethodField()
+ background_image_url = serializers.SerializerMethodField()
- def get_background_image(self, obj):
+ def get_background_image_url(self, obj):
if obj.background_image:
- if not settings.DEBUG:
- return cloudinary_url(obj.background_image.public_id, secure=True)[0]
- else:
- return self.context['request'].build_absolute_uri(obj.background_image.url)
+ return obj.background_image.url
return None
class Meta:
diff --git a/src/website/apps/event/admin.py b/src/website/apps/event/admin.py
index cc669b0d7d..360d12090d 100644
--- a/src/website/apps/event/admin.py
+++ b/src/website/apps/event/admin.py
@@ -1,64 +1,173 @@
-# backend/apps/event/admin.py
from django.contrib import admin
import nested_admin
from .models import Event, Inquiry, Program, Session, PartnerLogo, Resource
+from django.utils.html import format_html
+import logging
+
+# Configure logger
+logger = logging.getLogger(__name__)
+
+# -------- Inline Classes --------
class InquiryInline(nested_admin.NestedTabularInline):
+ """Inline admin for inquiries."""
model = Inquiry
extra = 0
sortable_field_name = 'order'
fields = ('inquiry', 'role', 'email', 'order')
+ ordering = ['order']
class PartnerLogoInline(nested_admin.NestedTabularInline):
+ """Inline admin for partner logos with logo preview."""
model = PartnerLogo
extra = 0
sortable_field_name = 'order'
- fields = ('name', 'partner_logo', 'order')
+ fields = ('name', 'partner_logo', 'order', 'preview_logo')
+ readonly_fields = ('preview_logo',)
+
+ def preview_logo(self, obj):
+ """Preview partner logo."""
+ if obj.partner_logo and hasattr(obj.partner_logo, 'url'):
+ try:
+ return format_html(
+ '',
+ obj.partner_logo.url
+ )
+ except Exception as e:
+ logger.error(
+ f"Error loading partner_logo for Partner '{obj.name}': {e}")
+ return "Error loading logo."
+ elif isinstance(obj.partner_logo, str) and obj.partner_logo:
+ # Handle cases where partner_logo is a string path
+ try:
+ return format_html(
+ '',
+ obj.partner_logo
+ )
+ except Exception as e:
+ logger.error(
+ f"Error loading partner_logo path for Partner '{obj.name}': {e}")
+ return "Error loading logo."
+ return "No logo uploaded."
+
+ preview_logo.short_description = "Preview Logo"
class ResourceInline(nested_admin.NestedTabularInline):
+ """Inline admin for resources with download links."""
model = Resource
extra = 0
- fields = ('title', 'link', 'resource', 'order')
- sortable_field_name = 'order'
+ fields = ('title', 'link', 'resource', 'order', 'download_link')
+ readonly_fields = ('download_link',)
+
+ def download_link(self, obj):
+ """Generate a download link for resources."""
+ if obj.resource and hasattr(obj.resource, 'url'):
+ try:
+ return format_html('Download', obj.resource.url)
+ except Exception as e:
+ logger.error(
+ f"Error generating download link for Resource '{obj.title}': {e}")
+ return "Error generating link."
+ elif isinstance(obj.resource, str) and obj.resource:
+ # Handle cases where resource is a string path
+ try:
+ return format_html('Download', obj.resource)
+ except Exception as e:
+ logger.error(
+ f"Error generating download link path for Resource '{obj.title}': {e}")
+ return "Error generating link."
+ return "No resource uploaded."
+
+ download_link.short_description = "Resource Download Link"
class SessionInline(nested_admin.NestedTabularInline):
+ """Inline admin for sessions."""
model = Session
extra = 0
fields = ('session_title', 'start_time', 'end_time',
'venue', 'session_details', 'order')
sortable_field_name = 'order'
+ ordering = ['order']
-class ProgramInline(nested_admin.NestedTabularInline):
+class ProgramInline(nested_admin.NestedStackedInline):
+ """Inline admin for programs with nested sessions."""
model = Program
extra = 0
fields = ('date', 'program_details', 'order')
sortable_field_name = 'order'
+ ordering = ['order']
inlines = [SessionInline]
+# -------- Event Admin --------
+
@admin.register(Event)
class EventAdmin(nested_admin.NestedModelAdmin):
+ """Admin configuration for the Event model."""
list_display = (
'title',
'start_date',
'end_date',
'website_category',
'event_category',
- 'order'
+ 'order',
+ 'preview_event_image',
)
search_fields = ('title', 'location_name')
list_editable = ('order',)
ordering = ('order', '-start_date')
list_per_page = 10
+ readonly_fields = ('preview_event_image',)
inlines = [
InquiryInline,
ProgramInline,
PartnerLogoInline,
ResourceInline
]
+ fieldsets = (
+ ("Basic Information", {
+ "fields": ('title', 'title_subtext', 'start_date', 'end_date', 'start_time', 'end_time')
+ }),
+ ("Location", {
+ "fields": ('location_name', 'location_link')
+ }),
+ ("Details", {
+ "fields": ('event_details', 'registration_link', 'event_image', 'background_image')
+ }),
+ ("Categorization", {
+ "fields": ('website_category', 'event_category', 'event_tag', 'order')
+ }),
+ )
+
+ def preview_event_image(self, obj):
+ """Preview event image."""
+ if obj.event_image and hasattr(obj.event_image, 'url'):
+ try:
+ return format_html(
+ '',
+ obj.event_image.url
+ )
+ except Exception as e:
+ logger.error(
+ f"Error loading event_image for Event '{obj.title}': {e}")
+ return "Error loading image."
+ elif isinstance(obj.event_image, str) and obj.event_image:
+ # Handle cases where event_image is a string path
+ try:
+ return format_html(
+ '',
+ obj.event_image
+ )
+ except Exception as e:
+ logger.error(
+ f"Error loading event_image path for Event '{obj.title}': {e}")
+ return "Error loading image."
+ return "No image uploaded."
+
+ preview_event_image.short_description = "Event Image Preview"
diff --git a/src/website/apps/event/migrations/0011_alter_event_background_image_alter_event_event_image_and_more.py b/src/website/apps/event/migrations/0011_alter_event_background_image_alter_event_event_image_and_more.py
new file mode 100644
index 0000000000..26888f4237
--- /dev/null
+++ b/src/website/apps/event/migrations/0011_alter_event_background_image_alter_event_event_image_and_more.py
@@ -0,0 +1,34 @@
+# Generated by Django 4.2.5 on 2024-12-04 13:51
+
+from django.db import migrations, models
+import utils.validators
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('event', '0010_alter_event_background_image_alter_event_event_image_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='event',
+ name='background_image',
+ field=models.ImageField(blank=True, null=True, upload_to='website/uploads/events/images', validators=[utils.validators.validate_image]),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='event_image',
+ field=models.ImageField(blank=True, null=True, upload_to='website/uploads/events/images', validators=[utils.validators.validate_image]),
+ ),
+ migrations.AlterField(
+ model_name='partnerlogo',
+ name='partner_logo',
+ field=models.ImageField(blank=True, null=True, upload_to='website/uploads/events/logos/', validators=[utils.validators.validate_image]),
+ ),
+ migrations.AlterField(
+ model_name='resource',
+ name='resource',
+ field=models.FileField(blank=True, null=True, upload_to='website/uploads/events/files/', validators=[utils.validators.validate_file]),
+ ),
+ ]
diff --git a/src/website/apps/event/migrations/0012_alter_program_program_details_and_more.py b/src/website/apps/event/migrations/0012_alter_program_program_details_and_more.py
new file mode 100644
index 0000000000..65dd51f18f
--- /dev/null
+++ b/src/website/apps/event/migrations/0012_alter_program_program_details_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.1.4 on 2024-12-06 10:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('event', '0011_alter_event_background_image_alter_event_event_image_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='program',
+ name='program_details',
+ field=models.TextField(default='No details available yet.'),
+ ),
+ migrations.AlterField(
+ model_name='session',
+ name='session_details',
+ field=models.TextField(default='No details available yet.'),
+ ),
+ ]
diff --git a/src/website/apps/event/migrations/0013_auto_20241206_1317.py b/src/website/apps/event/migrations/0013_auto_20241206_1317.py
new file mode 100644
index 0000000000..e888d09289
--- /dev/null
+++ b/src/website/apps/event/migrations/0013_auto_20241206_1317.py
@@ -0,0 +1,85 @@
+from django.db import migrations
+import json
+from html.parser import HTMLParser
+
+
+def transform_quill_to_plain_text(apps, schema_editor):
+ """
+ Transform QuillField data stored in JSON or HTML format into plain text for TextField.
+ """
+ # Get the models
+ Program = apps.get_model('event', 'Program')
+ Session = apps.get_model('event', 'Session')
+
+ class HTMLToTextParser(HTMLParser):
+ """Utility to convert HTML content to plain text."""
+
+ def __init__(self):
+ super().__init__()
+ self.text_parts = []
+
+ def handle_data(self, data):
+ self.text_parts.append(data)
+
+ def handle_starttag(self, tag, attrs):
+ if tag in ['br', 'p']:
+ self.text_parts.append("\n") # Add new line for certain tags
+
+ def get_text(self):
+ return ''.join(self.text_parts).strip()
+
+ def extract_plain_text(data):
+ """
+ Extract plain text from stored QuillField JSON or HTML data.
+ """
+ try:
+ # Attempt to parse JSON content
+ parsed = json.loads(data)
+ if "delta" in parsed:
+ # Handle Quill Delta JSON format
+ ops = json.loads(parsed["delta"]).get("ops", [])
+ plain_text = "".join(op.get("insert", "") for op in ops)
+ return plain_text.strip()
+ elif "html" in parsed:
+ # Handle Quill HTML content
+ html_parser = HTMLToTextParser()
+ html_parser.feed(parsed["html"])
+ return html_parser.get_text()
+ except (json.JSONDecodeError, KeyError, AttributeError):
+ pass # Fallback to HTML parsing below
+
+ # Handle plain HTML directly
+ html_parser = HTMLToTextParser()
+ html_parser.feed(data)
+ return html_parser.get_text()
+
+ # Process Program model
+ for program in Program.objects.all():
+ try:
+ program.program_details = extract_plain_text(
+ program.program_details
+ )
+ program.save()
+ except Exception as e:
+ print(f"Failed to process Program ID {program.id}: {e}")
+
+ # Process Session model
+ for session in Session.objects.all():
+ try:
+ session.session_details = extract_plain_text(
+ session.session_details
+ )
+ session.save()
+ except Exception as e:
+ print(f"Failed to process Session ID {session.id}: {e}")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('event', '0012_alter_program_program_details_and_more'),
+ ]
+
+ operations = [
+ migrations.RunPython(transform_quill_to_plain_text),
+ ]
diff --git a/src/website/apps/event/migrations/0014_alter_event_background_image_and_more.py b/src/website/apps/event/migrations/0014_alter_event_background_image_and_more.py
new file mode 100644
index 0000000000..777f4ddfdc
--- /dev/null
+++ b/src/website/apps/event/migrations/0014_alter_event_background_image_and_more.py
@@ -0,0 +1,39 @@
+# Generated by Django 5.1.4 on 2024-12-06 12:39
+
+import utils.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('event', '0013_auto_20241206_1317'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='event',
+ name='background_image',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[utils.fields.validate_image_format, utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='event_details',
+ field=models.TextField(default='No details available yet.'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='event_image',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[utils.fields.validate_image_format, utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='partnerlogo',
+ name='partner_logo',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[utils.fields.validate_image_format, utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='resource',
+ name='resource',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[utils.fields.validate_image_format, utils.fields.validate_image_format]),
+ ),
+ ]
diff --git a/src/website/apps/event/migrations/0015_alter_event_background_image_and_more.py b/src/website/apps/event/migrations/0015_alter_event_background_image_and_more.py
new file mode 100644
index 0000000000..05ee1812c5
--- /dev/null
+++ b/src/website/apps/event/migrations/0015_alter_event_background_image_and_more.py
@@ -0,0 +1,40 @@
+# Generated by Django 5.1.4 on 2024-12-06 12:43
+
+import django_quill.fields
+import utils.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('event', '0014_alter_event_background_image_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='event',
+ name='background_image',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[utils.fields.validate_image_format, utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='event_details',
+ field=django_quill.fields.QuillField(default='No details available yet.'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='event_image',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[utils.fields.validate_image_format, utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='partnerlogo',
+ name='partner_logo',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[utils.fields.validate_image_format, utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='resource',
+ name='resource',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[utils.fields.validate_image_format, utils.fields.validate_image_format]),
+ ),
+ ]
diff --git a/src/website/apps/event/migrations/0016_alter_event_background_image_alter_event_event_image_and_more.py b/src/website/apps/event/migrations/0016_alter_event_background_image_alter_event_event_image_and_more.py
new file mode 100644
index 0000000000..71ea28a7af
--- /dev/null
+++ b/src/website/apps/event/migrations/0016_alter_event_background_image_alter_event_event_image_and_more.py
@@ -0,0 +1,35 @@
+# Generated by Django 5.1.4 on 2024-12-06 13:30
+
+import django.core.validators
+import utils.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('event', '0015_alter_event_background_image_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='event',
+ name='background_image',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format, django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='event_image',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format, django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='partnerlogo',
+ name='partner_logo',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format, django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='resource',
+ name='resource',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format]),
+ ),
+ ]
diff --git a/src/website/apps/event/migrations/0017_alter_event_background_image_alter_event_event_image_and_more.py b/src/website/apps/event/migrations/0017_alter_event_background_image_alter_event_event_image_and_more.py
new file mode 100644
index 0000000000..e1795ae8f7
--- /dev/null
+++ b/src/website/apps/event/migrations/0017_alter_event_background_image_alter_event_event_image_and_more.py
@@ -0,0 +1,35 @@
+# Generated by Django 5.1.4 on 2024-12-06 13:39
+
+import django.core.validators
+import utils.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('event', '0016_alter_event_background_image_alter_event_event_image_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='event',
+ name='background_image',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format, django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='event_image',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format, django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='partnerlogo',
+ name='partner_logo',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format, django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format]),
+ ),
+ migrations.AlterField(
+ model_name='resource',
+ name='resource',
+ field=utils.fields.CustomCloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tiff']), utils.fields.validate_image_format]),
+ ),
+ ]
diff --git a/src/website/apps/event/migrations/0018_alter_event_background_image_alter_event_event_image_and_more.py b/src/website/apps/event/migrations/0018_alter_event_background_image_alter_event_event_image_and_more.py
new file mode 100644
index 0000000000..aa38da8a2a
--- /dev/null
+++ b/src/website/apps/event/migrations/0018_alter_event_background_image_alter_event_event_image_and_more.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.1.4 on 2024-12-06 13:49
+
+import cloudinary.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('event', '0017_alter_event_background_image_alter_event_event_image_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='event',
+ name='background_image',
+ field=cloudinary.models.CloudinaryField(blank=True, default=None, max_length=255, null=True, verbose_name='image'),
+ ),
+ migrations.AlterField(
+ model_name='event',
+ name='event_image',
+ field=cloudinary.models.CloudinaryField(blank=True, default=None, max_length=255, null=True, verbose_name='image'),
+ ),
+ migrations.AlterField(
+ model_name='partnerlogo',
+ name='partner_logo',
+ field=cloudinary.models.CloudinaryField(blank=True, default=None, max_length=255, null=True, verbose_name='image'),
+ ),
+ migrations.AlterField(
+ model_name='resource',
+ name='resource',
+ field=cloudinary.models.CloudinaryField(blank=True, default=None, max_length=255, null=True, verbose_name='file'),
+ ),
+ ]
diff --git a/src/website/apps/event/models.py b/src/website/apps/event/models.py
index 51787b4692..a93214cd31 100644
--- a/src/website/apps/event/models.py
+++ b/src/website/apps/event/models.py
@@ -1,17 +1,18 @@
-# backend/apps/event/models.py
+
from django.db import models
-from utils.models import BaseModel
from django.contrib.auth import get_user_model
-from django.conf import settings
-from utils.fields import ConditionalImageField, ConditionalFileField
-from cloudinary.uploader import destroy
from django_quill.fields import QuillField
-import os
+from utils.models import BaseModel
+from cloudinary.models import CloudinaryField
+from cloudinary.uploader import destroy
+import logging
User = get_user_model()
+# Configure logger
+logger = logging.getLogger(__name__)
+
-# Event Model
class Event(BaseModel):
title = models.CharField(max_length=100)
title_subtext = models.CharField(max_length=90)
@@ -22,59 +23,64 @@ class Event(BaseModel):
registration_link = models.URLField(null=True, blank=True)
class WebsiteCategory(models.TextChoices):
- AirQo = "airqo", "AirQo"
- CleanAir = "cleanair", "CleanAir"
+ AIRQO = "airqo", "AirQo"
+ CLEAN_AIR = "cleanair", "CleanAir"
website_category = models.CharField(
max_length=40,
- default=WebsiteCategory.AirQo,
choices=WebsiteCategory.choices,
+ default=WebsiteCategory.AIRQO,
null=True,
blank=True,
)
class EventTag(models.TextChoices):
- Untagged = "none", "None"
- Featured = "featured", "Featured"
+ UNTAGGED = "none", "None"
+ FEATURED = "featured", "Featured"
event_tag = models.CharField(
max_length=40,
- default=EventTag.Untagged,
choices=EventTag.choices,
+ default=EventTag.UNTAGGED,
null=True,
blank=True,
)
class EventCategory(models.TextChoices):
- NoneCategory = "none", "None"
- Webinar = "webinar", "Webinar"
- Workshop = "workshop", "Workshop"
- Marathon = "marathon", "Marathon"
- Conference = "conference", "Conference"
- Summit = "summit", "Summit"
- Commemoration = "commemoration", "Commemoration"
- InPerson = "in-person", "In-person"
- Hybrid = "hybrid", "Hybrid"
+ NONE_CATEGORY = "none", "None"
+ WEBINAR = "webinar", "Webinar"
+ WORKSHOP = "workshop", "Workshop"
+ MARATHON = "marathon", "Marathon"
+ CONFERENCE = "conference", "Conference"
+ SUMMIT = "summit", "Summit"
+ COMMEMORATION = "commemoration", "Commemoration"
+ IN_PERSON = "in-person", "In-person"
+ HYBRID = "hybrid", "Hybrid"
event_category = models.CharField(
max_length=40,
- default=EventCategory.NoneCategory,
choices=EventCategory.choices,
+ default=EventCategory.NONE_CATEGORY,
null=True,
blank=True,
)
- event_image = ConditionalImageField(
- local_upload_to='events/images/',
- cloudinary_folder='website/uploads/events/images',
+ # Image fields using CloudinaryField
+ event_image = CloudinaryField(
+ 'image',
+ folder='website/uploads/events/images',
null=True,
- blank=True
+ blank=True,
+ default=None,
+ resource_type='image'
)
- background_image = ConditionalImageField(
- local_upload_to='events/images/',
- cloudinary_folder='website/uploads/events/images',
+ background_image = CloudinaryField(
+ 'image',
+ folder='website/uploads/events/images',
null=True,
- blank=True
+ blank=True,
+ default=None,
+ resource_type='image'
)
location_name = models.CharField(max_length=100, null=True, blank=True)
@@ -86,28 +92,29 @@ class Meta:
ordering = ["order", "-start_date"]
def __str__(self):
- return f"{self.title}"
+ return self.title
def delete(self, *args, **kwargs):
- # Delete files from storage for both Cloudinary and local storage
+ # Delete files from Cloudinary
if self.event_image:
- if not settings.DEBUG: # Delete from Cloudinary in production
+ try:
destroy(self.event_image.public_id)
- else: # Delete from local storage in development
- if os.path.isfile(self.event_image.path):
- os.remove(self.event_image.path)
-
+ logger.info(
+ f"Deleted event_image from Cloudinary: {self.event_image.public_id}")
+ except Exception as e:
+ logger.error(
+ f"Error deleting event_image from Cloudinary: {e}")
if self.background_image:
- if not settings.DEBUG:
+ try:
destroy(self.background_image.public_id)
- else:
- if os.path.isfile(self.background_image.path):
- os.remove(self.background_image.path)
-
+ logger.info(
+ f"Deleted background_image from Cloudinary: {self.background_image.public_id}")
+ except Exception as e:
+ logger.error(
+ f"Error deleting background_image from Cloudinary: {e}")
super().delete(*args, **kwargs)
-# Inquiry Model
class Inquiry(BaseModel):
inquiry = models.CharField(max_length=80)
role = models.CharField(max_length=100, null=True, blank=True)
@@ -128,10 +135,9 @@ def __str__(self):
return f"Inquiry - {self.inquiry}"
-# Program Model
class Program(BaseModel):
date = models.DateField()
- program_details = QuillField(default="No details available yet.")
+ program_details = models.TextField(default="No details available yet.")
order = models.IntegerField(default=1)
event = models.ForeignKey(
Event,
@@ -148,13 +154,12 @@ def __str__(self):
return f"Program - {self.date}"
-# Session Model
class Session(BaseModel):
start_time = models.TimeField()
end_time = models.TimeField()
venue = models.CharField(max_length=80, null=True, blank=True)
session_title = models.CharField(max_length=150)
- session_details = QuillField(default="No details available yet.")
+ session_details = models.TextField(default="No details available yet.")
order = models.IntegerField(default=1)
program = models.ForeignKey(
Program,
@@ -171,13 +176,14 @@ def __str__(self):
return f"Session - {self.session_title}"
-# PartnerLogo Model
class PartnerLogo(BaseModel):
- partner_logo = ConditionalImageField(
- local_upload_to='events/logos/',
- cloudinary_folder='website/uploads/events/logos',
+ partner_logo = CloudinaryField(
+ 'image',
+ folder='website/uploads/events/logos',
null=True,
- blank=True
+ blank=True,
+ default=None,
+ resource_type='image'
)
name = models.CharField(max_length=70)
order = models.IntegerField(default=1)
@@ -197,23 +203,26 @@ def __str__(self):
def delete(self, *args, **kwargs):
if self.partner_logo:
- if not settings.DEBUG:
+ try:
destroy(self.partner_logo.public_id)
- else:
- if os.path.isfile(self.partner_logo.path):
- os.remove(self.partner_logo.path)
+ logger.info(
+ f"Deleted partner_logo from Cloudinary: {self.partner_logo.public_id}")
+ except Exception as e:
+ logger.error(
+ f"Error deleting partner_logo from Cloudinary: {e}")
super().delete(*args, **kwargs)
-# Resource Model
class Resource(BaseModel):
title = models.CharField(max_length=100)
link = models.URLField(null=True, blank=True)
- resource = ConditionalFileField(
- local_upload_to='publications/files/',
- cloudinary_folder='website/uploads/events/files',
+ resource = CloudinaryField(
+ 'file',
+ folder='website/uploads/events/files',
null=True,
- blank=True
+ blank=True,
+ default=None,
+ resource_type='raw'
)
order = models.IntegerField(default=1)
event = models.ForeignKey(
@@ -232,9 +241,11 @@ def __str__(self):
def delete(self, *args, **kwargs):
if self.resource:
- if not settings.DEBUG:
+ try:
destroy(self.resource.public_id)
- else:
- if os.path.isfile(self.resource.path):
- os.remove(self.resource.path)
+ logger.info(
+ f"Deleted resource from Cloudinary: {self.resource.public_id}")
+ except Exception as e:
+ logger.error(
+ f"Error deleting resource from Cloudinary: {e}")
super().delete(*args, **kwargs)
diff --git a/src/website/apps/event/serializers.py b/src/website/apps/event/serializers.py
index c245b93cb2..6599b66f2c 100644
--- a/src/website/apps/event/serializers.py
+++ b/src/website/apps/event/serializers.py
@@ -1,9 +1,6 @@
-# backend/apps/event/serializers.py
from rest_framework import serializers
from .models import Event, Inquiry, Program, Session, PartnerLogo, Resource
-from cloudinary.utils import cloudinary_url
-from django.conf import settings
class PartnerLogoSerializer(serializers.ModelSerializer):
@@ -15,10 +12,7 @@ class Meta:
def get_partner_logo_url(self, obj):
if obj.partner_logo:
- if settings.DEBUG:
- return self.context['request'].build_absolute_uri(obj.partner_logo.url)
- else:
- return cloudinary_url(obj.partner_logo.public_id, secure=True)[0]
+ return obj.partner_logo.url # Directly use the .url attribute
return None
@@ -31,10 +25,7 @@ class Meta:
def get_resource_url(self, obj):
if obj.resource:
- if settings.DEBUG:
- return self.context['request'].build_absolute_uri(obj.resource.url)
- else:
- return cloudinary_url(obj.resource.public_id, secure=True)[0]
+ return obj.resource.url # Directly use the .url attribute
return None
@@ -46,7 +37,7 @@ class Meta:
'start_time',
'end_time',
'venue',
- 'session_details', # Keep session_details as QuillField
+ 'session_details', # Now TextField
'order'
]
ref_name = 'EventSessionSerializer'
@@ -59,7 +50,7 @@ class Meta:
model = Program
fields = [
'date',
- 'program_details', # Keep program_details as QuillField
+ 'program_details', # Now TextField
'order',
'sessions'
]
@@ -92,10 +83,7 @@ class Meta:
def get_event_image_url(self, obj):
if obj.event_image:
- if settings.DEBUG:
- return self.context['request'].build_absolute_uri(obj.event_image.url)
- else:
- return cloudinary_url(obj.event_image.public_id, secure=True)[0]
+ return obj.event_image.url # Directly use the .url attribute
return None
@@ -126,7 +114,7 @@ class Meta:
'background_image_url',
'location_name',
'location_link',
- 'event_details', # Keep event_details as QuillField
+ 'event_details', # Now TextField
'order',
'inquiries',
'programs',
@@ -136,16 +124,10 @@ class Meta:
def get_event_image_url(self, obj):
if obj.event_image:
- if settings.DEBUG:
- return self.context['request'].build_absolute_uri(obj.event_image.url)
- else:
- return cloudinary_url(obj.event_image.public_id, secure=True)[0]
+ return obj.event_image.url # Directly use the .url attribute
return None
def get_background_image_url(self, obj):
if obj.background_image:
- if settings.DEBUG:
- return self.context['request'].build_absolute_uri(obj.background_image.url)
- else:
- return cloudinary_url(obj.background_image.public_id, secure=True)[0]
+ return obj.background_image.url # Directly use the .url attribute
return None
diff --git a/src/website/apps/event/urls.py b/src/website/apps/event/urls.py
index 336a81a956..0ced194601 100644
--- a/src/website/apps/event/urls.py
+++ b/src/website/apps/event/urls.py
@@ -1,4 +1,4 @@
-# backend/apps/event/urls.py
+
from django.urls import path, include
from rest_framework.routers import DefaultRouter
diff --git a/src/website/apps/externalteams/admin.py b/src/website/apps/externalteams/admin.py
index 9c1e4f69cd..f2402bdd7b 100644
--- a/src/website/apps/externalteams/admin.py
+++ b/src/website/apps/externalteams/admin.py
@@ -19,15 +19,25 @@ class ExternalTeamMemberAdmin(admin.ModelAdmin):
inlines = [ExternalTeamMemberBiographyInline]
def image_preview(self, obj):
+ """
+ Display a small thumbnail of the team member's picture.
+ """
if obj.picture:
- return format_html(f'')
- return ""
+ return format_html(
+ f''
+ )
+ return "-"
image_preview.short_description = "Picture Preview"
def image_preview_detail(self, obj):
+ """
+ Display a larger preview of the team member's picture.
+ """
if obj.picture:
- return format_html(f'')
- return ""
+ return format_html(
+ f''
+ )
+ return "-"
image_preview_detail.short_description = "Picture Preview"
readonly_fields = ['image_preview_detail']
diff --git a/src/website/apps/externalteams/migrations/0007_alter_externalteammember_picture.py b/src/website/apps/externalteams/migrations/0007_alter_externalteammember_picture.py
new file mode 100644
index 0000000000..2f4298a817
--- /dev/null
+++ b/src/website/apps/externalteams/migrations/0007_alter_externalteammember_picture.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.1.4 on 2024-12-06 16:44
+
+import cloudinary.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('externalteams', '0006_alter_externalteammember_picture'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='externalteammember',
+ name='picture',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True),
+ ),
+ ]
diff --git a/src/website/apps/externalteams/models.py b/src/website/apps/externalteams/models.py
index 37c88b6e0f..2558861f91 100644
--- a/src/website/apps/externalteams/models.py
+++ b/src/website/apps/externalteams/models.py
@@ -1,5 +1,5 @@
from django.db import models
-from utils.fields import ConditionalImageField
+from cloudinary.models import CloudinaryField
from utils.models import BaseModel
@@ -7,11 +7,11 @@ class ExternalTeamMember(BaseModel):
name = models.CharField(max_length=100)
title = models.CharField(max_length=120)
- picture = ConditionalImageField(
- local_upload_to='external_team/',
- cloudinary_folder='website/uploads/team/externalTeam',
+ picture = CloudinaryField(
+ folder='website/uploads/team/externalTeam',
null=True,
- blank=True
+ blank=True,
+ resource_type='image'
)
twitter = models.URLField(max_length=255, null=True, blank=True)
@@ -25,8 +25,11 @@ def __str__(self):
return self.name
def get_picture_url(self):
+ """
+ Return the secure URL for the image.
+ """
if self.picture:
- return self.picture.url # This handles both local and Cloudinary URLs
+ return self.picture.url # Cloudinary already provides secure URLs
return None
diff --git a/src/website/apps/externalteams/serializers.py b/src/website/apps/externalteams/serializers.py
index 267835e063..a6d999c6b2 100644
--- a/src/website/apps/externalteams/serializers.py
+++ b/src/website/apps/externalteams/serializers.py
@@ -15,8 +15,13 @@ class ExternalTeamMemberSerializer(serializers.ModelSerializer):
class Meta:
model = ExternalTeamMember
- fields = ['id', 'name', 'title', 'picture_url',
- 'twitter', 'linked_in', 'order', 'descriptions']
+ fields = [
+ 'id', 'name', 'title', 'picture_url',
+ 'twitter', 'linked_in', 'order', 'descriptions'
+ ]
def get_picture_url(self, obj):
- return obj.get_picture_url() # Handles secure URL or local URL based on the environment
+ """
+ Return the secure URL for the picture.
+ """
+ return obj.get_picture_url() # Already handled in the model
diff --git a/src/website/apps/highlights/admin.py b/src/website/apps/highlights/admin.py
index c5a4566a2a..55794912ea 100644
--- a/src/website/apps/highlights/admin.py
+++ b/src/website/apps/highlights/admin.py
@@ -1,37 +1,41 @@
from django.contrib import admin
-from .models import Tag, Highlight
+from .models import Tag, Highlight # Correctly import the Tag model
from django.utils.html import format_html
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
- # Display the Tag ID and name in the admin list view
+ """
+ Admin configuration for Tag model.
+ """
list_display = ('id', 'name')
search_fields = ('name',)
@admin.register(Highlight)
class HighlightAdmin(admin.ModelAdmin):
- # Display relevant fields in the admin list view
+ """
+ Admin configuration for Highlight model.
+ """
list_display = ('title', 'display_tags', 'order', 'image_preview')
-
list_filter = ('order', 'tags')
-
search_fields = ('title', 'link', 'link_title')
filter_horizontal = ('tags',)
list_editable = ('order',)
list_per_page = 10
-
- # Include all fields, including 'tags', in the form view
fields = ('title', 'tags', 'image', 'link', 'link_title', 'order')
def display_tags(self, obj):
- """Display tags in a comma-separated format."""
+ """
+ Display tags in a comma-separated format.
+ """
return ", ".join([tag.name for tag in obj.tags.all()])
display_tags.short_description = 'Tags'
def image_preview(self, obj):
- """Show a preview of the image in the admin list view."""
+ """
+ Show a preview of the image in the admin list view.
+ """
if obj.image:
return format_html(
'',
diff --git a/src/website/apps/highlights/migrations/0011_alter_highlight_image.py b/src/website/apps/highlights/migrations/0011_alter_highlight_image.py
new file mode 100644
index 0000000000..680c7c0dd4
--- /dev/null
+++ b/src/website/apps/highlights/migrations/0011_alter_highlight_image.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.1.4 on 2024-12-06 16:57
+
+import cloudinary.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('highlights', '0010_alter_highlight_image'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='highlight',
+ name='image',
+ field=cloudinary.models.CloudinaryField(blank=True, default='website/uploads/default_image.webp', max_length=255, null=True),
+ ),
+ ]
diff --git a/src/website/apps/highlights/models.py b/src/website/apps/highlights/models.py
index 6a46a4e881..8bd8131301 100644
--- a/src/website/apps/highlights/models.py
+++ b/src/website/apps/highlights/models.py
@@ -1,9 +1,6 @@
from django.db import models
-from django.conf import settings
+from cloudinary.models import CloudinaryField
from utils.models import BaseModel
-from utils.fields import ConditionalImageField
-
-# Tag Model
class Tag(BaseModel):
@@ -13,18 +10,16 @@ def __str__(self):
return self.name
-# Highlight Model
class Highlight(BaseModel):
title = models.CharField(max_length=110)
- tags = models.ManyToManyField(Tag, related_name='highlights')
+ # String-based reference to Tag
+ tags = models.ManyToManyField("Tag", related_name='highlights')
- # Use ConditionalImageField for handling local or Cloudinary image storage
- image = ConditionalImageField(
- local_upload_to='highlights/images/',
- cloudinary_folder='website/uploads/highlights/images',
- default='website/uploads/default_image.webp',
+ image = CloudinaryField(
+ folder='website/uploads/highlights/images',
null=True,
- blank=True
+ blank=True,
+ resource_type='image'
)
link = models.URLField()
@@ -39,8 +34,7 @@ def __str__(self):
def delete(self, *args, **kwargs):
"""
- Automatically delete the file from Cloudinary or local storage
- when the highlight is deleted.
+ Automatically delete the image from Cloudinary when the highlight is deleted.
"""
if self.image:
self.image.delete(save=False)
diff --git a/src/website/apps/highlights/serializers.py b/src/website/apps/highlights/serializers.py
index f6148b71fb..00456ac9cd 100644
--- a/src/website/apps/highlights/serializers.py
+++ b/src/website/apps/highlights/serializers.py
@@ -1,6 +1,5 @@
from rest_framework import serializers
from .models import Tag, Highlight
-from django.conf import settings
class TagSerializer(serializers.ModelSerializer):
@@ -33,12 +32,9 @@ class Meta:
]
def get_image_url(self, obj):
+ """
+ Return the secure URL for the image.
+ """
if obj.image:
- request = self.context.get('request')
- if settings.DEBUG and request:
- # Return absolute URL in local development
- return request.build_absolute_uri(obj.image.url)
- else:
- # Return secure Cloudinary URL in production
- return obj.image.url
+ return obj.image.url # CloudinaryField provides secure URLs
return None
diff --git a/src/website/apps/partners/admin.py b/src/website/apps/partners/admin.py
index b41b1e9e33..6851d0f618 100644
--- a/src/website/apps/partners/admin.py
+++ b/src/website/apps/partners/admin.py
@@ -1,48 +1,63 @@
from django.contrib import admin
import nested_admin
from .models import Partner, PartnerDescription
+from django.utils.html import format_html, escape
class PartnerDescriptionInline(nested_admin.NestedTabularInline):
- fields = ('description', 'order')
+ fields = ("description", "order")
model = PartnerDescription
extra = 0
@admin.register(Partner)
class PartnerAdmin(nested_admin.NestedModelAdmin):
- list_display = ('partner_name', 'website_category', 'type_display', 'logo_preview', 'image_preview')
- list_filter = ('website_category', 'type',)
+ list_display = (
+ "partner_name",
+ "website_category",
+ "type_display",
+ "logo_preview",
+ "image_preview",
+ )
+ list_filter = ("website_category", "type")
fields = (
- 'partner_name',
- 'website_category',
- 'type',
- 'partner_logo',
- 'partner_image',
- 'partner_link',
- 'order'
+ "partner_name",
+ "website_category",
+ "type",
+ "partner_logo",
+ "partner_image",
+ "partner_link",
+ "order",
)
list_per_page = 10
- search_fields = ('partner_name', 'type')
+ search_fields = ("partner_name", "type")
inlines = (PartnerDescriptionInline,)
def type_display(self, obj):
return obj.get_type_display()
def logo_preview(self, obj):
- width, height = 65, 50
- from django.utils.html import escape, format_html
+ """
+ Display a preview of the partner logo.
+ """
if obj.partner_logo:
- return format_html(f'')
+ return format_html(
+ '',
+ escape(obj.partner_logo.url),
+ )
return "No Logo"
logo_preview.short_description = "Logo"
def image_preview(self, obj):
- width, height = 120, 80
- from django.utils.html import escape, format_html
+ """
+ Display a preview of the partner image.
+ """
if obj.partner_image:
- return format_html(f'')
+ return format_html(
+ '',
+ escape(obj.partner_image.url),
+ )
return "No Image"
image_preview.short_description = "Image"
diff --git a/src/website/apps/partners/migrations/0007_alter_partner_partner_image_and_more.py b/src/website/apps/partners/migrations/0007_alter_partner_partner_image_and_more.py
new file mode 100644
index 0000000000..ca6e015aac
--- /dev/null
+++ b/src/website/apps/partners/migrations/0007_alter_partner_partner_image_and_more.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.1.4 on 2024-12-06 17:06
+
+import cloudinary.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('partners', '0006_alter_partner_partner_image_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='partner',
+ name='partner_image',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AlterField(
+ model_name='partner',
+ name='partner_logo',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True),
+ ),
+ ]
diff --git a/src/website/apps/partners/models.py b/src/website/apps/partners/models.py
index fdaaaf6ea7..38764ff7cc 100644
--- a/src/website/apps/partners/models.py
+++ b/src/website/apps/partners/models.py
@@ -1,5 +1,5 @@
from django.db import models
-from utils.fields import ConditionalImageField
+from cloudinary.models import CloudinaryField
from utils.models import BaseModel
@@ -15,15 +15,17 @@ class RelationTypes(models.TextChoices):
FORUM = "ca-forum", "Clean air Policy Forum"
PRIVATE = "ca-private-sector", "Clean air Private Sector"
- partner_image = ConditionalImageField(
- local_upload_to='partners/images/',
- cloudinary_folder='website/uploads/partners/images',
- null=True, blank=True
+ partner_image = CloudinaryField(
+ folder="website/uploads/partners/images",
+ resource_type="image",
+ null=True,
+ blank=True,
)
- partner_logo = ConditionalImageField(
- local_upload_to='partners/logos/',
- cloudinary_folder='website/uploads/partners/logos',
- null=True, blank=True
+ partner_logo = CloudinaryField(
+ folder="website/uploads/partners/logos",
+ resource_type="image",
+ null=True,
+ blank=True,
)
partner_name = models.CharField(max_length=200)
order = models.IntegerField(default=1)
@@ -32,7 +34,8 @@ class RelationTypes(models.TextChoices):
max_length=40,
choices=RelationTypes.choices,
default=RelationTypes.PARTNERSHIP,
- null=True, blank=True
+ null=True,
+ blank=True,
)
class WebsiteCategory(models.TextChoices):
@@ -43,11 +46,12 @@ class WebsiteCategory(models.TextChoices):
max_length=40,
choices=WebsiteCategory.choices,
default=WebsiteCategory.AIRQO,
- null=True, blank=True
+ null=True,
+ blank=True,
)
class Meta:
- ordering = ['order', 'id']
+ ordering = ["order", "id"]
def __str__(self):
return f"Partner - {self.partner_name}"
@@ -65,7 +69,7 @@ class PartnerDescription(BaseModel):
)
class Meta:
- ordering = ['order', 'id']
+ ordering = ["order", "id"]
def __str__(self):
return f"Description {self.id}"
diff --git a/src/website/apps/partners/serializers.py b/src/website/apps/partners/serializers.py
index 7acb03165f..454c750ab6 100644
--- a/src/website/apps/partners/serializers.py
+++ b/src/website/apps/partners/serializers.py
@@ -1,6 +1,5 @@
from rest_framework import serializers
from .models import Partner, PartnerDescription
-from django.conf import settings # For checking the environment
class PartnerDescriptionSerializer(serializers.ModelSerializer):
@@ -17,35 +16,29 @@ class PartnerSerializer(serializers.ModelSerializer):
class Meta:
model = Partner
fields = [
- 'id',
- 'partner_name',
- 'partner_link',
- 'website_category',
- 'type',
- 'partner_logo_url',
- 'partner_image_url',
- 'descriptions',
- 'order'
+ "id",
+ "partner_name",
+ "partner_link",
+ "website_category",
+ "type",
+ "partner_logo_url",
+ "partner_image_url",
+ "descriptions",
+ "order",
]
def get_partner_logo_url(self, obj):
+ """
+ Return the secure or local URL for the partner logo.
+ """
if obj.partner_logo:
- if settings.DEBUG:
- # Return the full URL in development
- request = self.context.get('request')
- return request.build_absolute_uri(obj.partner_logo.url) if request else obj.partner_logo.url
- else:
- # Return Cloudinary URL in production
- return obj.partner_logo.url # Cloudinary already returns a secure URL
+ return obj.partner_logo.url
return None
def get_partner_image_url(self, obj):
+ """
+ Return the secure or local URL for the partner image.
+ """
if obj.partner_image:
- if settings.DEBUG:
- # Return the full URL in development
- request = self.context.get('request')
- return request.build_absolute_uri(obj.partner_image.url) if request else obj.partner_image.url
- else:
- # Return Cloudinary URL in production
- return obj.partner_image.url # Cloudinary already returns a secure URL
+ return obj.partner_image.url
return None
diff --git a/src/website/apps/press/admin.py b/src/website/apps/press/admin.py
index 0a1bee42bc..96cf73cf5f 100644
--- a/src/website/apps/press/admin.py
+++ b/src/website/apps/press/admin.py
@@ -6,30 +6,56 @@
@admin.register(Press)
class PressAdmin(admin.ModelAdmin):
- list_display = ('article_title', 'date_published', 'website_category', 'image_preview', 'order')
- list_filter = ('website_category', 'date_published')
- search_fields = ('article_title', 'article_intro', 'article_link')
- ordering = ('order', '-date_published')
- list_editable = ('order',)
- readonly_fields = ('image_preview',)
+ list_display = (
+ "article_title",
+ "date_published",
+ "website_category",
+ "image_preview",
+ "order",
+ )
+ list_filter = ("website_category", "date_published")
+ search_fields = ("article_title", "article_intro", "article_link")
+ ordering = ("order", "-date_published")
+ list_editable = ("order",)
+ readonly_fields = ("image_preview",)
fieldsets = (
- (None, {
- 'fields': ('article_title', 'article_intro', 'article_link', 'date_published', 'publisher_logo', 'website_category', 'order', 'image_preview')
- }),
+ (
+ None,
+ {
+ "fields": (
+ "article_title",
+ "article_intro",
+ "article_link",
+ "date_published",
+ "publisher_logo",
+ "website_category",
+ "order",
+ "image_preview",
+ )
+ },
+ ),
)
def image_preview(self, obj):
+ """
+ Display the publisher logo as a thumbnail in the admin interface.
+ """
if obj.publisher_logo:
- return format_html('', obj.publisher_logo.url)
+ return format_html(
+ '',
+ obj.publisher_logo.url,
+ )
return "No image available"
- image_preview.short_description = 'Image Preview'
+ image_preview.short_description = "Image Preview"
def delete_queryset(self, request, queryset):
+ """
+ Handle bulk deletion of objects, ensuring that associated Cloudinary files are removed.
+ """
for obj in queryset:
if obj.publisher_logo:
- public_id = obj.publisher_logo.public_id
- if public_id:
- cloudinary.uploader.destroy(public_id)
+ cloudinary.uploader.destroy(
+ obj.publisher_logo.public_id, invalidate=True)
obj.delete()
diff --git a/src/website/apps/press/migrations/0011_alter_press_publisher_logo.py b/src/website/apps/press/migrations/0011_alter_press_publisher_logo.py
new file mode 100644
index 0000000000..9830f08820
--- /dev/null
+++ b/src/website/apps/press/migrations/0011_alter_press_publisher_logo.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.1.4 on 2024-12-06 17:13
+
+import cloudinary.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('press', '0010_alter_press_publisher_logo'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='press',
+ name='publisher_logo',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True),
+ ),
+ ]
diff --git a/src/website/apps/press/models.py b/src/website/apps/press/models.py
index fc15aac2d6..38fb3be71c 100644
--- a/src/website/apps/press/models.py
+++ b/src/website/apps/press/models.py
@@ -1,8 +1,7 @@
import cloudinary
-from django.conf import settings
+from cloudinary.models import CloudinaryField
from django.db import models
from utils.models import BaseModel
-from utils.fields import ConditionalImageField
class Press(BaseModel):
@@ -11,9 +10,9 @@ class Press(BaseModel):
article_link = models.URLField(null=True, blank=True)
date_published = models.DateField()
- publisher_logo = ConditionalImageField(
- local_upload_to='press/logos/',
- cloudinary_folder='website/uploads/press/logos',
+ publisher_logo = CloudinaryField(
+ folder="website/uploads/press/logos",
+ resource_type="image",
null=True,
blank=True
)
@@ -45,21 +44,18 @@ class ArticleTag(models.TextChoices):
)
class Meta:
- ordering = ['order', '-id']
- verbose_name = 'Press Article'
- verbose_name_plural = 'Press Articles'
+ ordering = ["order", "-id"]
+ verbose_name = "Press Article"
+ verbose_name_plural = "Press Articles"
def __str__(self):
return self.article_title
def delete(self, *args, **kwargs):
"""
- Override the delete method to remove the associated Cloudinary file or local file
- before deleting the Press article instance.
+ Override the delete method to remove the associated Cloudinary file before deleting the instance.
"""
if self.publisher_logo:
- public_id = self.publisher_logo.public_id
- if public_id:
- cloudinary.uploader.destroy(public_id)
-
+ cloudinary.uploader.destroy(
+ self.publisher_logo.public_id, invalidate=True)
super().delete(*args, **kwargs)
diff --git a/src/website/apps/press/serializers.py b/src/website/apps/press/serializers.py
index 9d11e552d9..65cf71668b 100644
--- a/src/website/apps/press/serializers.py
+++ b/src/website/apps/press/serializers.py
@@ -1,6 +1,6 @@
from rest_framework import serializers
from .models import Press
-from django.conf import settings # To check the environment
+
class PressSerializer(serializers.ModelSerializer):
publisher_logo_url = serializers.SerializerMethodField()
@@ -8,27 +8,22 @@ class PressSerializer(serializers.ModelSerializer):
class Meta:
model = Press
fields = [
- 'id',
- 'article_title',
- 'article_intro',
- 'article_link',
- 'date_published',
- 'publisher_logo',
- 'publisher_logo_url', # Include the generated logo URL
- 'website_category',
- 'article_tag',
- 'order',
+ "id",
+ "article_title",
+ "article_intro",
+ "article_link",
+ "date_published",
+ "publisher_logo",
+ "publisher_logo_url",
+ "website_category",
+ "article_tag",
+ "order",
]
def get_publisher_logo_url(self, obj):
"""
- Return the full URL for the publisher_logo, depending on the environment.
+ Return the URL of the publisher logo.
"""
if obj.publisher_logo:
- request = self.context.get('request')
- if settings.DEBUG: # In development mode, serve files locally
- if request is not None:
- return request.build_absolute_uri(obj.publisher_logo.url)
- else: # In production mode, use the secure Cloudinary URL
- return obj.publisher_logo.url # This will return the Cloudinary URL
+ return obj.publisher_logo.url
return None
diff --git a/src/website/apps/publications/admin.py b/src/website/apps/publications/admin.py
index 23696776d2..3bf1c2a591 100644
--- a/src/website/apps/publications/admin.py
+++ b/src/website/apps/publications/admin.py
@@ -1,11 +1,12 @@
from django.contrib import admin
from .models import Publication
+
@admin.register(Publication)
class PublicationAdmin(admin.ModelAdmin):
- list_display = ['title', 'category', 'order']
- search_fields = ['title', 'category']
- list_filter = ['category']
- list_editable = ['order']
- ordering = ['order', '-id']
+ list_display = ["title", "category", "order"]
+ search_fields = ["title", "category"]
+ list_filter = ["category"]
+ list_editable = ["order"]
+ ordering = ["order", "-id"]
list_per_page = 10
diff --git a/src/website/apps/publications/migrations/0011_alter_publication_resource_file.py b/src/website/apps/publications/migrations/0011_alter_publication_resource_file.py
new file mode 100644
index 0000000000..ed7d0adaef
--- /dev/null
+++ b/src/website/apps/publications/migrations/0011_alter_publication_resource_file.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.1.4 on 2024-12-06 17:27
+
+import cloudinary.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('publications', '0010_alter_publication_resource_file'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='publication',
+ name='resource_file',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True),
+ ),
+ ]
diff --git a/src/website/apps/publications/models.py b/src/website/apps/publications/models.py
index e343502067..2671e26280 100644
--- a/src/website/apps/publications/models.py
+++ b/src/website/apps/publications/models.py
@@ -1,54 +1,49 @@
+from cloudinary.models import CloudinaryField
from django.db import models
from utils.models import BaseModel
-from utils.fields import ConditionalFileField
-from django.db.models.signals import post_delete
-from django.dispatch import receiver
+import cloudinary
class Publication(BaseModel):
class CategoryTypes(models.TextChoices):
- Research = "research", "Research"
- Technical = "technical", "Technical"
- Policy = "policy", "Policy"
- Guide = "guide", "Guide"
- Manual = "manual", "Manual"
+ RESEARCH = "research", "Research"
+ TECHNICAL = "technical", "Technical"
+ POLICY = "policy", "Policy"
+ GUIDE = "guide", "Guide"
+ MANUAL = "manual", "Manual"
title = models.CharField(max_length=255)
authors = models.TextField(null=True, blank=True)
link = models.URLField(null=True, blank=True)
-
- # Using ConditionalFileField to manage both local and Cloudinary file storage
- resource_file = ConditionalFileField(
- local_upload_to='publications/files/',
- cloudinary_folder='website/uploads/publications/files',
+ resource_file = CloudinaryField(
+ folder="website/uploads/publications/files",
+ resource_type="raw",
null=True,
- blank=True
+ blank=True,
)
-
link_title = models.CharField(
- max_length=100, default="Read More", null=True, blank=True)
+ max_length=100, default="Read More", null=True, blank=True
+ )
category = models.CharField(
max_length=40,
- default=CategoryTypes.Research,
+ default=CategoryTypes.RESEARCH,
choices=CategoryTypes.choices,
null=True,
- blank=True
+ blank=True,
)
order = models.IntegerField(default=1)
class Meta:
- ordering = ['order', '-id']
+ ordering = ["order", "-id"]
def __str__(self):
return self.title
-
-# Signal to delete the file when a Publication instance is deleted
-@receiver(post_delete, sender=Publication)
-def delete_resource_file(sender, instance, **kwargs):
- """
- Signal that ensures the file is deleted both locally and from Cloudinary
- when a Publication instance is deleted.
- """
- if instance.resource_file:
- instance.resource_file.delete(save=False)
+ def delete(self, *args, **kwargs):
+ """
+ Override the delete method to remove the associated Cloudinary file before deletion.
+ """
+ if self.resource_file:
+ cloudinary.uploader.destroy(
+ self.resource_file.public_id, invalidate=True)
+ super().delete(*args, **kwargs)
diff --git a/src/website/apps/publications/serializers.py b/src/website/apps/publications/serializers.py
index ea6ed05700..aa5b37e9dd 100644
--- a/src/website/apps/publications/serializers.py
+++ b/src/website/apps/publications/serializers.py
@@ -1,6 +1,5 @@
from rest_framework import serializers
from .models import Publication
-from django.conf import settings
class PublicationSerializer(serializers.ModelSerializer):
@@ -8,22 +7,21 @@ class PublicationSerializer(serializers.ModelSerializer):
class Meta:
model = Publication
- fields = ['id', 'title', 'authors', 'link',
- 'resource_file_url', 'link_title', 'category', 'order']
+ fields = [
+ "id",
+ "title",
+ "authors",
+ "link",
+ "resource_file_url",
+ "link_title",
+ "category",
+ "order",
+ ]
def get_resource_file_url(self, obj):
"""
- Handle the file URL depending on the environment.
- - Serve files locally during development.
- - Return secure Cloudinary URL in production.
+ Handle the file URL for the resource_file field.
"""
if obj.resource_file:
- if settings.DEBUG:
- # In development, serve files from local storage
- request = self.context.get('request')
- if request is not None:
- return request.build_absolute_uri(obj.resource_file.url)
- else:
- # In production, return the Cloudinary secure URL
- return obj.resource_file.url
+ return obj.resource_file.url
return None
diff --git a/src/website/apps/team/admin.py b/src/website/apps/team/admin.py
index 25a698c64d..7ffcda4eac 100644
--- a/src/website/apps/team/admin.py
+++ b/src/website/apps/team/admin.py
@@ -1,6 +1,7 @@
from django.contrib import admin
from .models import Member, MemberBiography
import nested_admin
+from django.utils.html import format_html
class MemberBiographyInline(nested_admin.NestedTabularInline):
@@ -12,9 +13,7 @@ class MemberBiographyInline(nested_admin.NestedTabularInline):
@admin.register(Member)
class MemberAdmin(nested_admin.NestedModelAdmin):
list_display = ("name", "title", "image_tag")
- readonly_fields = (
- "image_tag",
- )
+ readonly_fields = ("image_tag",)
fields = (
"name",
"title",
@@ -30,12 +29,10 @@ class MemberAdmin(nested_admin.NestedModelAdmin):
inlines = (MemberBiographyInline,)
def image_tag(self, obj):
- width, height = 100, 200
- from django.utils.html import escape, format_html
-
if obj.picture:
return format_html(
- f''
+ '',
+ obj.get_picture_url()
)
return "No Image"
diff --git a/src/website/apps/team/migrations/0007_alter_member_picture.py b/src/website/apps/team/migrations/0007_alter_member_picture.py
new file mode 100644
index 0000000000..9a9ae2bf97
--- /dev/null
+++ b/src/website/apps/team/migrations/0007_alter_member_picture.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.1.4 on 2024-12-06 17:35
+
+import cloudinary.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('team', '0006_alter_member_picture'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='member',
+ name='picture',
+ field=cloudinary.models.CloudinaryField(blank=True, max_length=255, null=True),
+ ),
+ ]
diff --git a/src/website/apps/team/models.py b/src/website/apps/team/models.py
index 95c28433ac..756e025e8f 100644
--- a/src/website/apps/team/models.py
+++ b/src/website/apps/team/models.py
@@ -1,5 +1,5 @@
from django.db import models
-from utils.fields import ConditionalImageField
+from cloudinary.models import CloudinaryField
from utils.models import BaseModel
@@ -8,9 +8,9 @@ class Member(BaseModel):
title = models.CharField(max_length=100)
about = models.TextField(blank=True)
- picture = ConditionalImageField(
- local_upload_to='members/pictures/',
- cloudinary_folder='website/uploads/team/members',
+ picture = CloudinaryField(
+ folder="website/uploads/team/members",
+ resource_type="image",
null=True,
blank=True
)
@@ -27,7 +27,7 @@ def __str__(self):
def get_picture_url(self):
if self.picture:
- return self.picture.url # This handles both local and Cloudinary URLs
+ return self.picture.url # CloudinaryField already handles secure URLs
return None
diff --git a/src/website/apps/team/serializers.py b/src/website/apps/team/serializers.py
index ea2e7e4aec..eae4d892ac 100644
--- a/src/website/apps/team/serializers.py
+++ b/src/website/apps/team/serializers.py
@@ -14,7 +14,20 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class Meta:
model = Member
- fields = '__all__'
+ fields = [
+ "id",
+ "name",
+ "title",
+ "about",
+ "picture_url",
+ "twitter",
+ "linked_in",
+ "order",
+ "descriptions",
+ ]
def get_picture_url(self, obj):
- return obj.get_picture_url() # Handles secure URL or local URL based on the environment
+ """
+ Get the secure URL for the member's picture.
+ """
+ return obj.get_picture_url()
diff --git a/src/website/assets/website/uploads/events/files/MARTIN_LEAVE_HANDOVER_REPORT_25th_Feb_-_5th_March_2024.pdf b/src/website/assets/website/uploads/events/files/MARTIN_LEAVE_HANDOVER_REPORT_25th_Feb_-_5th_March_2024.pdf
new file mode 100644
index 0000000000..77913e693c
Binary files /dev/null and b/src/website/assets/website/uploads/events/files/MARTIN_LEAVE_HANDOVER_REPORT_25th_Feb_-_5th_March_2024.pdf differ
diff --git a/src/website/assets/website/uploads/events/images/chuttersnap-aEnH4hJ_Mrs-unsplash.jpg b/src/website/assets/website/uploads/events/images/chuttersnap-aEnH4hJ_Mrs-unsplash.jpg
new file mode 100644
index 0000000000..3181d825d0
Binary files /dev/null and b/src/website/assets/website/uploads/events/images/chuttersnap-aEnH4hJ_Mrs-unsplash.jpg differ
diff --git a/src/website/assets/website/uploads/events/images/rachel-coyne-U7HLzMO4SIY-unsplash.jpg b/src/website/assets/website/uploads/events/images/rachel-coyne-U7HLzMO4SIY-unsplash.jpg
new file mode 100644
index 0000000000..d1176df5aa
Binary files /dev/null and b/src/website/assets/website/uploads/events/images/rachel-coyne-U7HLzMO4SIY-unsplash.jpg differ
diff --git a/src/website/assets/events/images/checkme_ce3jnHI.jpg b/src/website/assets/website/uploads/events/logos/checkme.jpg
similarity index 100%
rename from src/website/assets/events/images/checkme_ce3jnHI.jpg
rename to src/website/assets/website/uploads/events/logos/checkme.jpg
diff --git a/src/website/assets/website/uploads/events/logos/testimage.jpg b/src/website/assets/website/uploads/events/logos/testimage.jpg
new file mode 100644
index 0000000000..4033073af6
Binary files /dev/null and b/src/website/assets/website/uploads/events/logos/testimage.jpg differ
diff --git a/src/website/core/settings.py b/src/website/core/settings.py
index 986ddab0f6..149e8af3d7 100644
--- a/src/website/core/settings.py
+++ b/src/website/core/settings.py
@@ -1,41 +1,67 @@
+
import os
-from pathlib import Path
import sys
+from pathlib import Path
import dj_database_url
from dotenv import load_dotenv
-# Load environment variables from .env file
+# ---------------------------------------------------------
+# Load Environment Variables from .env
+# ---------------------------------------------------------
load_dotenv()
-# Build paths inside the project like this: BASE_DIR / 'subdir'.
+# ---------------------------------------------------------
+# Base Directory and Python Path Adjustments
+# ---------------------------------------------------------
BASE_DIR = Path(__file__).resolve().parent.parent
+sys.path.append(str(BASE_DIR / 'apps')) # Allow referencing apps directly
-# Add the apps directory to the Python path
-sys.path.append(str(BASE_DIR / 'apps'))
+# ---------------------------------------------------------
+# Helper Functions for Environment Variables
+# ---------------------------------------------------------
-def parse_env_list(env_var, default=""):
+def parse_env_list(env_var: str, default: str = "") -> list:
"""
- Parses a comma-separated string from an environment variable and trims whitespace.
+ Parse a comma-separated string from an environment variable into a list.
+ Trims whitespace and ignores empty entries.
"""
raw_value = os.getenv(env_var, default)
- return [v.strip() for v in raw_value.split(',') if v.strip()]
+ return [item.strip() for item in raw_value.split(',') if item.strip()]
+
+def get_env_bool(env_var: str, default: bool = False) -> bool:
+ """
+ Convert an environment variable to a boolean.
+ Accepts 'true', '1', 't' (case-insensitive) as True.
+ """
+ return os.getenv(env_var, str(default)).lower() in ['true', '1', 't']
-# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = os.getenv('SECRET_KEY')
-if not SECRET_KEY:
- raise ValueError("The SECRET_KEY environment variable is not set.")
-# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = os.getenv('DEBUG', 'False').lower() in ['true', '1', 't']
+def require_env_var(env_var: str) -> str:
+ """
+ Ensure an environment variable is set. Raise an error if not set.
+ """
+ value = os.getenv(env_var)
+ if not value:
+ raise ValueError(f"The {env_var} environment variable is not set.")
+ return value
+
+
+# ---------------------------------------------------------
+# Core Settings
+# ---------------------------------------------------------
+SECRET_KEY = require_env_var('SECRET_KEY')
+DEBUG = get_env_bool('DEBUG', default=False)
ALLOWED_HOSTS = parse_env_list("ALLOWED_HOSTS")
-# Application definition
+# ---------------------------------------------------------
+# Application Definitions
+# ---------------------------------------------------------
INSTALLED_APPS = [
- # Django default apps
+ # Django defaults
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -47,11 +73,12 @@ def parse_env_list(env_var, default=""):
'corsheaders',
'cloudinary',
'cloudinary_storage',
+ 'django_cleanup.apps.CleanupConfig',
'rest_framework',
'django_extensions',
'nested_admin',
'drf_yasg',
- 'django_quill',
+ 'django_quill', # Re-added django_quill
# Custom apps
'apps.externalteams',
@@ -69,7 +96,9 @@ def parse_env_list(env_var, default=""):
'apps.team',
]
-
+# ---------------------------------------------------------
+# Middleware
+# ---------------------------------------------------------
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
@@ -82,21 +111,26 @@ def parse_env_list(env_var, default=""):
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
-# CORS Configuration
+# ---------------------------------------------------------
+# CORS and CSRF Configuration
+# ---------------------------------------------------------
CORS_ORIGIN_ALLOW_ALL = False
CORS_ALLOWED_ORIGINS = parse_env_list("CORS_ALLOWED_ORIGINS")
CORS_ORIGIN_REGEX_WHITELIST = parse_env_list("CORS_ORIGIN_REGEX_WHITELIST")
CSRF_TRUSTED_ORIGINS = parse_env_list("CSRF_TRUSTED_ORIGINS")
+CSRF_COOKIE_SECURE = not DEBUG
+SESSION_COOKIE_SECURE = not DEBUG
-# Only allow CSRF cookie over HTTPS in production
-CSRF_COOKIE_SECURE = True
-SESSION_COOKIE_SECURE = True
-
-# Root URL configuration
+# ---------------------------------------------------------
+# URL and WSGI Configuration
+# ---------------------------------------------------------
ROOT_URLCONF = 'core.urls'
+WSGI_APPLICATION = 'core.wsgi.application'
-# Template configuration
+# ---------------------------------------------------------
+# Templates Configuration
+# ---------------------------------------------------------
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
@@ -105,7 +139,7 @@ def parse_env_list(env_var, default=""):
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
- 'django.template.context_processors.request',
+ 'django.template.context_processors.request', # Required by django-quill
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
@@ -113,33 +147,25 @@ def parse_env_list(env_var, default=""):
},
]
-# WSGI Application
-WSGI_APPLICATION = 'core.wsgi.application'
-
-# Database configuration
-# if DEBUG:
-# DATABASES = {
-# 'default': {
-# 'ENGINE': os.getenv('DATABASE_ENGINE', 'django.db.backends.sqlite3'),
-# 'NAME': BASE_DIR / os.getenv('DATABASE_NAME', 'db.sqlite3'),
-# }
-# }
-# else:
-# DATABASE_URL = os.getenv('DATABASE_URL')
-# if not DATABASE_URL:
-# raise ValueError(
-# "The DATABASE_URL environment variable is not set in production.")
-# DATABASES = {
-# 'default': dj_database_url.parse(DATABASE_URL, conn_max_age=600, ssl_require=True)
-# }
+# ---------------------------------------------------------
+# Database Configuration
+# ---------------------------------------------------------
DATABASE_URL = os.getenv('DATABASE_URL')
-if not DATABASE_URL:
- raise ValueError(
- "The DATABASE_URL environment variable is not set in production.")
+
DATABASES = {
- 'default': dj_database_url.parse(DATABASE_URL, conn_max_age=600, ssl_require=True)
+ 'default': dj_database_url.parse(
+ DATABASE_URL,
+ conn_max_age=600,
+ ssl_require=True
+ ) if DATABASE_URL else {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': BASE_DIR / 'db.sqlite3',
+ }
}
-# Password validation
+
+# ---------------------------------------------------------
+# Password Validation
+# ---------------------------------------------------------
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
@@ -147,58 +173,51 @@ def parse_env_list(env_var, default=""):
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
+# ---------------------------------------------------------
# Internationalization
+# ---------------------------------------------------------
LANGUAGE_CODE = os.getenv('LANGUAGE_CODE', 'en-us')
TIME_ZONE = os.getenv('TIME_ZONE', 'UTC')
USE_I18N = True
USE_L10N = True
USE_TZ = True
-# Static files (CSS, JavaScript, Images)
+# ---------------------------------------------------------
+# Static and Media Files
+# ---------------------------------------------------------
STATIC_URL = '/website/static/'
-
-# Define where `collectstatic` will output collected static files
STATIC_ROOT = BASE_DIR / 'staticfiles'
-
-# Additional locations for app-specific static files
STATICFILES_DIRS = [BASE_DIR / 'static']
-
-# Use WhiteNoise for serving static files in production
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
-# Media files (Uploaded files)
-MEDIA_URL = '/media/' if DEBUG else f'https://res.cloudinary.com/{os.getenv("CLOUDINARY_CLOUD_NAME")}/'
-MEDIA_ROOT = BASE_DIR / 'assets' if DEBUG else None
if DEBUG:
- # Local file storage for media
+ # Local file storage for development
+ MEDIA_URL = '/media/'
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
+ MEDIA_ROOT = BASE_DIR / 'assets'
+ print("DEBUG=True: Using local file storage for media.")
else:
- # Validate Cloudinary settings for production
- CLOUDINARY_CLOUD_NAME = os.getenv('CLOUDINARY_CLOUD_NAME')
- CLOUDINARY_API_KEY = os.getenv('CLOUDINARY_API_KEY')
- CLOUDINARY_API_SECRET = os.getenv('CLOUDINARY_API_SECRET')
-
- if not all([CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET]):
- raise ValueError(
- "Cloudinary environment variables are not fully set in production."
- )
-
+ # Cloudinary setup for production
CLOUDINARY_STORAGE = {
- 'CLOUD_NAME': CLOUDINARY_CLOUD_NAME,
- 'API_KEY': CLOUDINARY_API_KEY,
- 'API_SECRET': CLOUDINARY_API_SECRET,
+ 'CLOUD_NAME': require_env_var('CLOUDINARY_CLOUD_NAME'),
+ 'API_KEY': require_env_var('CLOUDINARY_API_KEY'),
+ 'API_SECRET': require_env_var('CLOUDINARY_API_SECRET'),
'SECURE': True,
'TIMEOUT': 600,
}
DEFAULT_FILE_STORAGE = 'cloudinary_storage.storage.MediaCloudinaryStorage'
+ print("DEBUG=False: Using Cloudinary for media storage.")
-
-# Default primary key field type
+# ---------------------------------------------------------
+# Default Primary Key Field Type
+# ---------------------------------------------------------
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
-# Django Rest Framework settings
+# ---------------------------------------------------------
+# Django REST Framework Configuration
+# ---------------------------------------------------------
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
@@ -209,7 +228,32 @@ def parse_env_list(env_var, default=""):
],
}
-# Quill Editor Configuration
+# ---------------------------------------------------------
+# File Upload Limits
+# ---------------------------------------------------------
+MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB
+DATA_UPLOAD_MAX_MEMORY_SIZE = MAX_UPLOAD_SIZE
+FILE_UPLOAD_MAX_MEMORY_SIZE = MAX_UPLOAD_SIZE
+
+# ---------------------------------------------------------
+# Admin and Authentication Settings
+# ---------------------------------------------------------
+LOGIN_URL = '/website/admin/login/'
+
+# ---------------------------------------------------------
+# Swagger / DRF-YASG Settings
+# ---------------------------------------------------------
+SWAGGER_SETTINGS = {
+ 'LOGIN_URL': LOGIN_URL,
+ 'LOGOUT_URL': '/website/admin/logout/',
+ 'USE_SESSION_AUTH': True,
+ 'SECURITY_DEFINITIONS': {
+ 'basic': {
+ 'type': 'basic'
+ }
+ },
+}
+
QUILL_CONFIGS = {
'default': {
'theme': 'snow',
@@ -236,48 +280,11 @@ def parse_env_list(env_var, default=""):
},
}
-# Custom upload handlers
-
-
-def local_file_upload(file):
- from django.core.files.storage import default_storage
- file_name = default_storage.save(f'quill_uploads/{file.name}', file)
- return f"{MEDIA_URL}{file_name}"
-
-
-def cloudinary_file_upload(file):
- from django.core.files.storage import default_storage
- file_name = default_storage.save(
- f'website/uploads/quill_uploads/{file.name}', file)
- return f"{MEDIA_URL}{file_name}"
-
-
-# Set the appropriate upload handler
-QUILL_UPLOAD_HANDLER = local_file_upload if DEBUG else cloudinary_file_upload
-
-# Debug logging
+# ---------------------------------------------------------
+# Mode-Specific Logging
+# ---------------------------------------------------------
if DEBUG:
print(f"Debug mode is: {DEBUG}")
- print(f"Media files are stored in: {MEDIA_ROOT}")
+ print(f"Media files are stored in: {BASE_DIR / 'assets'}")
else:
print("Production mode is ON")
-
-
-# File upload size limit (e.g., 10MB)
-DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB
-FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB
-
-
-# Django Admin Configuration
-LOGIN_URL = '/website/admin/login/'
-
-SWAGGER_SETTINGS = {
- 'LOGIN_URL': '/website/admin/login/',
- 'LOGOUT_URL': '/website/admin/logout/',
- 'USE_SESSION_AUTH': True,
- 'SECURITY_DEFINITIONS': {
- 'basic': {
- 'type': 'basic'
- }
- },
-}
diff --git a/src/website/requirements.txt b/src/website/requirements.txt
index 812d73ab90..631b395bf5 100644
--- a/src/website/requirements.txt
+++ b/src/website/requirements.txt
@@ -1,36 +1,88 @@
-asgiref==3.8.1
-bleach==6.1.0
-certifi==2024.8.30
-charset-normalizer==3.4.0
-cloudinary==1.41.0
-diff-match-patch==20181111
-dj-database-url==2.2.0
-Django==4.2.16
-django-cloudinary-storage==0.3.0
-django-cors-headers==4.5.0
-django-extensions==3.2.3
-django-js-asset==2.2.0
-django-nested-admin==4.1.1
-django-quill==0.1.0
-django-quill-editor==0.1.42
-djangorestframework==3.15.2
-drf-yasg==1.21.8
-idna==3.10
-inflection==0.5.1
-packaging==24.1
-pillow==11.0.0
-psycopg2-binary==2.9.10
-python-dotenv==1.0.1
-python-monkey-business==1.1.0
-pytz==2024.2
-PyYAML==6.0.2
-requests==2.32.3
-six==1.16.0
-sqlparse==0.5.1
-typing_extensions==4.12.2
-tzdata==2024.2
-uritemplate==4.1.1
-urllib3==2.2.3
-webencodings==0.5.1
-whitenoise==6.7.0
-gunicorn==23.0.0
+alembic==1.14.0
+asgiref==3.8.1
+blinker==1.9.0
+cachetools==5.5.0
+certifi==2024.8.30
+charset-normalizer==3.4.0
+click==8.1.7
+cloudinary==1.41.0
+colorama==0.4.6
+coreapi==2.3.3
+coreschema==0.0.4
+distlib==0.3.9
+dj-database-url==2.3.0
+Django==5.1.4
+django-author==1.2.0
+django-cleanup==9.0.0
+django-clearcache==1.2.1
+django-cloudinary-storage==0.3.0
+django-cors-headers==4.6.0
+django-environ==0.11.2
+django-extensions==3.2.3
+django-filter==24.3
+django-js-asset==2.2.0
+django-nested-admin==4.1.1
+django-quill-editor==0.1.42
+django-rest-swagger==2.2.0
+django-storages==1.14.4
+django-timezone==0.3
+djangorestframework==3.15.2
+drf-yasg==1.21.8
+environ==1.0
+et_xmlfile==2.0.0
+filelock==3.16.1
+Flask==3.1.0
+Flask-Cors==5.0.0
+Flask-Migrate==4.0.7
+Flask-SQLAlchemy==3.1.1
+google-api-core==2.23.0
+google-auth==2.36.0
+google-cloud-core==2.4.1
+google-cloud-storage==2.19.0
+google-crc32c==1.6.0
+google-resumable-media==2.7.2
+googleapis-common-protos==1.66.0
+greenlet==3.1.1
+gunicorn==23.0.0
+idna==3.10
+inflection==0.5.1
+invoke==2.2.0
+itsdangerous==2.2.0
+itypes==1.2.0
+Jinja2==3.1.4
+Mako==1.3.7
+MarkupSafe==3.0.2
+numpy==2.1.3
+openapi-codec==1.3.2
+openpyxl==3.1.5
+packaging==24.2
+pandas==2.2.3
+pillow==11.0.0
+platformdirs==4.3.6
+proto-plus==1.25.0
+protobuf==5.29.1
+psycopg2==2.9.10
+psycopg2-binary==2.9.10
+pyasn1==0.6.1
+pyasn1_modules==0.4.1
+python-dateutil==2.9.0.post0
+python-decouple==3.8
+python-dotenv==1.0.1
+python-monkey-business==1.1.0
+pytz==2024.2
+PyYAML==6.0.2
+requests==2.32.3
+rsa==4.9
+setuptools-git==1.2
+simplejson==3.19.3
+six==1.17.0
+SQLAlchemy==2.0.36
+sqlparse==0.5.2
+typing_extensions==4.12.2
+tzdata==2024.2
+uritemplate==4.1.1
+urllib3==2.2.3
+virtualenv==20.28.0
+waitress==3.0.2
+Werkzeug==3.1.3
+whitenoise==6.8.2
diff --git a/src/website/static/admin/css/custom_quill.css b/src/website/static/admin/css/custom_quill.css
index a6518cfb17..e36f3db689 100644
--- a/src/website/static/admin/css/custom_quill.css
+++ b/src/website/static/admin/css/custom_quill.css
@@ -1,8 +1,9 @@
-/* backend/static/admin/css/custom_quill.css */
+.quill-container {
+ min-height: 200px;
+}
-/* Light mode styles (default) */
.ql-container {
- height: 300px;
+ min-height: 200px;
background-color: #ffffff; /* Light mode background */
color: #000000; /* Light mode text color */
}
diff --git a/src/website/static/admin/js/admin_dark_mode.js b/src/website/static/admin/js/admin_dark_mode.js
deleted file mode 100644
index 1f30064732..0000000000
--- a/src/website/static/admin/js/admin_dark_mode.js
+++ /dev/null
@@ -1,24 +0,0 @@
-// backend/static/admin/js/admin_dark_mode.js
-
-document.addEventListener("DOMContentLoaded", function () {
- const toggleButton = document.createElement("button");
- toggleButton.textContent = "Toggle Dark Mode";
-
- // Styling adjustments
- toggleButton.style.position = "fixed";
- toggleButton.style.bottom = "20px"; // Moved to the bottom-right corner
- toggleButton.style.right = "20px";
- toggleButton.style.zIndex = "1000";
- toggleButton.style.padding = "10px 15px";
- toggleButton.style.backgroundColor = "#f0f0f0";
- toggleButton.style.border = "none";
- toggleButton.style.borderRadius = "5px";
- toggleButton.style.cursor = "pointer";
- toggleButton.style.boxShadow = "0 4px 6px rgba(0, 0, 0, 0.1)";
-
- document.body.appendChild(toggleButton);
-
- toggleButton.addEventListener("click", function () {
- document.body.classList.toggle("dark-mode");
- });
-});
diff --git a/src/website/templates/admin/base_site.html b/src/website/templates/admin/base_site.html
index d9d445e485..cd005936d8 100644
--- a/src/website/templates/admin/base_site.html
+++ b/src/website/templates/admin/base_site.html
@@ -6,9 +6,8 @@
href="{% static 'admin/css/custom_quill.css' %}"
/>
AirQo Website Admin Portal
{% endblock %} {% block footer %} {{ block.super }}
-
-
+
+
{% endblock %}
diff --git a/src/website/utils/fields.py b/src/website/utils/fields.py
index 50642b2da7..fb516713ef 100644
--- a/src/website/utils/fields.py
+++ b/src/website/utils/fields.py
@@ -1,182 +1,131 @@
+# utils/fields.py
import os
import logging
from django.conf import settings
-from cloudinary.models import CloudinaryField
from django.db import models
from django.core.exceptions import ValidationError
-from PIL import Image, UnidentifiedImageError
-from io import BytesIO
-from django.core.files.base import ContentFile
-from cloudinary.uploader import upload_large
+from django.core.files.images import get_image_dimensions
+from django.core.validators import FileExtensionValidator
+from cloudinary.models import CloudinaryField
-# Configure logger
logger = logging.getLogger(__name__)
-# Validator for allowed image formats
+# Maximum size for uploaded images in bytes (10MB)
+MAX_IMAGE_SIZE = 10 * 1024 * 1024
def validate_image_format(file):
- """Validates the uploaded image and compresses if applicable."""
+ """
+ Validate that the file is a valid image and does not exceed 10MB.
+ Allowed extensions are handled by FileExtensionValidator in the field definition.
+ """
+ if file.size > MAX_IMAGE_SIZE:
+ raise ValidationError(
+ f"Image size must not exceed 10MB. Current size: {file.size/1024/1024:.2f}MB.")
+
+ # Check if file is an actual image
+ # get_image_dimensions will raise an error if not a valid image
try:
- # Open the file to ensure it is a valid image
- with Image.open(file) as img:
- img.verify() # Verify that it is, in fact, an image
-
- # Re-open the image for further processing after verify()
- with Image.open(file) as img:
- img_format = img.format.upper()
- valid_formats = ["JPEG", "JPG", "PNG",
- "WEBP", "GIF", "BMP", "TIFF"]
-
- if img_format not in valid_formats:
- error_msg = (
- f"Unsupported image format: {img_format}. "
- f"Allowed formats: {', '.join(valid_formats)}."
- )
- logger.error(error_msg)
- raise ValidationError(error_msg)
-
- # Compress image if it is larger than 2MB and compressible
- if file.size > 2 * 1024 * 1024 and img_format in ["JPEG", "JPG", "PNG", "WEBP"]:
- buffer = BytesIO()
- save_kwargs = {"optimize": True, "quality": 85}
-
- # For PNG, convert to RGB to allow optimization
- if img_format == "PNG" and img.mode in ("RGBA", "P"):
- img = img.convert("RGB")
-
- try:
- img.save(buffer, format=img_format, **save_kwargs)
- except Exception as e:
- error_msg = f"Error compressing image {file.name}: {e}"
- logger.error(error_msg)
- raise ValidationError(error_msg)
-
- # Replace file content with compressed content
- file.file = ContentFile(buffer.getvalue())
- file.size = buffer.tell() # Update file size
-
- except UnidentifiedImageError:
- error_msg = f"The file '{file.name}' is not a valid image."
- logger.error(error_msg)
- raise ValidationError(error_msg)
- except ValidationError as ve:
- # Re-raise validation errors after logging
- logger.error(f"Validation error for file '{file.name}': {ve.message}")
- raise
+ get_image_dimensions(file)
except Exception as e:
- # Catch-all for any other exceptions
- error_msg = f"An unexpected error occurred while processing '{file.name}': {e}"
- logger.error(error_msg)
- raise ValidationError(error_msg)
-
-# Helper function for custom upload paths
+ logger.error(f"Invalid image file '{file.name}': {e}")
+ raise ValidationError(f"The file '{file.name}' is not a valid image.")
def upload_to(instance, filename):
+ """
+ A helper function for upload_to. This can be extended to use instance-specific logic.
+ If the instance has a `get_upload_folder()` method, use that. Otherwise, use a default folder.
+ """
try:
folder = instance.get_upload_folder()
except AttributeError:
- folder = 'uploads/others/' # Default folder if method not defined
+ folder = 'uploads/others/'
logger.warning(
- f"Instance '{instance}' does not have 'get_upload_folder' method. "
- f"Using default folder '{folder}'."
+ f"Instance '{instance}' does not have 'get_upload_folder' method. Using default folder '{folder}'."
)
return os.path.join(folder, filename)
-# Custom ImageField for local storage
-
class CustomImageField(models.ImageField):
+ """
+ A custom ImageField for local storage.
+ Uses FileExtensionValidator and validate_image_format for basic validation.
+ """
+
def __init__(self, *args, **kwargs):
+ # Allowed image extensions
+ allowed_extensions = ['jpg', 'jpeg',
+ 'png', 'webp', 'gif', 'bmp', 'tiff']
+
+ # Use local_upload_to if provided, else defaults to 'uploads/images/'
kwargs['upload_to'] = kwargs.pop('local_upload_to', 'uploads/images/')
kwargs['null'] = kwargs.get('null', True)
kwargs['blank'] = kwargs.get('blank', True)
kwargs['default'] = kwargs.get('default', 'uploads/default_image.webp')
+
+ # Append validators
validators = kwargs.get('validators', [])
+ validators.append(FileExtensionValidator(allowed_extensions))
validators.append(validate_image_format)
kwargs['validators'] = validators
- super().__init__(*args, **kwargs)
-
- def save_form_data(self, instance, data):
- """Override to handle file replacement properly."""
- try:
- super().save_form_data(instance, data)
- except Exception as e:
- logger.error(f"Error saving form data for {self.name}: {e}")
- raise
-# Custom FileField for local storage
+ super().__init__(*args, **kwargs)
class CustomFileField(models.FileField):
+ """
+ A custom FileField for local storage.
+ Handles files without complex validation, unless you add validators.
+ """
+
def __init__(self, *args, **kwargs):
+ # Use local_upload_to if provided, else defaults to 'uploads/files/'
kwargs['upload_to'] = kwargs.pop('local_upload_to', 'uploads/files/')
kwargs['null'] = kwargs.get('null', True)
kwargs['blank'] = kwargs.get('blank', True)
kwargs['default'] = kwargs.get('default', 'uploads/default_file.txt')
super().__init__(*args, **kwargs)
- def save_form_data(self, instance, data):
- """Override to handle file replacement properly."""
- try:
- super().save_form_data(instance, data)
- except Exception as e:
- logger.error(f"Error saving form data for {self.name}: {e}")
- raise
-
-# Custom CloudinaryField with format validation
-
class CustomCloudinaryField(CloudinaryField):
+ """
+ A custom CloudinaryField that stores files on Cloudinary.
+ Uses basic validation for image files and no chunked uploads.
+ """
+
def __init__(self, *args, **kwargs):
self.folder = kwargs.pop('cloudinary_folder', 'uploads/cloud')
kwargs['folder'] = self.folder
kwargs['null'] = kwargs.get('null', True)
kwargs['blank'] = kwargs.get('blank', True)
- validators = kwargs.get('validators', [])
- validators.append(validate_image_format)
- kwargs['validators'] = validators
+
+ # Determine if this is for an image or a raw file
is_image = kwargs.pop('is_image', True)
if is_image:
kwargs['default'] = 'website/uploads/default_image.webp'
kwargs['resource_type'] = 'image'
+ # Allowed image extensions
+ allowed_extensions = ['jpg', 'jpeg',
+ 'png', 'webp', 'gif', 'bmp', 'tiff']
+ validators = kwargs.get('validators', [])
+ validators.append(FileExtensionValidator(allowed_extensions))
+ validators.append(validate_image_format)
+ kwargs['validators'] = validators
else:
kwargs['default'] = 'website/uploads/default_file.txt'
kwargs['resource_type'] = 'raw'
super().__init__(*args, **kwargs)
- def pre_save(self, model_instance, add):
- """Override pre_save to use chunked upload for large files."""
- file = getattr(model_instance, self.attname)
- if not file:
- return super().pre_save(model_instance, add)
-
- if file.size > 2 * 1024 * 1024: # Files larger than 2MB
- try:
- upload_result = upload_large(
- file,
- folder=self.folder,
- chunk_size=6 * 1024 * 1024 # 6MB chunks
- )
- url = upload_result.get(
- 'secure_url') or upload_result.get('url')
- if not url:
- raise ValueError("Upload did not return a URL.")
- return url
- except Exception as e:
- error_msg = f"Failed to upload '{file.name}' to Cloudinary: {e}"
- logger.error(error_msg)
- raise ValidationError(error_msg)
- else:
- return super().pre_save(model_instance, add)
-
-# Conditional field for choosing between local and Cloudinary storage for images
-
class ConditionalImageField(models.Field):
+ """
+ A conditional field that uses local storage if DEBUG is True,
+ and Cloudinary if DEBUG is False.
+ """
+
def __init__(self, local_upload_to='uploads/images/', cloudinary_folder='uploads/images/', null=True, blank=True, *args, **kwargs):
if settings.DEBUG:
field_class = CustomImageField
@@ -192,6 +141,7 @@ def __init__(self, local_upload_to='uploads/images/', cloudinary_folder='uploads
'cloudinary_folder': cloudinary_folder,
'null': null,
'blank': blank,
+ # is_image defaults to True
**kwargs
}
@@ -207,10 +157,13 @@ def __get__(self, instance, owner):
def __set__(self, instance, value):
self.field_instance.__set__(instance, value)
-# Conditional field for choosing between local and Cloudinary storage for files
-
class ConditionalFileField(models.Field):
+ """
+ A conditional field that uses local storage if DEBUG is True,
+ and Cloudinary if DEBUG is False, for non-image files.
+ """
+
def __init__(self, local_upload_to='uploads/files/', cloudinary_folder='uploads/files/', null=True, blank=True, *args, **kwargs):
if settings.DEBUG:
field_class = CustomFileField
diff --git a/src/website/utils/validators.py b/src/website/utils/validators.py
new file mode 100644
index 0000000000..b8313cfaec
--- /dev/null
+++ b/src/website/utils/validators.py
@@ -0,0 +1,61 @@
+# backend/utils/validators.py
+
+from django.core.exceptions import ValidationError
+from django.core.validators import FileExtensionValidator
+
+
+def validate_image(file):
+ """
+ Validates the uploaded image for allowed extensions and maximum file size.
+ Skips validation for existing files during updates.
+ """
+ if not file or not hasattr(file, 'name'):
+ return
+
+ if '.' not in file.name:
+ return
+
+ allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp']
+ extension_validator = FileExtensionValidator(allowed_extensions)
+
+ try:
+ extension_validator(file)
+ except ValidationError:
+ raise ValidationError(
+ f"Unsupported file extension. Allowed extensions are: {', '.join(allowed_extensions)}."
+ )
+
+ max_size = 8 * 1024 * 1024 # 8 MB
+ if file.size > max_size:
+ raise ValidationError(
+ f"Image size must not exceed {max_size / (1024 * 1024)} MB."
+ )
+
+
+def validate_file(file):
+ """
+ Validates the uploaded file for allowed extensions and maximum file size.
+ Skips validation for existing files during updates.
+ """
+ if not file or not hasattr(file, 'name'):
+ return
+
+ if '.' not in file.name:
+ return
+
+ allowed_extensions = ['pdf', 'doc', 'docx',
+ 'xls', 'xlsx', 'ppt', 'pptx', 'txt']
+ extension_validator = FileExtensionValidator(allowed_extensions)
+
+ try:
+ extension_validator(file)
+ except ValidationError:
+ raise ValidationError(
+ f"Unsupported file extension. Allowed extensions are: {', '.join(allowed_extensions)}."
+ )
+
+ max_size = 10 * 1024 * 1024 # 10 MB
+ if file.size > max_size:
+ raise ValidationError(
+ f"File size must not exceed {max_size / (1024 * 1024)} MB."
+ )