# # This file is part of pretix (Community Edition). # # Copyright (C) 2014-2020 Raphael Michel and contributors # Copyright (C) 2020-2021 rami.io GmbH and contributors # # This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General # Public License as published by the Free Software Foundation in version 3 of the License. # # ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are # applicable granting you additional permissions and placing additional restrictions on your usage of this software. # Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive # this file, see . # # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied # warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # import gzip import hashlib import logging import os from urllib.parse import urljoin, urlsplit import django_libsass import sass from compressor.filters.cssmin import CSSMinFilter from django.conf import settings from django.core.cache import cache from django.core.files.base import ContentFile, File from django.core.files.storage import default_storage from django.dispatch import Signal from django.templatetags.static import static as _static from django.utils.timezone import now from django_scopes import scope from pretix.base.models import Event, Event_SettingsStore, Organizer from pretix.base.services.tasks import ( TransactionAwareProfiledEventTask, TransactionAwareTask, ) from pretix.base.signals import EventPluginSignal from pretix.celery_app import app from pretix.multidomain.urlreverse import ( get_event_domain, get_organizer_domain, ) from pretix.presale.signals import sass_postamble, sass_preamble logger = logging.getLogger('pretix.presale.style') affected_keys = ['primary_font', 'primary_color', 'theme_color_success', 'theme_color_danger', 'theme_color_background', 'theme_round_borders'] def compile_scss(object, file="main.scss", fonts=True): sassdir = os.path.join(settings.STATIC_ROOT, 'pretixpresale/scss') def static(path): sp = _static(path) if not settings.MEDIA_URL.startswith("/") and sp.startswith("/"): if isinstance(object, Event): domain = get_event_domain(object, fallback=True) else: domain = get_organizer_domain(object) if domain: siteurlsplit = urlsplit(settings.SITE_URL) if siteurlsplit.port and siteurlsplit.port not in (80, 443): domain = '%s:%d' % (domain, siteurlsplit.port) sp = urljoin('%s://%s' % (siteurlsplit.scheme, domain), sp) else: sp = urljoin(settings.SITE_URL, sp) return '"{}"'.format(sp) sassrules = [] if object.settings.get('primary_color'): sassrules.append('$brand-primary: {};'.format(object.settings.get('primary_color'))) if object.settings.get('theme_color_success'): sassrules.append('$brand-success: {};'.format(object.settings.get('theme_color_success'))) if object.settings.get('theme_color_danger'): sassrules.append('$brand-danger: {};'.format(object.settings.get('theme_color_danger'))) if object.settings.get('theme_color_background'): sassrules.append('$body-bg: {};'.format(object.settings.get('theme_color_background'))) if not object.settings.get('theme_round_borders'): sassrules.append('$border-radius-base: 0;') sassrules.append('$border-radius-large: 0;') sassrules.append('$border-radius-small: 0;') font = object.settings.get('primary_font') if font != 'Open Sans' and fonts: sassrules.append(get_font_stylesheet(font, event=object if isinstance(object, Event) else None)) sassrules.append( '$font-family-sans-serif: "{}", "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif ' '!default'.format( font )) if isinstance(object, Event): for recv, resp in sass_preamble.send(object, filename=file): sassrules.append(resp) sassrules.append('@import "{}";'.format(file)) if isinstance(object, Event): for recv, resp in sass_postamble.send(object, filename=file): sassrules.append(resp) sasssrc = "\n".join(sassrules) srcchecksum = hashlib.sha1(sasssrc.encode('utf-8')).hexdigest() cp = cache.get_or_set('sass_compile_prefix', now().isoformat()) css = cache.get('sass_compile_{}_{}'.format(cp, srcchecksum)) if css: if isinstance(css, bytes) and css[0:2] == b'\x1f\x8b': css = gzip.decompress(css).decode() else: cf = dict(django_libsass.CUSTOM_FUNCTIONS) cf['static'] = static css = sass.compile( string=sasssrc, include_paths=[sassdir], output_style='nested', custom_functions=cf ) cssf = CSSMinFilter(css) css = cssf.output() cache.set('sass_compile_{}_{}'.format(cp, srcchecksum), gzip.compress(css.encode()), 600) checksum = hashlib.sha1(css.encode('utf-8')).hexdigest() return css, checksum def delete_old_file(fname): if fname: if isinstance(fname, File): default_storage.delete(fname.name) else: default_storage.delete(fname) @app.task(base=TransactionAwareProfiledEventTask) def regenerate_css(event): settings = event.settings._cache() # ignore organizer settings # main.scss css, checksum = compile_scss(event) fname = 'pub/{}/{}/presale.{}.css'.format(event.organizer.slug, event.slug, checksum[:16]) if settings.get('presale_css_checksum', '') != checksum: old_fname = settings.get('presale_css_file') newname = default_storage.save(fname, ContentFile(css.encode('utf-8'))) event.settings.set('presale_css_file', newname) event.settings.set('presale_css_checksum', checksum) if old_fname and old_fname != newname and f'/{event.slug}/' in old_fname: delete_old_file(old_fname) # widget.scss css, checksum = compile_scss(event, file='widget.scss', fonts=False) fname = 'pub/{}/{}/widget.{}.css'.format(event.organizer.slug, event.slug, checksum[:16]) if settings.get('presale_widget_css_checksum', '') != checksum: old_fname = settings.get('presale_widget_css_file') newname = default_storage.save(fname, ContentFile(css.encode('utf-8'))) event.settings.set('presale_widget_css_file', newname) event.settings.set('presale_widget_css_checksum', checksum) if old_fname and old_fname != newname and f'/{event.slug}/' in old_fname: delete_old_file(old_fname) @app.task(base=TransactionAwareTask) def regenerate_organizer_css(organizer_id: int, regenerate_events=True): organizer = Organizer.objects.get(pk=organizer_id) with scope(organizer=organizer): # main.scss css, checksum = compile_scss(organizer) fname = 'pub/{}/presale.{}.css'.format(organizer.slug, checksum[:16]) if organizer.settings.get('presale_css_checksum', '') != checksum: old_fname = organizer.settings.get('presale_css_file') newname = default_storage.save(fname, ContentFile(css.encode('utf-8'))) organizer.settings.set('presale_css_file', newname) organizer.settings.set('presale_css_checksum', checksum) if old_fname != newname: delete_old_file(old_fname) # widget.scss css, checksum = compile_scss(organizer, file='widget.scss', fonts=False) fname = 'pub/{}/widget.{}.css'.format(organizer.slug, checksum[:16]) if organizer.settings.get('presale_widget_css_checksum', '') != checksum: old_fname = organizer.settings.get('presale_widget_css_file') newname = default_storage.save(fname, ContentFile(css.encode('utf-8'))) organizer.settings.set('presale_widget_css_file', newname) organizer.settings.set('presale_widget_css_checksum', checksum) if old_fname != newname: delete_old_file(old_fname) if regenerate_events: non_inherited_events = set(Event_SettingsStore.objects.filter( object__organizer=organizer, key__in=affected_keys ).values_list('object_id', flat=True)) for event in organizer.events.all(): if event.pk not in non_inherited_events: regenerate_css.apply_async(args=(event.pk,)) register_fonts = Signal() """ Return a dictionaries of the following structure. Paths should be relative to static root or an absolute URL. In the latter case, the fonts won't be available for PDF-rendering. { "font name": { "regular": { "truetype": "….ttf", "woff": "…", "woff2": "…" }, "bold": { ... }, "italic": { ... }, "bolditalic": { ... }, "pdf_only": False, # if True, font is not usable on the web } } """ register_event_fonts = EventPluginSignal() """ Return a dictionaries of the following structure. Paths should be relative to static root or an absolute URL. In the latter case, the fonts won't be available for PDF-rendering. As with all plugin signals, the ``sender`` keyword argument will contain the event. { "font name": { "regular": { "truetype": "….ttf", "woff": "…", "woff2": "…" }, "bold": { ... }, "italic": { ... }, "bolditalic": { ... }, "pdf_only": False, # if True, font is not usable on the web, } } """ def get_fonts(event: Event = None, pdf_support_required=False): def nested_dict_values(d): for v in d.values(): if isinstance(v, dict): yield from nested_dict_values(v) else: if isinstance(v, str): yield v f = {} received_fonts = {} for recv, value in register_fonts.send(0): received_fonts.update(value) # When deleting an event, the function is still getting called with an event. # We check specifically if there is a PK present to make sure the event actually exists. if event and event.pk: for recv, value in register_event_fonts.send(event): received_fonts.update(value) for font, payload in received_fonts.items(): if pdf_support_required: if any('//' in v for v in list(nested_dict_values(payload))): continue f.update({font: payload}) else: if payload.get('pdf_only', False): continue f.update({font: payload}) return f def get_font_stylesheet(font_name, event: Event = None): stylesheet = [] font = get_fonts(event)[font_name] for sty, formats in font.items(): if sty == 'sample': continue stylesheet.append('@font-face { ') stylesheet.append('font-family: "{}";'.format(font_name)) if sty in ("italic", "bolditalic"): stylesheet.append("font-style: italic;") else: stylesheet.append("font-style: normal;") if sty in ("bold", "bolditalic"): stylesheet.append("font-weight: bold;") else: stylesheet.append("font-weight: normal;") srcs = [] for f in ["woff2", "woff", "truetype"]: if f in formats: if formats[f].startswith('https'): srcs.append(f"url('{formats[f]}') format('{f}')") else: srcs.append(f"url(static('{formats[f]}')) format('{f}')") stylesheet.append("src: {};".format(", ".join(srcs))) stylesheet.append("font-display: swap;") stylesheet.append("}") return "\n".join(stylesheet)