diff --git a/src/strass/live_settings/__init__.py b/src/strass/live_settings/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0f496f40992ff686bda9681882f3e820436a5aae --- /dev/null +++ b/src/strass/live_settings/__init__.py @@ -0,0 +1,27 @@ +from django.core.cache import cache +from django.db import OperationalError, ProgrammingError + + +class LiveSettings(object): + def __setattr__(self, key, value, *args, **kwargs): + try: + from live_settings.models import LiveSettings + + LiveSettings.objects.update_or_create(key=key, defaults=dict(value=value)) + except (ProgrammingError, OperationalError): + return super().__setattr__(key, value) + + def __getattribute__(self, key): + live_settings_dict = cache.get("live_settings_dict") + if live_settings_dict is None: + try: + from live_settings.models import LiveSettings + + live_settings_dict = dict(LiveSettings.objects.values_list("key", "value")) + cache.set("live_settings_dict", live_settings_dict) + except (ProgrammingError, OperationalError): + return super().__getattribute__(key) + return live_settings_dict.get(key, None) + + +live_settings = LiveSettings() diff --git a/src/strass/live_settings/admin.py b/src/strass/live_settings/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..31c733caf05f3f49bceb70104dab46dd989deab8 --- /dev/null +++ b/src/strass/live_settings/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from live_settings import models + + +class LiveSettingsAdmin(admin.ModelAdmin): + list_display = ("key", "last_edition_date") + readonly_fields = ["last_edition_date"] + + +admin.site.register(models.LiveSettings, LiveSettingsAdmin) diff --git a/src/strass/live_settings/context_processors.py b/src/strass/live_settings/context_processors.py new file mode 100644 index 0000000000000000000000000000000000000000..9fc38cc3fe1c0f34998ba57df5194cbbe2655749 --- /dev/null +++ b/src/strass/live_settings/context_processors.py @@ -0,0 +1,5 @@ +from live_settings import live_settings + + +def processors(request): + return dict(live_settings=live_settings) diff --git a/src/strass/live_settings/forms.py b/src/strass/live_settings/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..c636c9a796c9956a2122bb8dc100043cc0069844 --- /dev/null +++ b/src/strass/live_settings/forms.py @@ -0,0 +1,12 @@ +from django import forms + +from live_settings import models + + +class LiveSettingsForm(forms.ModelForm): + next = forms.CharField(widget=forms.HiddenInput(), required=True) + + class Meta: + model = models.LiveSettings + fields = ("value",) + widgets = {"value": forms.HiddenInput()} diff --git a/src/strass/live_settings/migrations/0001_initial.py b/src/strass/live_settings/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..cabe4e90c83a589304b6d4a795de420cfbfd3a7c --- /dev/null +++ b/src/strass/live_settings/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.5 on 2019-11-20 10:20 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='LiveSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=256, unique=True, validators=[django.core.validators.RegexValidator(code='invalid_live_settings_key', message='LiveSettings key must must be Alphanumeric and start with a letter: <code>^[a-zA-Z][\\w]*$</code>.', regex='^[a-zA-Z][\\w]*$')])), + ('value', models.TextField(blank=True, null=True)), + ('last_edition_date', models.DateTimeField()), + ], + ), + ] diff --git a/src/strass/live_settings/migrations/0002_create_group_settings_changer.py b/src/strass/live_settings/migrations/0002_create_group_settings_changer.py new file mode 100644 index 0000000000000000000000000000000000000000..efcdf440c9c75a6b329f2caf6f22abebc6a72982 --- /dev/null +++ b/src/strass/live_settings/migrations/0002_create_group_settings_changer.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.contrib.auth import get_permission_codename +from django.contrib.auth.models import Permission, Group +from django.contrib.contenttypes.models import ContentType +from django.db import migrations + + +def migration_code(apps, schema_editor): + # inspired by django.contrib.auth.management.create_permissions + group, _ = Group.objects.get_or_create(name="LiveSettingEditor") + + for klass in [ + apps.get_model("live_settings", "LiveSettings"), + ]: + ct = ContentType.objects.get_for_model(klass) + opts = klass._meta + + for action in klass._meta.default_permissions: + perm, _ = Permission.objects.get_or_create( + codename=get_permission_codename(action, opts), + name='Can %s %s' % (action, opts.verbose_name_raw), + content_type=ct) + group.permissions.add(perm) + + +def reverse_code(apps, schema_editor): + Group.objects.filter(name="LiveSettingEditor").delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('live_settings', '0001_initial'), + ] + + operations = [ + migrations.RunPython(migration_code, reverse_code=reverse_code), + ] diff --git a/src/strass/live_settings/migrations/0003_auto_20200311_1037.py b/src/strass/live_settings/migrations/0003_auto_20200311_1037.py new file mode 100644 index 0000000000000000000000000000000000000000..498cafe9b0b7b96e8db499fff04b4b38c307d4a1 --- /dev/null +++ b/src/strass/live_settings/migrations/0003_auto_20200311_1037.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.5 on 2020-03-11 09:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('live_settings', '0002_create_group_settings_changer'), + ] + + operations = [ + migrations.AlterModelOptions( + name='livesettings', + options={'verbose_name': 'Live setting', 'verbose_name_plural': 'Live settings'}, + ), + ] diff --git a/src/strass/live_settings/migrations/__init__.py b/src/strass/live_settings/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/strass/live_settings/models.py b/src/strass/live_settings/models.py new file mode 100644 index 0000000000000000000000000000000000000000..bec35c8903b6424cb1536c9d0b083f5d1f70fc65 --- /dev/null +++ b/src/strass/live_settings/models.py @@ -0,0 +1,45 @@ +from __future__ import unicode_literals + +from django.core.cache import cache +from django.core.validators import RegexValidator +from django.db import models +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from django.utils import timezone +from django.utils.safestring import mark_safe + + +class LiveSettings(models.Model): + class Meta: + verbose_name = "Live setting" + verbose_name_plural = "Live settings" + + regex = "^[a-zA-Z][\\w]*$" + key = models.CharField( + max_length=256, + unique=True, + validators=( + RegexValidator( + regex=regex, + message=mark_safe( + "LiveSettings key must must be Alphanumeric and start with a letter: " "<code>%s</code>." % regex + ), + code="invalid_live_settings_key", + ), + ), + ) + value = models.TextField( + blank=True, + null=True, + ) + last_edition_date = models.DateTimeField() + + def save(self, *args, **kwargs): + self.last_edition_date = timezone.now() + super().save(*args, **kwargs) + + +@receiver(post_save, sender=LiveSettings) +@receiver(post_delete, sender=LiveSettings) +def flush_live_settings_in_cache(*args, **kwargs): + cache.delete("live_settings_dict") diff --git a/src/strass/live_settings/templates/admin/index.html b/src/strass/live_settings/templates/admin/index.html new file mode 100644 index 0000000000000000000000000000000000000000..8517f906f3da53f1eb7c2a3a76143ae720fa0b3a --- /dev/null +++ b/src/strass/live_settings/templates/admin/index.html @@ -0,0 +1,57 @@ +{% extends "admin/index.html" %} +{% load i18n static basetheme_bootstrap %} + +{% block extrastyle %} +{{ block.super }} +<style> +#content-related{ + background: none; +} +#content-related .module{ + background: #f8f8f8; +} +#site-wide-commands form{ + margin:15px 7px; +} +</style> +{% endblock %} + +{% block sidebar %} +<div id="content-related"> + {% if perms.live_settings.change_livesettings %} + <div class="module" id="site-wide-commands"> + <h2>{% trans 'Site wide settings' %}</h2> + {%include_if_exists "live_settings/sitewidecommands.html" %} + </div> + {%endif%} + + <div class="module" id="recent-actions-module"> + <h2>{% trans 'Recent actions' %}</h2> + <h3>{% trans 'My actions' %}</h3> + {% load log %} + {% get_admin_log 10 as admin_log for_user user %} + {% if not admin_log %} + <p>{% trans 'None available' %}</p> + {% else %} + <ul class="actionlist"> + {% for entry in admin_log %} + <li class="{% if entry.is_addition %}addlink{% endif %}{% if entry.is_change %}changelink{% endif %}{% if entry.is_deletion %}deletelink{% endif %}"> + {% if entry.is_deletion or not entry.get_admin_url %} + {{ entry.object_repr }} + {% else %} + <a href="{{ entry.get_admin_url }}">{{ entry.object_repr }}</a> + {% endif %} + <br> + {% if entry.content_type %} + <span class="mini quiet">{% filter capfirst %}{{ entry.content_type }}{% endfilter %}</span> + {% else %} + <span class="mini quiet">{% trans 'Unknown content' %}</span> + {% endif %} + </li> + {% endfor %} + </ul> + {% endif %} + <hr/> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/src/strass/live_settings/tests.py b/src/strass/live_settings/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..23ae0c55111275222d707473d987307197560dd2 --- /dev/null +++ b/src/strass/live_settings/tests.py @@ -0,0 +1,76 @@ +import logging +from unittest import TestCase + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.core import management +from django.core.cache import cache +from django.test import TestCase as DjangoTestCase +from django.urls import reverse + +from live_settings import live_settings, models + +logger = logging.getLogger(__name__) + + +class LiveSettingsTestCase(DjangoTestCase): + def setUp(self) -> None: + super().setUp() + ################################################################################ + self.user = get_user_model().objects.create( + username="root", + ) + + def test_get_value(self): + self.assertIsNone(live_settings.toto) + models.LiveSettings.objects.create(key="toto", value="tata") + self.assertIsNotNone(live_settings.toto) + self.assertEqual(live_settings.toto, "tata") + + def test_get_set(self): + live_settings.tralala = 1 + self.assertEqual(str(live_settings.tralala), "1") + + def test_view_works(self): + form_data = dict(value="titi", next="/") + url = reverse('live_settings:update', args=["toto"]) + + response = self.client.post(url, form_data) + self.assertEqual(response.status_code, 302) + self.assertNotEqual(live_settings.toto, form_data["value"]) + + self.client.force_login(self.user) + + response = self.client.post(url, form_data) + self.assertEqual(response.status_code, 302) + self.assertNotEqual(live_settings.toto, form_data["value"]) + + change = Permission.objects.get( + content_type=ContentType.objects.get_for_model(models.LiveSettings), codename__startswith="change" + ) + self.user.user_permissions.add(change) + + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + self.assertNotEqual(live_settings.toto, form_data["value"]) + + response = self.client.post(url, dict(value=form_data["value"])) + self.assertEqual(response.status_code, 400) + self.assertNotEqual(live_settings.toto, form_data["value"]) + + response = self.client.post(url, form_data) + self.assertEqual(response.status_code, 302) + self.assertEqual(live_settings.toto, form_data["value"]) + + +class LiveSettingsNoDBTestCase(TestCase): + def test_get_value(self): + management.call_command("migrate", "live_settings", "zero", no_input=True, skip_checks=True) + cache.delete("live_settings_dict") + try: + ex = live_settings.tralala + except Exception as e: + ex = e + assert isinstance(ex, AttributeError) + live_settings.tralala = 1 diff --git a/src/strass/live_settings/urls.py b/src/strass/live_settings/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..b638d2d812328bf510e7dbd7f8b8e563c6bcf8c0 --- /dev/null +++ b/src/strass/live_settings/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls import url + +from live_settings import views + +app_name = "live_settings" +urlpatterns = [url(r"^update/(?P<slug>[a-zA-Z][\w]*)/$", views.change_value, name="update")] diff --git a/src/strass/live_settings/views.py b/src/strass/live_settings/views.py new file mode 100644 index 0000000000000000000000000000000000000000..2a20f019516b6b8080feb0ff1bbf4241fbe82f68 --- /dev/null +++ b/src/strass/live_settings/views.py @@ -0,0 +1,20 @@ +from django.contrib.auth.decorators import permission_required +from django.http import HttpResponseForbidden, HttpResponseBadRequest +from django.shortcuts import redirect + +from live_settings import forms +from live_settings import live_settings + + +@permission_required("live_settings.change_livesettings") +def change_value(request, slug): + if request.method != "POST": + return HttpResponseForbidden() + + form = forms.LiveSettingsForm(data=request.POST) + if not form.is_valid(): + return HttpResponseBadRequest() + + setattr(live_settings, slug, form.cleaned_data["value"]) + + return redirect(form.cleaned_data["next"]) diff --git a/src/strass/strass/settings.py b/src/strass/strass/settings.py index 41286e21bfd71abe74028a46363cd298de1ddf47..187741a6b8442b939875fc77b9198cea56851d35 100644 --- a/src/strass/strass/settings.py +++ b/src/strass/strass/settings.py @@ -38,6 +38,7 @@ DEFAULT_DOMAIN = 'https://{}'.format(ALLOWED_HOSTS[0]) # Application definition INSTALLED_APPS = [ + 'live_settings', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -76,6 +77,7 @@ TEMPLATES = [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'basetheme_bootstrap.context_processors.processors', + "live_settings.context_processors.processors", ], }, }, diff --git a/src/strass/strass/urls.py b/src/strass/strass/urls.py index eae1fb1ec91b5dbd3cae9dca75f2e93fcc0ddd8c..57a694c8af30987025646557f13bf6eb5f4a75a2 100644 --- a/src/strass/strass/urls.py +++ b/src/strass/strass/urls.py @@ -18,6 +18,7 @@ from django.urls import path, include urlpatterns = [ path('', include('basetheme_bootstrap.urls')), + path('live_settings/', include("live_settings.urls")), path('', include('strass_app.urls')), path('admin/', admin.site.urls), ]