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( + 'Background Image', + 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( + 'Background Image', + 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( + 'Partner Logo', + 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( + 'Partner Logo', + 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( + 'Event Image', + 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( + 'Event Image', + 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." + )