This commit is contained in:
blag 2026-04-19 22:52:49 +02:00 committed by GitHub
commit 7d014a4ee8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 526 additions and 374 deletions

View file

@ -14,8 +14,7 @@ from dbtemplates.utils.template import check_template_syntax
# 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 @@ else:
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("""
"""
<script type="text/javascript">
var editor = CodeMirror.fromTextArea('id_%(name)s', {
path: "%(media_prefix)sjs/",
@ -51,7 +50,9 @@ class CodeMirrorTextArea(forms.Textarea):
lineNumbers: true
});
</script>
""" % 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 @@ else:
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 @@ class TemplateAdmin(TemplateModelAdmin):
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 @@ class TemplateAdmin(TemplateModelAdmin):
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)

View file

@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
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"

View file

@ -33,35 +33,45 @@ class DbTemplatesConf(AppConf):
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

View file

@ -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 @@ class Loader(BaseLoader):
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 @@ class Loader(BaseLoader):
# 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)

View file

@ -12,8 +12,9 @@ class Command(BaseCommand):
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")

View file

@ -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
)

View file

@ -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 @@ class Command(BaseCommand):
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 @@ class Command(BaseCommand):
"" % (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 @@ class Command(BaseCommand):
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

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("sites", "0001_initial"),
]

View file

@ -4,20 +4,19 @@ from django.db import migrations, models
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"),
),
]

View file

@ -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 @@ class Template(models.Model):
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())
signals.post_save.connect(add_default_site, sender=Template)
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_template_to_cache, sender=Template)
signals.pre_delete.connect(remove_cached_template, sender=Template)

View file

@ -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):
@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.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'
]
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)
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
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 @@ class DbTemplatesTestCase(TestCase):
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="<h1>This is a header</h1>"
)
t0.populate()
self.assertEqual(t0.content, "<h1>This is a header</h1>")
t0.populate(name="header.html")
self.assertEqual(t0.content, "<h1>This is a header</h1>")
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()
)

View file

@ -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",
],
},
},
]

View file

@ -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))

View file

@ -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):

View file

@ -15,29 +15,29 @@ import sys, os
# 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 @@ copyright = '2007-2019, Jannis Leidel and contributors'
# 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 @@ html_short_title = "django-dbtemplates"
# 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 @@ html_short_title = "django-dbtemplates"
# 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 <link> 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