diff --git a/dbtemplates/admin.py b/dbtemplates/admin.py index f6d65af..9b782d0 100644 --- a/dbtemplates/admin.py +++ b/dbtemplates/admin.py @@ -14,8 +14,7 @@ # use reversion_compare's CompareVersionAdmin or reversion's VersionAdmin as # the base admin class if yes if settings.DBTEMPLATES_USE_REVERSION_COMPARE: - from reversion_compare.admin import CompareVersionAdmin \ - as TemplateModelAdmin + from reversion_compare.admin import CompareVersionAdmin as TemplateModelAdmin elif settings.DBTEMPLATES_USE_REVERSION: from reversion.admin import VersionAdmin as TemplateModelAdmin else: @@ -23,22 +22,22 @@ class CodeMirrorTextArea(forms.Textarea): - """ A custom widget for the CodeMirror browser editor to be used with the content field of the Template model. """ + class Media: - css = dict(screen=[posixpath.join( - settings.DBTEMPLATES_MEDIA_PREFIX, 'css/editor.css')]) - js = [posixpath.join(settings.DBTEMPLATES_MEDIA_PREFIX, - 'js/codemirror.js')] + css = dict( + screen=[posixpath.join(settings.DBTEMPLATES_MEDIA_PREFIX, "css/editor.css")] + ) + js = [posixpath.join(settings.DBTEMPLATES_MEDIA_PREFIX, "js/codemirror.js")] def render(self, name, value, attrs=None, renderer=None): result = [] + result.append(super().render(name, value, attrs)) result.append( - super().render(name, value, attrs)) - result.append(""" + """ -""" % dict(media_prefix=settings.DBTEMPLATES_MEDIA_PREFIX, name=name)) +""" + % dict(media_prefix=settings.DBTEMPLATES_MEDIA_PREFIX, name=name) + ) return mark_safe("".join(result)) @@ -61,62 +62,79 @@ def render(self, name, value, attrs=None, renderer=None): TemplateContentTextArea = forms.Textarea if settings.DBTEMPLATES_AUTO_POPULATE_CONTENT: - content_help_text = _("Leaving this empty causes Django to look for a " - "template with the given name and populate this " - "field with its content.") + content_help_text = _( + "Leaving this empty causes Django to look for a " + "template with the given name and populate this " + "field with its content." + ) else: content_help_text = "" if settings.DBTEMPLATES_USE_CODEMIRROR and settings.DBTEMPLATES_USE_TINYMCE: - raise ImproperlyConfigured("You may use either CodeMirror or TinyMCE " - "with dbtemplates, not both. Please disable " - "one of them.") + raise ImproperlyConfigured( + "You may use either CodeMirror or TinyMCE " + "with dbtemplates, not both. Please disable " + "one of them." + ) if settings.DBTEMPLATES_USE_TINYMCE: from tinymce.widgets import AdminTinyMCE + TemplateContentTextArea = AdminTinyMCE elif settings.DBTEMPLATES_USE_REDACTOR: from redactor.widgets import RedactorEditor + TemplateContentTextArea = RedactorEditor class TemplateAdminForm(forms.ModelForm): - """ Custom AdminForm to make the content textarea wider. """ + content = forms.CharField( - widget=TemplateContentTextArea(attrs={'rows': '24'}), - help_text=content_help_text, required=False) + widget=TemplateContentTextArea(attrs={"rows": "24"}), + help_text=content_help_text, + required=False, + ) class Meta: model = Template - fields = ('name', 'content', 'sites', 'creation_date', 'last_changed') + fields = ("name", "content", "sites", "creation_date", "last_changed") fields = "__all__" class TemplateAdmin(TemplateModelAdmin): form = TemplateAdminForm - readonly_fields = ['creation_date', 'last_changed'] + readonly_fields = ["creation_date", "last_changed"] fieldsets = ( - (None, { - 'fields': ('name', 'content'), - 'classes': ('monospace',), - }), - (_('Advanced'), { - 'fields': (('sites'),), - }), - (_('Date/time'), { - 'fields': (('creation_date', 'last_changed'),), - 'classes': ('collapse',), - }), + ( + None, + { + "fields": ("name", "content"), + "classes": ("monospace",), + }, + ), + ( + _("Advanced"), + { + "fields": (("sites"),), + }, + ), + ( + _("Date/time"), + { + "fields": (("creation_date", "last_changed"),), + "classes": ("collapse",), + }, + ), ) - filter_horizontal = ('sites',) - list_display = ('name', 'creation_date', 'last_changed', 'site_list') - list_filter = ('sites',) + filter_horizontal = ("sites",) + list_display = ("name", "creation_date", "last_changed", "site_list") + list_filter = ("sites",) save_as = True - search_fields = ('name', 'content') - actions = ['invalidate_cache', 'repopulate_cache', 'check_syntax'] + search_fields = ("name", "content") + actions = ["invalidate_cache", "repopulate_cache", "check_syntax"] def invalidate_cache(self, request, queryset): for template in queryset: @@ -125,10 +143,11 @@ def invalidate_cache(self, request, queryset): message = ngettext( "Cache of one template successfully invalidated.", "Cache of %(count)d templates successfully invalidated.", - count) - self.message_user(request, message % {'count': count}) - invalidate_cache.short_description = _("Invalidate cache of " - "selected templates") + count, + ) + self.message_user(request, message % {"count": count}) + + invalidate_cache.short_description = _("Invalidate cache of selected templates") def repopulate_cache(self, request, queryset): for template in queryset: @@ -137,37 +156,43 @@ def repopulate_cache(self, request, queryset): message = ngettext( "Cache successfully repopulated with one template.", "Cache successfully repopulated with %(count)d templates.", - count) - self.message_user(request, message % {'count': count}) - repopulate_cache.short_description = _("Repopulate cache with " - "selected templates") + count, + ) + self.message_user(request, message % {"count": count}) + + repopulate_cache.short_description = _("Repopulate cache with selected templates") def check_syntax(self, request, queryset): errors = [] for template in queryset: valid, error = check_template_syntax(template) if not valid: - errors.append(f'{template.name}: {error}') + errors.append(f"{template.name}: {error}") if errors: count = len(errors) message = ngettext( "Template syntax check FAILED for %(names)s.", - "Template syntax check FAILED for " - "%(count)d templates: %(names)s.", - count) - self.message_user(request, message % - {'count': count, 'names': ', '.join(errors)}) + "Template syntax check FAILED for %(count)d templates: %(names)s.", + count, + ) + self.message_user( + request, message % {"count": count, "names": ", ".join(errors)} + ) else: count = queryset.count() message = ngettext( "Template syntax OK.", - "Template syntax OK for %(count)d templates.", count) - self.message_user(request, message % {'count': count}) + "Template syntax OK for %(count)d templates.", + count, + ) + self.message_user(request, message % {"count": count}) + check_syntax.short_description = _("Check template syntax") def site_list(self, template): return ", ".join([site.name for site in template.sites.all()]) - site_list.short_description = _('sites') + + site_list.short_description = _("sites") admin.site.register(Template, TemplateAdmin) diff --git a/dbtemplates/apps.py b/dbtemplates/apps.py index 9f75273..a98ba83 100644 --- a/dbtemplates/apps.py +++ b/dbtemplates/apps.py @@ -3,7 +3,7 @@ class DBTemplatesConfig(AppConfig): - name = 'dbtemplates' - verbose_name = _('Database templates') + name = "dbtemplates" + verbose_name = _("Database templates") - default_auto_field = 'django.db.models.AutoField' + default_auto_field = "django.db.models.AutoField" diff --git a/dbtemplates/conf.py b/dbtemplates/conf.py index 010db5b..77b5d08 100644 --- a/dbtemplates/conf.py +++ b/dbtemplates/conf.py @@ -33,35 +33,45 @@ def configure_cache_backend(self, value): else: return "default" if isinstance(value, str) and value.startswith("dbtemplates."): - raise ImproperlyConfigured("Please upgrade to one of the " - "supported backends as defined " - "in the Django docs.") + raise ImproperlyConfigured( + "Please upgrade to one of the " + "supported backends as defined " + "in the Django docs." + ) return value def configure_use_reversion(self, value): - if value and 'reversion' not in settings.INSTALLED_APPS: - raise ImproperlyConfigured("Please add 'reversion' to your " - "INSTALLED_APPS setting to make " - "use of it in dbtemplates.") + if value and "reversion" not in settings.INSTALLED_APPS: + raise ImproperlyConfigured( + "Please add 'reversion' to your " + "INSTALLED_APPS setting to make " + "use of it in dbtemplates." + ) return value def configure_use_reversion_compare(self, value): - if value and 'reversion_compare' not in settings.INSTALLED_APPS: - raise ImproperlyConfigured("Please add 'reversion_compare' to your" - " INSTALLED_APPS setting to make " - "use of it in dbtemplates.") + if value and "reversion_compare" not in settings.INSTALLED_APPS: + raise ImproperlyConfigured( + "Please add 'reversion_compare' to your" + " INSTALLED_APPS setting to make " + "use of it in dbtemplates." + ) return value def configure_use_tinymce(self, value): - if value and 'tinymce' not in settings.INSTALLED_APPS: - raise ImproperlyConfigured("Please add 'tinymce' to your " - "INSTALLED_APPS setting to make " - "use of it in dbtemplates.") + if value and "tinymce" not in settings.INSTALLED_APPS: + raise ImproperlyConfigured( + "Please add 'tinymce' to your " + "INSTALLED_APPS setting to make " + "use of it in dbtemplates." + ) return value def configure_use_redactor(self, value): - if value and 'redactor' not in settings.INSTALLED_APPS: - raise ImproperlyConfigured("Please add 'redactor' to your " - "INSTALLED_APPS setting to make " - "use of it in dbtemplates.") + if value and "redactor" not in settings.INSTALLED_APPS: + raise ImproperlyConfigured( + "Please add 'redactor' to your " + "INSTALLED_APPS setting to make " + "use of it in dbtemplates." + ) return value diff --git a/dbtemplates/loader.py b/dbtemplates/loader.py index 5a57f27..a1a9942 100644 --- a/dbtemplates/loader.py +++ b/dbtemplates/loader.py @@ -1,11 +1,17 @@ +from typing import Optional, Tuple + from django.contrib.sites.models import Site from django.db import router from django.template import Origin, TemplateDoesNotExist from django.template.loaders.base import Loader as BaseLoader from dbtemplates.models import Template -from dbtemplates.utils.cache import (cache, get_cache_key, - set_and_return, get_cache_notfound_key) +from dbtemplates.utils.cache import ( + cache, + get_cache_key, + set_and_return, + get_cache_notfound_key, +) class Loader(BaseLoader): @@ -17,6 +23,7 @@ class Loader(BaseLoader): it falls back to query the database field ``name`` with the template path and ``sites`` with the current site. """ + is_usable = True def get_template_sources(self, template_name, template_dirs=None): @@ -26,18 +33,17 @@ def get_template_sources(self, template_name, template_dirs=None): loader=self, ) - def get_contents(self, origin): + def get_contents(self, origin: Origin) -> str: content, _ = self._load_template_source(origin.template_name) return content - def _load_and_store_template(self, template_name, cache_key, site, - **params): + def _load_and_store_template(self, template_name: str, cache_key: str, site: Site, **params) -> Tuple[str, str]: template = Template.objects.get(name__exact=template_name, **params) db = router.db_for_read(Template, instance=template) - display_name = f'dbtemplates:{db}:{template_name}:{site.domain}' + display_name = f"dbtemplates:{db}:{template_name}:{site.domain}" return set_and_return(cache_key, template.content, display_name) - def _load_template_source(self, template_name, template_dirs=None): + def _load_template_source(self, template_name: str, template_dirs: Optional[str] = None) -> Tuple[str, str]: # The logic should work like this: # * Try to find the template in the cache. If found, return it. # * Now check the cache if a lookup for the given template @@ -51,37 +57,40 @@ def _load_template_source(self, template_name, template_dirs=None): # in the cache indicating that queries failed, with the current # timestamp. site = Site.objects.get_current() - cache_key = get_cache_key(template_name) + cache_key = get_cache_key(template_name, current_site=site) + # Not found in cache, move on. + cache_notfound_key = get_cache_notfound_key(template_name, current_site=site) if cache: try: backend_template = cache.get(cache_key) - if backend_template: - return backend_template, template_name except Exception: pass + else: + if backend_template: + return (backend_template, template_name) - # Not found in cache, move on. - cache_notfound_key = get_cache_notfound_key(template_name) - if cache: try: notfound = cache.get(cache_notfound_key) - if notfound: - raise TemplateDoesNotExist(template_name) except Exception: raise TemplateDoesNotExist(template_name) + else: + if notfound: + raise TemplateDoesNotExist(template_name) # Not marked as not-found, move on... try: - return self._load_and_store_template(template_name, cache_key, - site, sites__in=[site.id]) + return self._load_and_store_template( + template_name, cache_key, site, sites__in=[site.id] + ) except (Template.MultipleObjectsReturned, Template.DoesNotExist): try: - return self._load_and_store_template(template_name, cache_key, - site, sites__isnull=True) + return self._load_and_store_template( + template_name, cache_key, site, sites__isnull=True + ) except (Template.MultipleObjectsReturned, Template.DoesNotExist): pass # Mark as not-found in cache. - cache.set(cache_notfound_key, '1') + cache.set(cache_notfound_key, "1") raise TemplateDoesNotExist(template_name) diff --git a/dbtemplates/management/commands/check_template_syntax.py b/dbtemplates/management/commands/check_template_syntax.py index 3837e65..c315ad2 100644 --- a/dbtemplates/management/commands/check_template_syntax.py +++ b/dbtemplates/management/commands/check_template_syntax.py @@ -12,8 +12,9 @@ def handle(self, **options): for template in Template.objects.all(): valid, error = check_template_syntax(template) if not valid: - errors.append(f'{template.name}: {error}') + errors.append(f"{template.name}: {error}") if errors: raise CommandError( - 'Some templates contained errors\n%s' % '\n'.join(errors)) - self.stdout.write('OK') + "Some templates contained errors\n%s" % "\n".join(errors) + ) + self.stdout.write("OK") diff --git a/dbtemplates/management/commands/create_error_templates.py b/dbtemplates/management/commands/create_error_templates.py index 3c53d24..f7a78c9 100644 --- a/dbtemplates/management/commands/create_error_templates.py +++ b/dbtemplates/management/commands/create_error_templates.py @@ -29,29 +29,39 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - "-f", "--force", action="store_true", dest="force", - default=False, help="overwrite existing database templates") + "-f", + "--force", + action="store_true", + dest="force", + default=False, + help="overwrite existing database templates", + ) def handle(self, **options): - force = options.get('force') + force = options.get("force") try: site = Site.objects.get_current() except Site.DoesNotExist: - raise CommandError("Please make sure to have the sites contrib " - "app installed and setup with a site object") + raise CommandError( + "Please make sure to have the sites contrib " + "app installed and setup with a site object" + ) - verbosity = int(options.get('verbosity', 1)) + verbosity = int(options.get("verbosity", 1)) for error_code in (404, 500): template, created = Template.objects.get_or_create( - name=f"{error_code}.html") + name=f"{error_code}.html" + ) if created or (not created and force): - template.content = TEMPLATES.get(error_code, '') + template.content = TEMPLATES.get(error_code, "") template.save() template.sites.add(site) if verbosity >= 1: - sys.stdout.write("Created database template " - "for %s errors.\n" % error_code) + sys.stdout.write( + "Created database template for %s errors.\n" % error_code + ) else: if verbosity >= 1: - sys.stderr.write("A template for %s errors " - "already exists.\n" % error_code) + sys.stderr.write( + "A template for %s errors already exists.\n" % error_code + ) diff --git a/dbtemplates/management/commands/sync_templates.py b/dbtemplates/management/commands/sync_templates.py index 7e336e0..1cad812 100644 --- a/dbtemplates/management/commands/sync_templates.py +++ b/dbtemplates/management/commands/sync_templates.py @@ -1,4 +1,5 @@ import os +from pathlib import Path from dbtemplates.models import Template from django.contrib.sites.models import Site @@ -86,19 +87,19 @@ def handle(self, **options): tpl_dirs = app_template_dirs + DIRS else: tpl_dirs = DIRS + app_template_dirs - templatedirs = [str(d) for d in tpl_dirs if os.path.isdir(d)] + templatedirs = [Path(d) for d in tpl_dirs if Path(d).is_dir()] for templatedir in templatedirs: + # TODO: Replace os.walk(templatedir) with templatedir.walk(follow_symlinks=True) + # once we only support Python 3.12+ for dirpath, subdirs, filenames in os.walk(templatedir): for f in [ f for f in filenames if f.endswith(extension) and not f.startswith(".") ]: - path = os.path.join(dirpath, f) - name = path.split(str(templatedir))[1] - if name.startswith("/"): - name = name[1:] + path = Path(dirpath) / f + name = path.relative_to(templatedir) try: t = Template.on_site.get(name__exact=name) except Template.DoesNotExist: @@ -110,9 +111,9 @@ def handle(self, **options): "" % (name, path) ) if force or confirm.lower().startswith("y"): - with open(path, encoding="utf-8") as f: - t = Template(name=name, content=f.read()) - t.save() + t = Template.objects.create( + name=name, content=path.read_text(encoding="utf-8") + ) t.sites.add(site) else: while True: @@ -134,20 +135,18 @@ def handle(self, **options): DATABASE_TO_FILES, ): if confirm == FILES_TO_DATABASE: - with open(path, encoding="utf-8") as f: - t.content = f.read() - t.save() - t.sites.add(site) + t.content = path.read_text(encoding="utf-8") + t.save() + t.sites.add(site) if delete: try: - os.remove(path) + path.unlink(missing_ok=True) except OSError: raise CommandError( f"Couldn't delete {path}" ) elif confirm == DATABASE_TO_FILES: - with open(path, "w", encoding="utf-8") as f: # noqa - f.write(t.content) + path.write_text(t.content, encoding="utf-8") if delete: t.delete() break diff --git a/dbtemplates/migrations/0001_initial.py b/dbtemplates/migrations/0001_initial.py index 7ac217f..b0e5dab 100644 --- a/dbtemplates/migrations/0001_initial.py +++ b/dbtemplates/migrations/0001_initial.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("sites", "0001_initial"), ] diff --git a/dbtemplates/migrations/0002_alter_template_creation_date_and_more.py b/dbtemplates/migrations/0002_alter_template_creation_date_and_more.py index 61cb561..73d4a0d 100644 --- a/dbtemplates/migrations/0002_alter_template_creation_date_and_more.py +++ b/dbtemplates/migrations/0002_alter_template_creation_date_and_more.py @@ -4,20 +4,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('dbtemplates', '0001_initial'), + ("dbtemplates", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='template', - name='creation_date', - field=models.DateTimeField(auto_now_add=True, verbose_name='creation date'), + model_name="template", + name="creation_date", + field=models.DateTimeField(auto_now_add=True, verbose_name="creation date"), ), migrations.AlterField( - model_name='template', - name='last_changed', - field=models.DateTimeField(auto_now=True, verbose_name='last changed'), + model_name="template", + name="last_changed", + field=models.DateTimeField(auto_now=True, verbose_name="last changed"), ), ] diff --git a/dbtemplates/models.py b/dbtemplates/models.py index aa87dcb..62f4a1c 100644 --- a/dbtemplates/models.py +++ b/dbtemplates/models.py @@ -18,24 +18,26 @@ class Template(models.Model): Defines a template model for use with the database template loader. The field ``name`` is the equivalent to the filename of a static template. """ - id = models.AutoField(primary_key=True, verbose_name=_('ID'), - serialize=False, auto_created=True) - name = models.CharField(_('name'), max_length=100, - help_text=_("Example: 'flatpages/default.html'")) - content = models.TextField(_('content'), blank=True) - sites = models.ManyToManyField(Site, verbose_name=_('sites'), - blank=True) - creation_date = models.DateTimeField(_('creation date'), auto_now_add=True) - last_changed = models.DateTimeField(_('last changed'), auto_now=True) + + id = models.AutoField( + primary_key=True, verbose_name=_("ID"), serialize=False, auto_created=True + ) + name = models.CharField( + _("name"), max_length=100, help_text=_("Example: 'flatpages/default.html'") + ) + content = models.TextField(_("content"), blank=True) + sites = models.ManyToManyField(Site, verbose_name=_("sites"), blank=True) + creation_date = models.DateTimeField(_("creation date"), auto_now_add=True) + last_changed = models.DateTimeField(_("last changed"), auto_now=True) objects = models.Manager() - on_site = CurrentSiteManager('sites') + on_site = CurrentSiteManager("sites") class Meta: - db_table = 'django_template' - verbose_name = _('template') - verbose_name_plural = _('templates') - ordering = ('name',) + db_table = "django_template" + verbose_name = _("template") + verbose_name_plural = _("templates") + ordering = ("name",) def __str__(self): return self.name @@ -49,17 +51,10 @@ def populate(self, name=None): name = self.name try: source = get_template_source(name) - if source: - self.content = source except TemplateDoesNotExist: pass - - def save(self, *args, **kwargs): - # If content is empty look for a template with the given name and - # populate the template instance with its content. - if settings.DBTEMPLATES_AUTO_POPULATE_CONTENT and not self.content: - self.populate() - super().save(*args, **kwargs) + else: + self.content = source def add_default_site(instance, **kwargs): @@ -68,13 +63,21 @@ def add_default_site(instance, **kwargs): in the database was added or changed, only if DBTEMPLATES_ADD_DEFAULT_SITE setting is set. """ - if not settings.DBTEMPLATES_ADD_DEFAULT_SITE: - return - current_site = Site.objects.get_current() - if current_site not in instance.sites.all(): - instance.sites.add(current_site) + instance.sites.add(Site.objects.get_current()) + + +def populate_empty_content(instance, **kwargs): + # If content is empty look for a template with the given name and + # populate the template instance with its content. + if not instance.content: + instance.populate() + + +if settings.DBTEMPLATES_ADD_DEFAULT_SITE: + signals.post_save.connect(add_default_site, sender=Template) +if settings.DBTEMPLATES_AUTO_POPULATE_CONTENT: + signals.pre_save.connect(populate_empty_content, sender=Template) -signals.post_save.connect(add_default_site, sender=Template) signals.post_save.connect(add_template_to_cache, sender=Template) signals.pre_delete.connect(remove_cached_template, sender=Template) diff --git a/dbtemplates/test_cases.py b/dbtemplates/test_cases.py index 062e2e5..0a2109d 100644 --- a/dbtemplates/test_cases.py +++ b/dbtemplates/test_cases.py @@ -1,157 +1,114 @@ -import os import shutil import tempfile +from contextlib import contextmanager +from pathlib import Path from unittest import mock -from django.conf import settings as django_settings from django.core.cache.backends.base import BaseCache from django.core.management import call_command +from django.db.models.signals import post_save from django.template import loader, TemplateDoesNotExist -from django.test import TestCase +from django.test import TestCase, modify_settings, override_settings +from django.test.signals import receiver, setting_changed from django.contrib.sites.models import Site from dbtemplates.conf import settings -from dbtemplates.models import Template -from dbtemplates.utils.cache import get_cache_backend, get_cache_key -from dbtemplates.utils.template import (get_template_source, - check_template_syntax) -from dbtemplates.management.commands.sync_templates import (FILES_TO_DATABASE, - DATABASE_TO_FILES) +from dbtemplates.loader import Loader +from dbtemplates.models import Template, add_default_site +from dbtemplates.utils.cache import ( + cache, + get_cache_backend, + get_cache_key, + set_and_return, +) +from dbtemplates.utils.template import get_template_source, check_template_syntax +from dbtemplates.management.commands import sync_templates -class DbTemplatesTestCase(TestCase): - def setUp(self): - self.old_TEMPLATES = settings.TEMPLATES - if 'dbtemplates.loader.Loader' not in settings.TEMPLATES: - loader.template_source_loaders = None - settings.TEMPLATES = list(settings.TEMPLATES) + [ - 'dbtemplates.loader.Loader' - ] +@receiver(setting_changed) +def handle_add_default_site(sender, setting, value, **kwargs): + if setting == "DBTEMPLATES_ADD_DEFAULT_SITE": + if value: + post_save.connect(add_default_site, sender=Template) + else: + post_save.disconnect(add_default_site, sender=Template) + + +@contextmanager +def temptemplate(name: str, cleanup: bool = True): + temp_template_dir = Path(tempfile.mkdtemp("dbtemplates")) + temp_template_path = temp_template_dir / name + try: + yield temp_template_path + finally: + shutil.rmtree(temp_template_dir) + +class DbTemplatesCacheTestCase(TestCase): + def test_set_and_return(self): + self.assertTrue(bool(cache)) + rtn = set_and_return( + "this_is_the_cache_key", "cache test content", "cache display name" + ) + self.assertEqual(rtn, ("cache test content", "cache display name")) + self.assertEqual(cache.get("this_is_the_cache_key"), "cache test content") + + +class BaseDbTemplatesTestCase(TestCase): + @modify_settings( + TEMPLATES={ + "append": "dbtemplates.loader.Loader", + }, + ) + def setUp(self): self.site1, created1 = Site.objects.get_or_create( - domain="example.com", name="example.com") + domain="example.com", name="example.com" + ) self.site2, created2 = Site.objects.get_or_create( - domain="example.org", name="example.org") - self.t1, _ = Template.objects.get_or_create( - name='base.html', content='base') - self.t2, _ = Template.objects.get_or_create( - name='sub.html', content='sub') + domain="example.org", name="example.org" + ) + self.t1, _ = Template.objects.get_or_create(name="base.html", content="base") + self.t2, _ = Template.objects.get_or_create(name="sub.html", content="sub") self.t2.sites.add(self.site2) - def tearDown(self): - loader.template_source_loaders = None - settings.TEMPLATES = self.old_TEMPLATES - def test_basics(self): - self.assertEqual(list(self.t1.sites.all()), [self.site1]) - self.assertTrue("base" in self.t1.content) - self.assertEqual(list(Template.objects.filter(sites=self.site1)), - [self.t1, self.t2]) - self.assertEqual(list(self.t2.sites.all()), [self.site1, self.site2]) +class DbTemplatesLoaderTestCase(BaseDbTemplatesTestCase): + def test_load_and_store_template(self): + from django.template.loader import _engine_list + from django.core.cache import CacheKeyWarning - def test_empty_sites(self): - old_add_default_site = settings.DBTEMPLATES_ADD_DEFAULT_SITE - try: - settings.DBTEMPLATES_ADD_DEFAULT_SITE = False - self.t3 = Template.objects.create( - name='footer.html', content='footer') - self.assertEqual(list(self.t3.sites.all()), []) - finally: - settings.DBTEMPLATES_ADD_DEFAULT_SITE = old_add_default_site + loader = Loader(_engine_list()[0]) + with self.assertWarns(CacheKeyWarning): + rtn = loader._load_and_store_template( + "base.html", "base template cache key", self.site1 + ) + self.assertEqual(rtn, ("base", "dbtemplates:default:base.html:example.com")) + @override_settings(DBTEMPLATES_ADD_DEFAULT_SITE=False) def test_load_templates_sites(self): - old_add_default_site = settings.DBTEMPLATES_ADD_DEFAULT_SITE - old_site_id = django_settings.SITE_ID - try: - settings.DBTEMPLATES_ADD_DEFAULT_SITE = False - t_site1 = Template.objects.create( - name='copyright.html', content='(c) example.com') - t_site1.sites.add(self.site1) - t_site2 = Template.objects.create( - name='copyright.html', content='(c) example.org') - t_site2.sites.add(self.site2) - - django_settings.SITE_ID = Site.objects.create( - domain="example.net", name="example.net").id + t_site1 = Template.objects.create( + name="copyright.html", content="(c) example.com" + ) + t_site1.sites.add(self.site1) + t_site2 = Template.objects.create( + name="copyright.html", content="(c) example.org" + ) + t_site2.sites.add(self.site2) + + new_site = Site.objects.create(domain="example.net", name="example.net") + with self.settings(SITE_ID=new_site.id): Site.objects.clear_cache() - self.assertRaises(TemplateDoesNotExist, - loader.get_template, "copyright.html") - finally: - django_settings.SITE_ID = old_site_id - settings.DBTEMPLATES_ADD_DEFAULT_SITE = old_add_default_site + self.assertRaises( + TemplateDoesNotExist, loader.get_template, "copyright.html" + ) def test_load_templates(self): result = loader.get_template("base.html").render() - self.assertEqual(result, 'base') + self.assertEqual(result, "base") result2 = loader.get_template("sub.html").render() - self.assertEqual(result2, 'sub') - - def test_error_templates_creation(self): - call_command('create_error_templates', force=True, verbosity=0) - self.assertEqual(list(Template.objects.filter(sites=self.site1)), - list(Template.objects.filter())) - self.assertTrue(Template.objects.filter(name='404.html').exists()) - - def test_automatic_sync(self): - admin_base_template = get_template_source('admin/base.html') - template = Template.objects.create(name='admin/base.html') - self.assertEqual(admin_base_template, template.content) - - def test_sync_templates(self): - old_template_dirs = settings.TEMPLATES[0].get('DIRS', []) - temp_template_dir = tempfile.mkdtemp('dbtemplates') - temp_template_path = os.path.join(temp_template_dir, 'temp_test.html') - temp_template = open(temp_template_path, 'w', encoding='utf-8') - try: - temp_template.write('temp test') - settings.TEMPLATES[0]['DIRS'] = (temp_template_dir,) - # these works well if is not settings patched at runtime - # for supporting django < 1.7 tests we must patch dirs in runtime - from dbtemplates.management.commands import sync_templates - sync_templates.DIRS = settings.TEMPLATES[0]['DIRS'] - - self.assertFalse( - Template.objects.filter(name='temp_test.html').exists()) - call_command('sync_templates', force=True, - verbosity=0, overwrite=FILES_TO_DATABASE) - self.assertTrue( - Template.objects.filter(name='temp_test.html').exists()) - - t = Template.objects.get(name='temp_test.html') - t.content = 'temp test modified' - t.save() - call_command('sync_templates', force=True, - verbosity=0, overwrite=DATABASE_TO_FILES) - self.assertEqual('temp test modified', - open(temp_template_path, - encoding='utf-8').read()) - - call_command('sync_templates', force=True, verbosity=0, - delete=True, overwrite=DATABASE_TO_FILES) - self.assertTrue(os.path.exists(temp_template_path)) - self.assertFalse( - Template.objects.filter(name='temp_test.html').exists()) - finally: - temp_template.close() - settings.TEMPLATES[0]['DIRS'] = old_template_dirs - shutil.rmtree(temp_template_dir) - - def test_get_cache(self): - self.assertTrue(isinstance(get_cache_backend(), BaseCache)) - - def test_check_template_syntax(self): - bad_template, _ = Template.objects.get_or_create( - name='bad.html', content='{% if foo %}Bar') - good_template, _ = Template.objects.get_or_create( - name='good.html', content='{% if foo %}Bar{% endif %}') - self.assertFalse(check_template_syntax(bad_template)[0]) - self.assertTrue(check_template_syntax(good_template)[0]) - - def test_get_cache_name(self): - self.assertEqual(get_cache_key('name with spaces'), - 'dbtemplates::name-with-spaces::1') + self.assertEqual(result2, "sub") def test_cache_invalidation(self): # Add t1 into the cache of site2 @@ -173,3 +130,129 @@ def test_cache_invalidation(self): return_value=self.site2): result = loader.get_template("base.html").render() self.assertEqual(result, 'new content') + + +class DbTemplatesModelsTestCase(BaseDbTemplatesTestCase): + def test_basics(self): + self.assertQuerySetEqual( + self.t1.sites.all(), Site.objects.filter(id=self.site1.id) + ) + self.assertIn("base", self.t1.content) + self.assertEqual(str(self.t1), self.t1.name) + self.assertEqual(str(self.t2), self.t2.name) + self.assertQuerySetEqual( + Template.objects.filter(sites=self.site1), + Template.objects.filter(id__in=[self.t1.id, self.t2.id]), + ) + self.assertQuerySetEqual( + self.t2.sites.all(), + Site.objects.filter(id__in=[self.site1.id, self.site2.id]), + ) + + def test_populate(self): + t0 = Template.objects.create( + name="header.html", content="

This is a header

" + ) + t0.populate() + self.assertEqual(t0.content, "

This is a header

") + t0.populate(name="header.html") + self.assertEqual(t0.content, "

This is a header

") + + with temptemplate("temp_test.html") as temp_template_path: + temp_template_path.write_text("temp test") + (temp_template_path.parent / "temp_test_2.html").write_text("temp test 2") + NEW_TEMPLATES = settings.TEMPLATES.copy() + NEW_TEMPLATES[0]["DIRS"] = (temp_template_path.parent,) + with self.settings(TEMPLATES=NEW_TEMPLATES): + t1 = Template.objects.create(name="temp_test.html") + t1.populate() + self.assertEqual(t1.content, "temp test") + t2 = Template.objects.create(name="temp_test.html") + t2.populate(name="temp_test_2.html") + self.assertEqual(t2.content, "temp test 2") + t3 = Template.objects.create(name="temp_test_3.html") + self.assertIsNone(t3.populate(name="temp_test_doesnt_exist.html")) + self.assertEqual(t3.content, "") + + @override_settings(DBTEMPLATES_ADD_DEFAULT_SITE=False) + def test_empty_sites(self): + self.t3 = Template.objects.create(name="footer.html", content="footer") + self.assertQuerySetEqual(self.t3.sites.all(), self.t3.sites.none()) + + def test_error_templates_creation(self): + call_command("create_error_templates", force=True, verbosity=0) + self.assertQuerySetEqual( + Template.objects.filter(sites=self.site1), Template.objects.filter() + ) + self.assertTrue(Template.objects.filter(name="404.html").exists()) + + def test_automatic_sync(self): + admin_base_template = get_template_source("admin/base.html") + template = Template.objects.create(name="admin/base.html") + self.assertEqual(admin_base_template, template.content) + + def test_get_cache(self): + self.assertTrue(isinstance(get_cache_backend(), BaseCache)) + + def test_check_template_syntax(self): + bad_template, _ = Template.objects.get_or_create( + name="bad.html", content="{% if foo %}Bar" + ) + good_template, _ = Template.objects.get_or_create( + name="good.html", content="{% if foo %}Bar{% endif %}" + ) + self.assertFalse(check_template_syntax(bad_template)[0]) + self.assertTrue(check_template_syntax(good_template)[0]) + + def test_get_cache_name(self): + self.assertEqual( + get_cache_key("name with spaces"), "dbtemplates::name-with-spaces::1" + ) + + +class DbTemplatesSyncTemplatesCommandTestCase(TestCase): + def test_sync_templates(self): + with temptemplate("temp_test.html") as temp_template_path: + temp_template_path.write_text("temp test", encoding="utf-8") + NEW_TEMPLATES = settings.TEMPLATES.copy() + NEW_TEMPLATES[0]["DIRS"] = sync_templates.DIRS = ( + temp_template_path.parent, + ) + with self.settings(TEMPLATES=NEW_TEMPLATES): + self.assertFalse( + Template.objects.filter(name="temp_test.html").exists() + ) + call_command( + "sync_templates", + force=True, + verbosity=0, + overwrite=sync_templates.FILES_TO_DATABASE, + ) + self.assertTrue(Template.objects.filter(name="temp_test.html").exists()) + + t = Template.objects.get(name="temp_test.html") + t.content = "temp test modified" + t.save() + call_command( + "sync_templates", + force=True, + verbosity=0, + overwrite=sync_templates.DATABASE_TO_FILES, + ) + self.assertEqual( + "temp test modified", temp_template_path.read_text(encoding="utf-8") + ) + + call_command( + "sync_templates", + ext=".html", + app_first=True, + force=True, + verbosity=0, + delete=True, + overwrite=sync_templates.DATABASE_TO_FILES, + ) + self.assertTrue(temp_template_path.exists()) + self.assertFalse( + Template.objects.filter(name="temp_test.html").exists() + ) diff --git a/dbtemplates/test_settings.py b/dbtemplates/test_settings.py index 198fdde..d39673d 100644 --- a/dbtemplates/test_settings.py +++ b/dbtemplates/test_settings.py @@ -1,51 +1,55 @@ -DBTEMPLATES_CACHE_BACKEND = 'dummy://' - -DATABASE_ENGINE = 'sqlite3' +DATABASE_ENGINE = "sqlite3" # SQLite does not support removing unique constraints (see #28) SOUTH_TESTS_MIGRATE = False SITE_ID = 1 -SECRET_KEY = 'something-something' +SECRET_KEY = "something-something" + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + }, +} DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", } } INSTALLED_APPS = [ - 'django.contrib.contenttypes', - 'django.contrib.sites', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.admin', - 'django.contrib.auth', - 'dbtemplates', + "django.contrib.contenttypes", + "django.contrib.sites", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.admin", + "django.contrib.auth", + "dbtemplates", ] MIDDLEWARE = ( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", ) TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - 'dbtemplates.loader.Loader', + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + "dbtemplates.loader.Loader", ) TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'OPTIONS': { - 'loaders': TEMPLATE_LOADERS, - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ] - } + "BACKEND": "django.template.backends.django.DjangoTemplates", + "OPTIONS": { + "loaders": TEMPLATE_LOADERS, + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, }, ] diff --git a/dbtemplates/utils/cache.py b/dbtemplates/utils/cache.py index 89039ab..96076b9 100644 --- a/dbtemplates/utils/cache.py +++ b/dbtemplates/utils/cache.py @@ -9,6 +9,7 @@ def get_cache_backend(): Compatibilty wrapper for getting Django's cache backend instance """ from django.core.cache import caches + cache = caches.create_connection(settings.DBTEMPLATES_CACHE_BACKEND) # Some caches -- python-memcached in particular -- need to do a cleanup at @@ -21,14 +22,13 @@ def get_cache_backend(): cache = get_cache_backend() -def get_cache_key(name, site=None): - if site is None: - site = Site.objects.get_current() - return f"dbtemplates::{slugify(name)}::{site.pk}" +def get_cache_key(name, current_site=None): + current_site = current_site or Site.objects.get_current() + return f"dbtemplates::{slugify(name)}::{current_site.pk}" -def get_cache_notfound_key(name): - return get_cache_key(name) + "::notfound" +def get_cache_notfound_key(name, current_site=None): + return get_cache_key(name, current_site=current_site) + "::notfound" def remove_notfound_key(instance): @@ -59,4 +59,4 @@ def remove_cached_template(instance, **kwargs): in the database was changed or deleted. """ for site in instance.sites.all(): - cache.delete(get_cache_key(instance.name, site=site)) + cache.delete(get_cache_key(instance.name, current_site=site)) diff --git a/dbtemplates/utils/template.py b/dbtemplates/utils/template.py index 662b4f6..e8d3be4 100644 --- a/dbtemplates/utils/template.py +++ b/dbtemplates/utils/template.py @@ -1,9 +1,9 @@ -from django.template import (Template, TemplateDoesNotExist, - TemplateSyntaxError) +from django.template import Template, TemplateDoesNotExist, TemplateSyntaxError def get_loaders(): from django.template.loader import _engine_list + loaders = [] for engine in _engine_list(): loaders.extend(engine.engine.template_loaders) @@ -12,17 +12,21 @@ def get_loaders(): def get_template_source(name): source = None + not_found = [] for loader in get_loaders(): - if loader.__module__.startswith('dbtemplates.'): + if loader.__module__.startswith("dbtemplates."): # Don't give a damn about dbtemplates' own loader. continue for origin in loader.get_template_sources(name): try: source = loader.get_contents(origin) - except (NotImplementedError, TemplateDoesNotExist): + except (NotImplementedError, TemplateDoesNotExist) as exc: + if exc.args[0] not in not_found: + not_found.append(exc.args[0]) continue - if source: + else: return source + raise TemplateDoesNotExist(name, chain=not_found) def check_template_syntax(template): diff --git a/docs/conf.py b/docs/conf.py index 2055241..6b2422f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,29 +15,29 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.append(os.path.abspath('.')) +sys.path.append(os.path.abspath(".")) # -- General configuration ----------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage'] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.coverage"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.txt' +source_suffix = ".txt" # The encoding of source files. -#source_encoding = 'utf-8' +# source_encoding = 'utf-8' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'django-dbtemplates' -copyright = '2007-2019, Jannis Leidel and contributors' +project = "django-dbtemplates" +copyright = "2007-2019, Jannis Leidel and contributors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -46,61 +46,62 @@ # The short X.Y version. try: from dbtemplates import __version__ + # The short X.Y version. - version = '.'.join(__version__.split('.')[:2]) + version = ".".join(__version__.split(".")[:2]) # The full version, including alpha/beta/rc tags. release = __version__ except ImportError: - version = release = 'dev' + version = release = "dev" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of documents that shouldn't be included in the build. -#unused_docs = [] +# unused_docs = [] # List of directories, relative to source directory, that shouldn't be searched # for source files. -exclude_trees = ['_build'] +exclude_trees = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. -html_theme = 'default' +html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = ['_theme'] @@ -114,12 +115,12 @@ # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -128,71 +129,76 @@ # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_use_modindex = True +# html_use_modindex = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = '' +# html_file_suffix = '' # Output file base name for HTML help builder. -htmlhelp_basename = 'django-dbtemplatesdoc' +htmlhelp_basename = "django-dbtemplatesdoc" # -- Options for LaTeX output -------------------------------------------------- # The paper size ('letter' or 'a4'). -#latex_paper_size = 'letter' +# latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). -#latex_font_size = '10pt' +# latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'django-dbtemplates.tex', 'django-dbtemplates Documentation', - 'Jannis Leidel and contributors', 'manual'), + ( + "index", + "django-dbtemplates.tex", + "django-dbtemplates Documentation", + "Jannis Leidel and contributors", + "manual", + ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # Additional stuff for the LaTeX preamble. -#latex_preamble = '' +# latex_preamble = '' # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_use_modindex = True +# latex_use_modindex = True