Skip to content

Commit

Permalink
[#2030] Add option to upload custom fonts
Browse files Browse the repository at this point in the history
  • Loading branch information
pi-sigma committed Jan 24, 2024
1 parent e3d539f commit e0ead12
Show file tree
Hide file tree
Showing 10 changed files with 281 additions and 7 deletions.
14 changes: 12 additions & 2 deletions src/open_inwoner/configurations/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -275,7 +285,7 @@ class SiteConfigurarionAdmin(OrderedInlineModelAdminMixin, SingletonModelAdmin):
),
(_("Social media"), {"fields": ("display_social",)}),
)
inlines = [SiteConfigurationPageInline]
inlines = [SiteConfigurationPageInline, FontConfigurationInline]
form = SiteConfigurarionAdminForm

readonly_fields = [
Expand Down
5 changes: 5 additions & 0 deletions src/open_inwoner/configurations/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
7 changes: 7 additions & 0 deletions src/open_inwoner/configurations/constants.py
Original file line number Diff line number Diff line change
@@ -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")
68 changes: 68 additions & 0 deletions src/open_inwoner/configurations/migrations/0058_customfontset.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
],
),
]
83 changes: 81 additions & 2 deletions src/open_inwoner/configurations/models.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
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

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


Expand Down Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions src/open_inwoner/configurations/tests/test_upload.py
Original file line number Diff line number Diff line change
@@ -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’."
]
],
)
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
5 changes: 5 additions & 0 deletions src/open_inwoner/scss/admin/admin_overrides.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
24 changes: 22 additions & 2 deletions src/open_inwoner/scss/views/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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'),
Expand Down
9 changes: 9 additions & 0 deletions src/open_inwoner/utils/files.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit e0ead12

Please sign in to comment.