From 38011d7300b68697656f9d9b423161d3827df6e4 Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Wed, 11 May 2022 12:27:24 +0200
Subject: [PATCH] start the maintenance application (#64868)

---
 hobo/maintenance/forms.py                     | 29 +++++++
 .../commands/disable_maintenance_page.py      | 17 ++++
 .../templates/hobo/maintenance/home.html      | 21 +++++
 .../hobo/maintenance/maintenance_page.html    |  8 ++
 hobo/maintenance/urls.py                      | 23 ++++++
 hobo/maintenance/views.py                     | 77 +++++++++++++++++++
 hobo/middleware/maintenance.py                |  9 ++-
 hobo/settings.py                              |  1 +
 hobo/templates/hobo/home.html                 |  2 +-
 hobo/urls.py                                  |  2 +
 hobo/views.py                                 |  1 +
 tests/test_maintenance.py                     | 64 ++++++++++++++-
 12 files changed, 249 insertions(+), 5 deletions(-)
 create mode 100644 hobo/maintenance/forms.py
 create mode 100644 hobo/maintenance/management/commands/disable_maintenance_page.py
 create mode 100644 hobo/maintenance/templates/hobo/maintenance/home.html
 create mode 100644 hobo/maintenance/templates/hobo/maintenance/maintenance_page.html
 create mode 100644 hobo/maintenance/urls.py
 create mode 100644 hobo/maintenance/views.py

diff --git a/hobo/maintenance/forms.py b/hobo/maintenance/forms.py
new file mode 100644
index 0000000..b265e46
--- /dev/null
+++ b/hobo/maintenance/forms.py
@@ -0,0 +1,29 @@
+# hobo - portal to configure and deploy applications
+# Copyright (C) 2015-2022  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+
+
+class MaintenanceForm(forms.Form):
+    maintenance_page = forms.BooleanField(required=False, label=_('Enable maintenance page'))
+    maintenance_page_message = forms.CharField(
+        required=False, widget=forms.Textarea, label=_('Maintenance page message')
+    )
+    maintenance_pass_trough_header = forms.CharField(
+        required=False, label=_('Maintenance HTTP header pass through')
+    )
+    disable_cron = forms.BooleanField(required=False, label=_('Disable cron jobs'))
diff --git a/hobo/maintenance/management/commands/disable_maintenance_page.py b/hobo/maintenance/management/commands/disable_maintenance_page.py
new file mode 100644
index 0000000..38275bf
--- /dev/null
+++ b/hobo/maintenance/management/commands/disable_maintenance_page.py
@@ -0,0 +1,17 @@
+from django.core.management.base import BaseCommand, CommandError
+
+from hobo.environment.models import Variable
+from hobo.environment.utils import get_setting_variable
+
+
+class Command(BaseCommand):
+    help = 'Toggle maintenance page'
+
+    def handle(self, *args, **options):
+        maintenance_page_variable = get_setting_variable('MAINTENANCE_PAGE')
+        if not bool(maintenance_page_variable.json):
+            self.stdout.write(self.style.SUCCESS('The maintenance page is already disabled.'))
+            return
+        maintenance_page_variable.json = False
+        maintenance_page_variable.save()
+        self.stdout.write(self.style.SUCCESS('Maintenance page disabled.'))
diff --git a/hobo/maintenance/templates/hobo/maintenance/home.html b/hobo/maintenance/templates/hobo/maintenance/home.html
new file mode 100644
index 0000000..cac78b6
--- /dev/null
+++ b/hobo/maintenance/templates/hobo/maintenance/home.html
@@ -0,0 +1,21 @@
+{% extends "hobo/base.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'maintenance-home' %}">{% trans "Maintenance" %}</a>
+{% endblock %}
+
+{% block appbar %}
+<h2>{% trans 'Maintenance' %}</h2>
+{% endblock %}
+
+{% block content %}
+<form action="." method="post">
+  {% csrf_token %}
+  {{ form.as_p }}
+  <div class="buttons">
+    <button>{% trans "Submit" %}</button>
+  </div>
+</form>
+{% endblock %}
diff --git a/hobo/maintenance/templates/hobo/maintenance/maintenance_page.html b/hobo/maintenance/templates/hobo/maintenance/maintenance_page.html
new file mode 100644
index 0000000..d5289aa
--- /dev/null
+++ b/hobo/maintenance/templates/hobo/maintenance/maintenance_page.html
@@ -0,0 +1,8 @@
+{% load i18n %}
+
+<html>
+  <body>
+    <h1>{% trans "This site is currently unavailable." %}</h1>
+    <p>{{ maintenance_message|default:"" }}</p>
+  </body>
+</html>
diff --git a/hobo/maintenance/urls.py b/hobo/maintenance/urls.py
new file mode 100644
index 0000000..0706594
--- /dev/null
+++ b/hobo/maintenance/urls.py
@@ -0,0 +1,23 @@
+# hobo - portal to configure and deploy applications
+# Copyright (C) 2015-2022 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django.conf.urls import url
+
+from . import views
+
+urlpatterns = [
+    url(r'^$', views.home, name='maintenance-home'),
+]
diff --git a/hobo/maintenance/views.py b/hobo/maintenance/views.py
new file mode 100644
index 0000000..4d4ab5c
--- /dev/null
+++ b/hobo/maintenance/views.py
@@ -0,0 +1,77 @@
+# hobo - portal to configure and deploy applications
+# Copyright (C) 2015-2022 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django.conf import settings
+from django.urls import reverse_lazy
+from django.utils.functional import cached_property
+from django.views.generic import FormView
+
+from hobo.environment.utils import get_setting_variable
+
+from .forms import MaintenanceForm
+
+
+class HomeView(FormView):
+    template_name = 'hobo/maintenance/home.html'
+    form_class = MaintenanceForm
+    success_url = reverse_lazy('maintenance-home')
+
+    @cached_property
+    def maintenance_page_variable(self):
+        return get_setting_variable('MAINTENANCE_PAGE')
+
+    @cached_property
+    def maintenance_page_message_variable(self):
+        return get_setting_variable('MAINTENANCE_PAGE_MESSAGE')
+
+    @cached_property
+    def maintenance_pass_trough_header_variable(self):
+        return get_setting_variable('MAINTENANCE_PASS_THROUGH_HEADER')
+
+    @cached_property
+    def tenant_disable_cron_jobs_variable(self):
+        return get_setting_variable('TENANT_DISABLE_CRON_JOBS')
+
+    def get_initial(self):
+        initial = super().get_initial()
+        initial['maintenance_page'] = bool(self.maintenance_page_variable.json)
+        initial['maintenance_page_message'] = self.maintenance_page_message_variable.value
+        initial['maintenance_pass_trough_header'] = self.maintenance_pass_trough_header_variable.value
+        initial['disable_cron'] = bool(self.tenant_disable_cron_jobs_variable.json)
+        return initial
+
+    def form_valid(self, form):
+        self.maintenance_page_variable.json = form.cleaned_data['maintenance_page']
+        self.maintenance_page_variable.save()
+        self.maintenance_page_message_variable.value = form.cleaned_data['maintenance_page_message']
+        self.maintenance_page_message_variable.save()
+
+        self.maintenance_pass_trough_header_variable.value = form.cleaned_data[
+            'maintenance_pass_trough_header'
+        ]
+        self.maintenance_pass_trough_header_variable.save()
+        self.tenant_disable_cron_jobs_variable.json = form.cleaned_data['disable_cron']
+        self.tenant_disable_cron_jobs_variable.save()
+        return super().form_valid(form)
+
+    def get_context_data(self, **kwargs):
+        ctx = super().get_context_data(**kwargs)
+        pass_through_ips_setting = getattr(settings, 'MAINTENANCE_PASS_THROUGH_IPS', [])
+        ctx['pass_through_ips'] = ', '.join(pass_through_ips_setting)
+        return ctx
+
+
+home = HomeView.as_view()
diff --git a/hobo/middleware/maintenance.py b/hobo/middleware/maintenance.py
index 192a153..d8a7025 100644
--- a/hobo/middleware/maintenance.py
+++ b/hobo/middleware/maintenance.py
@@ -15,7 +15,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from django.conf import settings
-from django.http import HttpResponse
+from django.template.response import TemplateResponse
 from django.utils.translation import ugettext as _
 
 
@@ -38,6 +38,9 @@ class MaintenanceMiddleware:
     def __call__(self, request):
         maintenance_mode = getattr(settings, 'MAINTENANCE_PAGE', None)
         if maintenance_mode and not pass_through(request):
-            maintenance_msg = _('The site is under maintenance')
-            return HttpResponse('<h1>%s</h1>' % maintenance_msg, status=503)
+            maintenance_message = getattr(settings, 'MAINTENANCE_PAGE_MESSAGE', '')
+            context = {'maintenance_message': maintenance_message}
+            return TemplateResponse(
+                request, 'hobo/maintenance/maintenance_page.html', context=context, status=503
+            ).render()
         return self.get_response(request)
diff --git a/hobo/settings.py b/hobo/settings.py
index 4939c29..94bb586 100644
--- a/hobo/settings.py
+++ b/hobo/settings.py
@@ -46,6 +46,7 @@ INSTALLED_APPS = (
     'hobo.debug',
     'hobo.environment',
     'hobo.franceconnect',
+    'hobo.maintenance',
     'hobo.matomo',
     'hobo.profile',
     'hobo.seo',
diff --git a/hobo/templates/hobo/home.html b/hobo/templates/hobo/home.html
index b4f0e82..95f5831 100644
--- a/hobo/templates/hobo/home.html
+++ b/hobo/templates/hobo/home.html
@@ -117,6 +117,6 @@ $(function() {
  <a class="button button-paragraph" href="{% url 'environment-home' %}">{% trans 'Services' %}</a>
  <a class="button button-paragraph" href="{% url 'environment-variables' %}">{% trans 'Variables' %}</a>
  <a class="button button-paragraph" href="{% url 'debug-home' %}">{% trans 'Debugging' %}</a>
-
+ {% if show_maintenance_menu %}<a class="button button-paragraph" href="{% url 'maintenance-home' %}">{% trans 'Maintenance' %}</a>{% endif %}
 </aside>
 {% endblock %}
diff --git a/hobo/urls.py b/hobo/urls.py
index daa1517..a4624f5 100644
--- a/hobo/urls.py
+++ b/hobo/urls.py
@@ -23,6 +23,7 @@ from .debug.urls import urlpatterns as debug_urls
 from .emails.urls import urlpatterns as emails_urls
 from .environment.urls import urlpatterns as environment_urls
 from .franceconnect.urls import urlpatterns as franceconnect_urls
+from .maintenance.urls import urlpatterns as maintenance_urls
 from .matomo.urls import urlpatterns as matomo_urls
 from .profile.urls import urlpatterns as profile_urls
 from .seo.urls import urlpatterns as seo_urls
@@ -45,6 +46,7 @@ urlpatterns = [
     url(r'^sms/', decorated_includes(admin_required, include(sms_urls))),
     url(r'^debug/', decorated_includes(admin_required, include(debug_urls))),
     url(r'^applications/', decorated_includes(admin_required, include(applications_urls))),
+    url(r'^maintenance/', decorated_includes(admin_required, include(maintenance_urls))),
     url(r'^api/health/$', health_json, name='health-json'),
     url(r'^menu.json$', menu_json, name='menu_json'),
     url(r'^hobos.json$', hobo),
diff --git a/hobo/views.py b/hobo/views.py
index 0e5e3b8..07f8258 100644
--- a/hobo/views.py
+++ b/hobo/views.py
@@ -39,6 +39,7 @@ class Home(TemplateView):
         context['has_authentic'] = bool(Authentic.objects.filter(secondary=False))
         context['has_global_title'] = Variable.objects.filter(name='global_title').exists()
         context['has_default_from_email'] = Variable.objects.filter(name='default_from_email').exists()
+        context['show_maintenance_menu'] = bool(getattr(settings, 'MAINTENANCE_PASS_THROUGH_IPS', []))
         return context
 
 
diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py
index 7ab4b07..a553c3c 100644
--- a/tests/test_maintenance.py
+++ b/tests/test_maintenance.py
@@ -1,6 +1,10 @@
 import mock
 from test_manager import login
 
+from hobo.environment.models import Variable
+from hobo.environment.utils import get_setting_variable
+from hobo.maintenance.management.commands.disable_maintenance_page import Command
+
 
 def test_maintenance_middleware(app, admin_user, db, monkeypatch, settings):
     app = login(app)
@@ -9,7 +13,12 @@ def test_maintenance_middleware(app, admin_user, db, monkeypatch, settings):
 
     settings.MAINTENANCE_PAGE = True
     resp = app.get('/', status=503)
-    assert 'The site is under maintenance' in resp.text
+    assert 'This site is currently unavailable.' in resp.text
+
+    # check custom maintenance message
+    settings.MAINTENANCE_PAGE_MESSAGE = 'foobar'
+    resp = app.get('/', status=503)
+    assert 'foobar' in resp.text
 
     settings.MAINTENANCE_PASS_THROUGH_IPS = ['127.0.0.1']
     resp = app.get('/')
@@ -21,3 +30,56 @@ def test_maintenance_middleware(app, admin_user, db, monkeypatch, settings):
     settings.MAINTENANCE_PASS_THROUGH_HEADER = 'X-Entrouvert'
     resp = app.get('/', headers={'X-Entrouvert': 'yes'})
     assert resp.status_code == 200
+
+
+def test_manage(app, admin_user, settings):
+    assert Variable.objects.filter(name='SETTING_MAINTENANCE_PAGE').count() == 0
+    assert Variable.objects.filter(name='SETTING_MAINTENANCE_MESSAGE').count() == 0
+    assert Variable.objects.filter(name='SETTING_MAINTENANCE_PASS_THROUGH_HEADER').count() == 0
+    assert Variable.objects.filter(name='TENANT_DISABLE_CRON_JOBS').count() == 0
+    assert not getattr(settings, 'MAINTENANCE_PASS_THROUGH_IPS', [])
+
+    login(app)
+    resp = app.get('/')
+    assert 'Maintenance' not in resp.text
+    settings.MAINTENANCE_PASS_THROUGH_IPS = ['127.0.0.1']
+    resp = app.get('/')
+    assert 'Maintenance' in resp.text
+
+    resp = app.get('/maintenance/')
+    resp.form.set('maintenance_page', True)
+    resp.form.set('maintenance_page_message', 'Foo')
+    resp.form.set('maintenance_pass_trough_header', 'X-Entrouvert')
+    resp.form.set('disable_cron', True)
+    resp = resp.form.submit().follow()
+    assert Variable.objects.filter(name='SETTING_MAINTENANCE_PAGE').get().value == 'true'
+    assert Variable.objects.filter(name='SETTING_MAINTENANCE_PAGE_MESSAGE').get().value == 'Foo'
+    assert (
+        Variable.objects.filter(name='SETTING_MAINTENANCE_PASS_THROUGH_HEADER').get().value == 'X-Entrouvert'
+    )
+    assert Variable.objects.filter(name='SETTING_TENANT_DISABLE_CRON_JOBS').get().value == 'true'
+
+    resp.form.set('maintenance_page', False)
+    resp.form.set('maintenance_page_message', '')
+    resp.form.set('maintenance_pass_trough_header', '')
+    resp.form.set('disable_cron', False)
+    resp = resp.form.submit().follow()
+    assert Variable.objects.filter(name='SETTING_MAINTENANCE_PAGE').get().value == 'false'
+    assert Variable.objects.filter(name='SETTING_MAINTENANCE_PAGE_MESSAGE').get().value == ''
+    assert Variable.objects.filter(name='SETTING_MAINTENANCE_PASS_THROUGH_HEADER').get().value == ''
+    assert Variable.objects.filter(name='SETTING_TENANT_DISABLE_CRON_JOBS').get().value == 'false'
+
+
+def test_disable_maintenance_page_command(db):
+    maintenance_page_variable = get_setting_variable('MAINTENANCE_PAGE')
+    assert not bool(maintenance_page_variable.json)
+    command = Command()
+    command.handle()
+    maintenance_page_variable = get_setting_variable('MAINTENANCE_PAGE')
+    assert not bool(maintenance_page_variable.json)
+    maintenance_page_variable.json = True
+    maintenance_page_variable.save()
+    assert bool(maintenance_page_variable.json)
+    command.handle()
+    maintenance_page_variable = get_setting_variable('MAINTENANCE_PAGE')
+    assert not bool(maintenance_page_variable.json)
-- 
2.30.2

