From 6fcce6de0aeaaf706b42d7f1d148f180edc54058 Mon Sep 17 00:00:00 2001
From: Bryan Brancotte <bryan.brancotte@pasteur.fr>
Date: Thu, 1 Oct 2020 14:20:51 +0200
Subject: [PATCH] use live_settings

---
 src/strass/live_settings/__init__.py          | 27 +++++++
 src/strass/live_settings/admin.py             | 11 +++
 .../live_settings/context_processors.py       |  5 ++
 src/strass/live_settings/forms.py             | 12 +++
 .../live_settings/migrations/0001_initial.py  | 24 ++++++
 .../0002_create_group_settings_changer.py     | 39 ++++++++++
 .../migrations/0003_auto_20200311_1037.py     | 17 +++++
 .../live_settings/migrations/__init__.py      |  0
 src/strass/live_settings/models.py            | 45 +++++++++++
 .../live_settings/templates/admin/index.html  | 57 ++++++++++++++
 src/strass/live_settings/tests.py             | 76 +++++++++++++++++++
 src/strass/live_settings/urls.py              |  6 ++
 src/strass/live_settings/views.py             | 20 +++++
 src/strass/strass/settings.py                 |  2 +
 src/strass/strass/urls.py                     |  1 +
 15 files changed, 342 insertions(+)
 create mode 100644 src/strass/live_settings/__init__.py
 create mode 100644 src/strass/live_settings/admin.py
 create mode 100644 src/strass/live_settings/context_processors.py
 create mode 100644 src/strass/live_settings/forms.py
 create mode 100644 src/strass/live_settings/migrations/0001_initial.py
 create mode 100644 src/strass/live_settings/migrations/0002_create_group_settings_changer.py
 create mode 100644 src/strass/live_settings/migrations/0003_auto_20200311_1037.py
 create mode 100644 src/strass/live_settings/migrations/__init__.py
 create mode 100644 src/strass/live_settings/models.py
 create mode 100644 src/strass/live_settings/templates/admin/index.html
 create mode 100644 src/strass/live_settings/tests.py
 create mode 100644 src/strass/live_settings/urls.py
 create mode 100644 src/strass/live_settings/views.py

diff --git a/src/strass/live_settings/__init__.py b/src/strass/live_settings/__init__.py
new file mode 100644
index 00000000..0f496f40
--- /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 00000000..31c733ca
--- /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 00000000..9fc38cc3
--- /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 00000000..c636c9a7
--- /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 00000000..cabe4e90
--- /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 00000000..efcdf440
--- /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 00000000..498cafe9
--- /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 00000000..e69de29b
diff --git a/src/strass/live_settings/models.py b/src/strass/live_settings/models.py
new file mode 100644
index 00000000..bec35c89
--- /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 00000000..8517f906
--- /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 00000000..23ae0c55
--- /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 00000000..b638d2d8
--- /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 00000000..2a20f019
--- /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 41286e21..187741a6 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 eae1fb1e..57a694c8 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),
 ]
-- 
GitLab