From e0ead128e0756ed2b6cf8ba8108f7dba09a00857 Mon Sep 17 00:00:00 2001 From: Paul Schilling Date: Wed, 17 Jan 2024 09:59:24 +0100 Subject: [PATCH] [#2030] Add option to upload custom fonts --- src/open_inwoner/configurations/admin.py | 14 +++- src/open_inwoner/configurations/choices.py | 5 ++ src/open_inwoner/configurations/constants.py | 7 ++ .../migrations/0058_customfontset.py | 68 +++++++++++++++ src/open_inwoner/configurations/models.py | 83 ++++++++++++++++++- .../configurations/tests/test_upload.py | 71 ++++++++++++++++ .../components/accessibility/change_font.js | 2 +- .../scss/admin/admin_overrides.scss | 5 ++ src/open_inwoner/scss/views/App.scss | 24 +++++- src/open_inwoner/utils/files.py | 9 ++ 10 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 src/open_inwoner/configurations/constants.py create mode 100644 src/open_inwoner/configurations/migrations/0058_customfontset.py create mode 100644 src/open_inwoner/configurations/tests/test_upload.py create mode 100644 src/open_inwoner/utils/files.py diff --git a/src/open_inwoner/configurations/admin.py b/src/open_inwoner/configurations/admin.py index 8a0590ea99..d4837502d1 100644 --- a/src/open_inwoner/configurations/admin.py +++ b/src/open_inwoner/configurations/admin.py @@ -25,7 +25,7 @@ from ..utils.css import ALLOWED_PROPERTIES from ..utils.fields import CSSEditorWidget from ..utils.iteration import split -from .models import SiteConfiguration, SiteConfigurationPage +from .models import CustomFontSet, SiteConfiguration, SiteConfigurationPage @admin.action(description=_("Delete selected websites")) @@ -60,6 +60,9 @@ def delete_model(self, request, obj): else: super().delete_model(request, obj) + class Media: + css = {"all": ("css/admin/admin_overrides.css",)} + # re-register `Site` with our CustomSiteAdmin admin.site.unregister(Site) @@ -82,6 +85,13 @@ class SiteConfigurationPageInline(OrderedTabularInline): autocomplete_fields = ("flatpage",) +class FontConfigurationInline(admin.StackedInline): + model = CustomFontSet + verbose_name = "Fonts" + min_num = 1 + can_delete = False + + class SiteConfigurarionAdminForm(forms.ModelForm): class Meta: model = SiteConfiguration @@ -275,7 +285,7 @@ class SiteConfigurarionAdmin(OrderedInlineModelAdminMixin, SingletonModelAdmin): ), (_("Social media"), {"fields": ("display_social",)}), ) - inlines = [SiteConfigurationPageInline] + inlines = [SiteConfigurationPageInline, FontConfigurationInline] form = SiteConfigurarionAdminForm readonly_fields = [ diff --git a/src/open_inwoner/configurations/choices.py b/src/open_inwoner/configurations/choices.py index c61488f6ee..d1b2d0b949 100644 --- a/src/open_inwoner/configurations/choices.py +++ b/src/open_inwoner/configurations/choices.py @@ -10,3 +10,8 @@ class ColorTypeChoices(models.TextChoices): class OpenIDDisplayChoices(models.TextChoices): admin = "admin", _("Admin") regular = "regular", _("Regular user") + + +class CustomFontName(models.TextChoices): + body = _("text_body_font"), _("Text body font") + heading = _("heading_font"), _("Heading font") diff --git a/src/open_inwoner/configurations/constants.py b/src/open_inwoner/configurations/constants.py new file mode 100644 index 0000000000..50349d679f --- /dev/null +++ b/src/open_inwoner/configurations/constants.py @@ -0,0 +1,7 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + + +class FontFileName(models.TextChoices): + body = _("text_body_font"), _("Text body font") + heading = _("heading_font"), _("Heading font") diff --git a/src/open_inwoner/configurations/migrations/0058_customfontset.py b/src/open_inwoner/configurations/migrations/0058_customfontset.py new file mode 100644 index 0000000000..beb89d0db6 --- /dev/null +++ b/src/open_inwoner/configurations/migrations/0058_customfontset.py @@ -0,0 +1,68 @@ +# Generated by Django 3.2.23 on 2024-01-23 15:38 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import open_inwoner.configurations.models +import open_inwoner.utils.files + + +class Migration(migrations.Migration): + + dependencies = [ + ("configurations", "0057_siteconfiguration_theme_stylesheet"), + ] + + operations = [ + migrations.CreateModel( + name="CustomFontSet", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "text_body_font", + open_inwoner.configurations.models.CustomFontField( + blank=True, + help_text="Regular font for the text body. Supported are files with the .ttf extension.", + null=True, + storage=open_inwoner.utils.files.OverwriteStorage(), + upload_to=open_inwoner.configurations.models.CustomFontSet.update_filename_body, + validators=[ + django.core.validators.FileExtensionValidator(["ttf"]) + ], + verbose_name="Text body font", + ), + ), + ( + "heading_font", + open_inwoner.configurations.models.CustomFontField( + blank=True, + help_text="Regular font for headings. Supported are files with the .ttf extension.", + null=True, + storage=open_inwoner.utils.files.OverwriteStorage(), + upload_to=open_inwoner.configurations.models.CustomFontSet.update_filename_heading, + validators=[ + django.core.validators.FileExtensionValidator(["ttf"]) + ], + verbose_name="Heading font", + ), + ), + ( + "site_configuration", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="custom_fonts", + to="configurations.siteconfiguration", + verbose_name="Configuration", + ), + ), + ], + ), + ] diff --git a/src/open_inwoner/configurations/models.py b/src/open_inwoner/configurations/models.py index 27f13c210b..5294f5fb8c 100644 --- a/src/open_inwoner/configurations/models.py +++ b/src/open_inwoner/configurations/models.py @@ -1,12 +1,15 @@ +import os from typing import Optional +from django.conf import settings from django.contrib.flatpages.models import FlatPage from django.core.validators import FileExtensionValidator from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ from colorfield.fields import ColorField -from filer.fields.file import FilerFileField from filer.fields.image import FilerImageField from ordered_model.models import OrderedModel, OrderedModelManager from solo.models import SingletonModel @@ -14,8 +17,9 @@ from ..utils.colors import hex_to_hsl from ..utils.css import clean_stylesheet from ..utils.fields import CSSField +from ..utils.files import OverwriteStorage from ..utils.validators import FilerExactImageSizeValidator -from .choices import ColorTypeChoices, OpenIDDisplayChoices +from .choices import ColorTypeChoices, CustomFontName, OpenIDDisplayChoices from .validators import validate_oidc_config @@ -584,6 +588,81 @@ def get_help_text(self, request) -> Optional[str]: return "" +class CustomFontField(models.FileField): + def __init__(self, file_name: str = "", **kwargs): + self.file_name = file_name + super().__init__(**kwargs) + + +class CustomFontSet(models.Model): + def update_filename(self, filename: str, new_name: str, path: str) -> str: + ext = filename.split(".")[1] + filename = f"{new_name}.{ext}" + return "{path}/{filename}".format(path=path, filename=filename) + + def update_filename_body(self, filename: str) -> str: + return CustomFontSet.update_filename( + self, + filename, + new_name=CustomFontName.body, + path="custom_fonts/", + ) + + def update_filename_heading(self, filename: str) -> str: + return CustomFontSet.update_filename( + self, + filename, + new_name=CustomFontName.heading, + path="custom_fonts/", + ) + + site_configuration = models.OneToOneField( + SiteConfiguration, + verbose_name=_("Configuration"), + related_name="custom_fonts", + on_delete=models.CASCADE, + ) + text_body_font = CustomFontField( + verbose_name=_("Text body font"), + upload_to=update_filename_body, + file_name=CustomFontName.body, + storage=OverwriteStorage(), + validators=[FileExtensionValidator(["ttf"])], + blank=True, + null=True, + help_text=_( + "Regular font for the text body. Supported are files with the .ttf extension." + ), + ) + heading_font = CustomFontField( + verbose_name=_("Heading font"), + upload_to=update_filename_heading, + file_name=CustomFontName.heading, + storage=OverwriteStorage(), + validators=[FileExtensionValidator(["ttf"])], + blank=True, + null=True, + help_text=_( + "Regular font for headings. Supported are files with the .ttf extension." + ), + ) + + +@receiver(post_save, sender=CustomFontSet) +def remove_orphan_files(sender, instance, *args, **kwargs): + """ + Remove font files corresponding to `CustomFont` fields that have been cleared + """ + custom_fonts_dir = os.path.join(settings.MEDIA_ROOT, "custom_fonts") + font_names = os.listdir(custom_fonts_dir) + + for field in sender._meta.concrete_fields: + if isinstance(field, models.FileField) and not getattr(instance, field.name): + for font_name in font_names: + if font_name.startswith(field.file_name): + os.remove(os.path.join(custom_fonts_dir, font_name)) + + class SiteConfigurationPage(OrderedModel): configuration = models.ForeignKey( SiteConfiguration, diff --git a/src/open_inwoner/configurations/tests/test_upload.py b/src/open_inwoner/configurations/tests/test_upload.py new file mode 100644 index 0000000000..973e5230e5 --- /dev/null +++ b/src/open_inwoner/configurations/tests/test_upload.py @@ -0,0 +1,71 @@ +from django.urls import reverse + +from django_webtest import WebTest +from webtest import Upload + +from open_inwoner.accounts.tests.factories import UserFactory + +from ...utils.test import ClearCachesMixin +from ..models import CustomFontSet, SiteConfiguration + + +class CustomFontsTest(ClearCachesMixin, WebTest): + def setUp(self): + self.user = UserFactory(is_superuser=True, is_staff=True) + + self.config = SiteConfiguration.get_solo() + self.config.custom_fonts = CustomFontSet() + self.config.save() + + self.form = self.app.get( + reverse("admin:configurations_siteconfiguration_change"), user=self.user + ).forms["siteconfiguration_form"] + + def test_upload_font_correct_filetype(self): + font_file = Upload("valid.ttf", b"content", content_type="font/ttf") + self.form["name"] = "Test" + self.form["custom_fonts-0-text_body_font"] = font_file + self.form["custom_fonts-0-heading_font"] = font_file + + self.form.submit() + + custom_font_set = CustomFontSet.objects.first() + body_font = custom_font_set.text_body_font + heading_font = custom_font_set.heading_font + + self.assertEqual(body_font.name, "custom_fonts/text_body_font.ttf") + self.assertEqual(heading_font.name, "custom_fonts/heading_font.ttf") + + # test file overwrite: upload again + another_font_file = Upload( + "valid_encore.ttf", b"content", content_type="font/ttf" + ) + self.form["custom_fonts-0-text_body_font"] = another_font_file + self.form["custom_fonts-0-heading_font"] = another_font_file + + self.form.submit() + + self.assertEqual(len(CustomFontSet.objects.all()), 1) + + custom_font_set = CustomFontSet.objects.first() + body_font = custom_font_set.text_body_font + heading_font = custom_font_set.heading_font + + self.assertEqual(body_font.name, "custom_fonts/text_body_font.ttf") + self.assertEqual(heading_font.name, "custom_fonts/heading_font.ttf") + + def test_upload_font_incorrect_filetype(self): + font_file = Upload("invalid.svg", b"content", content_type="font/svg") + self.form["name"] = "Test" + self.form["custom_fonts-0-text_body_font"] = font_file + + response = self.form.submit() + + self.assertEquals( + response.context["errors"], + [ + [ + "Bestandsextensie ‘svg’ is niet toegestaan. Toegestane extensies zijn: ‘ttf’." + ] + ], + ) diff --git a/src/open_inwoner/js/components/accessibility/change_font.js b/src/open_inwoner/js/components/accessibility/change_font.js index 5acd7b183c..3b4f64557e 100644 --- a/src/open_inwoner/js/components/accessibility/change_font.js +++ b/src/open_inwoner/js/components/accessibility/change_font.js @@ -15,7 +15,7 @@ class ChangeFont { const varName = '--font-family-body' if (root.style.getPropertyValue(varName) == 'Open Dyslexic') { - root.style.setProperty(varName, 'TheSans C5') + root.style.setProperty(varName, 'Body') this.text.innerText = this.node.dataset.text } else { root.style.setProperty(varName, 'Open Dyslexic') diff --git a/src/open_inwoner/scss/admin/admin_overrides.scss b/src/open_inwoner/scss/admin/admin_overrides.scss index 2c988225bd..ee30f05091 100644 --- a/src/open_inwoner/scss/admin/admin_overrides.scss +++ b/src/open_inwoner/scss/admin/admin_overrides.scss @@ -6,3 +6,8 @@ @import './app_overrides'; @import './ck_editor'; @import '../components/Map/Map.scss'; + +// hide title from admin.StackedInline +.inline-related > h3 { + display: none; +} diff --git a/src/open_inwoner/scss/views/App.scss b/src/open_inwoner/scss/views/App.scss index c4f4b23af4..0979ab2fbd 100644 --- a/src/open_inwoner/scss/views/App.scss +++ b/src/open_inwoner/scss/views/App.scss @@ -192,7 +192,7 @@ /// Font. --font-color-heading: var(--color-black); - --font-family-heading: 'TheMix C5'; + --font-family-heading: 'Heading'; --font-weight-heading: bold; --font-color-heading-1: var(--font-color-heading); @@ -221,7 +221,7 @@ --font-size-heading-card: 18px; - --font-family-body: 'TheSans C5'; + --font-family-body: 'Body'; --font-color-body: var(--color-gray-dark); --font-size-body: 16px; --font-line-height-body: 21px; @@ -253,6 +253,26 @@ --form-width: 500px; } +// Custom fonts with fallback +@font-face { + font-family: 'Body'; + src: local('Body'), + url('/media/custom_fonts/text_body_font.otf') format('opentype'), + url('/static/fonts/TheSansC5/DesktopFonts/TheSansC5-5_Plain.otf') + format('opentype'); + font-display: swap; +} + +@font-face { + font-family: 'Heading'; + src: local('Heading'), + url('/media/custom_fonts/heading_font.otf') format('opentype'), + url('/static/fonts/TheSansC5/DesktopFonts/TheSansC5-5_Plain.otf') + format('opentype'); + font-display: swap; +} + +// Default OIP fonts @font-face { font-family: 'TheMix C5'; src: local('TheMix C5'), diff --git a/src/open_inwoner/utils/files.py b/src/open_inwoner/utils/files.py new file mode 100644 index 0000000000..d0e1a601a7 --- /dev/null +++ b/src/open_inwoner/utils/files.py @@ -0,0 +1,9 @@ +from django.core.files.storage import FileSystemStorage + + +class OverwriteStorage(FileSystemStorage): + """Custom upload file storage for overwriting files with the same name""" + + def get_available_name(self, name, max_length=None): + self.delete(name) + return name