Repository: privacyidea/privacyidea

Issue:
Improve customization
The administrator should be able to improve the customization:

# What

* ~~Logo (we already do this)~~
* ~~CSS (we already do this)~~
* Links in the Footer (Dokumentation, Support...)
* Links in the right menu (Dokumentation, Support...)

# How

Customization could be done via:

* policies - this way we could also  show different menus to different user groups or different client IPs, since the conditions of policies apply.
* simple files
* configs in pi.cfg
* ...
Improve customization
The administrator should be able to improve the customization:

# What

* ~~Logo (we already do this)~~
* ~~CSS (we already do this)~~
* Links in the Footer (Dokumentation, Support...)
* Links in the right menu (Dokumentation, Support...)

# How

Customization could be done via:

* policies - this way we could also  show different menus to different user groups or different client IPs, since the conditions of policies apply.
* simple files
* configs in pi.cfg
* ...


Discussion:
We should also allow for a wider view, to use more of the screen to avoid scrolling.
If we simply want to make it possible to customize portions of the static HTML (i.e. ``privacyidea/static/templates/{index,footer,header}.html``), we could make use of Jinja's [template inheritance](http://jinja.pocoo.org/docs/2.9/templates/#template-inheritance):
We could, for example, define a block with a default content in our ``index.html``:
```jinja
<html>
...
{% block footer %}
our footer
{% endblock %}
...
</html>
```

And then allow users to define an template ``customized_index.html``:

```jinja
{% extends "index.html" %}
{% block footer %}
customized footer
{% block footer %}
```
We should also allow for a wider view, to use more of the screen to avoid scrolling.
If we simply want to make it possible to customize portions of the static HTML (i.e. ``privacyidea/static/templates/{index,footer,header}.html``), we could make use of Jinja's [template inheritance](http://jinja.pocoo.org/docs/2.9/templates/#template-inheritance):
We could, for example, define a block with a default content in our ``index.html``:
```jinja
<html>
...
{% block footer %}
our footer
{% endblock %}
...
</html>
```

And then allow users to define an template ``customized_index.html``:

```jinja
{% extends "index.html" %}
{% block footer %}
customized footer
{% block footer %}
```

Resolving patch:
diff --git a/privacyidea/lib/policy.py b/privacyidea/lib/policy.py
--- a/privacyidea/lib/policy.py
+++ b/privacyidea/lib/policy.py
@@ -1,5 +1,7 @@
 # -*- coding: utf-8 -*-
 #
+#  2017-11-14 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#             Add policy action for customization of menu and baseline
 #  2017-01-22 Cornelius Kölbel <cornelius.koelbel@netknights.it>
 #             Add policy action groups
 #  2016-12-19 Cornelius Kölbel <cornelius.koelbel@netknights.it>
@@ -296,6 +298,8 @@ class ACTION(object):
     TIMEOUT_ACTION = "timeout_action"
     AUTH_CACHE = "auth_cache"
     HIDE_WELCOME = "hide_welcome_info"
+    CUSTOM_MENU = "custom_menu"
+    CUSTOM_BASELINE = "custom_baseline"
 
 
 class GROUP(object):
@@ -594,7 +598,6 @@ def get_action_values(self, action, scope=SCOPE.AUTHZ, realm=None,
         would return a list of allowed serials
 
         :param unique: if set, the function will raise an exception if more
-
             than one value is returned
         :param allow_white_space_in_action: Some policies like emailtext
             would allow entering text with whitespaces. These whitespaces
@@ -1624,6 +1627,14 @@ def get_static_policy_definitions(scope=None):
                 'desc': _("Set how many users should be displayed in the user "
                           "view on one page.")
             },
+            ACTION.CUSTOM_MENU: {
+                'type': 'str',
+                'desc': _("Use your own html template for the web UI menu.")
+            },
+            ACTION.CUSTOM_BASELINE: {
+                'type': 'str',
+                'desc': _("Use your own html template for the web UI baseline/footer.")
+            },
             ACTION.USERDETAILS: {
                 'type': 'bool',
                 'desc': _("Whether the user ID and the resolver should be "
diff --git a/privacyidea/lib/subscriptions.py b/privacyidea/lib/subscriptions.py
--- a/privacyidea/lib/subscriptions.py
+++ b/privacyidea/lib/subscriptions.py
@@ -158,8 +158,8 @@ def save_subscription(subscription):
 
 def get_subscription(application=None):
     """
-    Return a list of subscriptions for a certai application
-    If application is ommitted, all applications are returned.
+    Return a list of subscriptions for a certain application
+    If application is omitted, all applications are returned.
 
     :param application: Name of the application
     :return: list of subscription dictionaries
diff --git a/privacyidea/webui/login.py b/privacyidea/webui/login.py
--- a/privacyidea/webui/login.py
+++ b/privacyidea/webui/login.py
@@ -3,6 +3,8 @@
 # http://www.privacyidea.org
 # (c) cornelius kölbel, privacyidea.org
 #
+# 2017-11-14 Cornelius Kölbel <cornelius.koelbel@netknights.it>
+#            Add custom baseline and menu
 # 2016-01-07 Cornelius Kölbel <cornelius@privacyidea.org>
 #            Add password reset
 # 2015-11-04 Cornelius Kölbel <cornelius@privacyidea.org>
@@ -26,7 +28,7 @@
 Other html code is dynamically loaded via angularJS and located in
 /static/views/...
 """
-__author__ = "Cornelius Kölbel, <cornelius@privacyidea.org>"
+__author__ = "Cornelius Kölbel <cornelius@privacyidea.org>"
 
 from flask import (Blueprint, render_template, request,
                    current_app)
@@ -35,6 +37,7 @@
 from privacyidea.lib.error import HSMException
 from privacyidea.lib.realm import get_realms
 from privacyidea.lib.policy import PolicyClass, ACTION, SCOPE
+from privacyidea.lib.subscriptions import subscription_status
 
 DEFAULT_THEME = "/static/contrib/css/bootstrap-theme.css"
 
@@ -107,6 +110,30 @@ def single_page_application():
     except HSMException:
         hsm_ready = False
 
+    # Use policies to determine the customization of menu
+    # and baseline. get_action_values returns an array!
+    sub_state  = subscription_status()
+    customization_menu_file = policy_object.get_action_values(
+        allow_white_space_in_action=True,
+        action=ACTION.CUSTOM_MENU,
+        scope=SCOPE.WEBUI,
+        client=client_ip, unique=True)
+    if len(customization_menu_file) and customization_menu_file[0] \
+            and sub_state not in [1, 2]:
+        customization_menu_file = customization_menu_file[0]
+    else:
+        customization_menu_file = "templates/menu.html"
+    customization_baseline_file = policy_object.get_action_values(
+        allow_white_space_in_action=True,
+        action=ACTION.CUSTOM_BASELINE,
+        scope=SCOPE.WEBUI,
+        client=client_ip, unique=True)
+    if len(customization_baseline_file) and customization_baseline_file[0] \
+            and sub_state not in [1, 2]:
+        customization_baseline_file = customization_baseline_file[0]
+    else:
+        customization_baseline_file = "templates/baseline.html"
+
     return render_template("index.html", instance=instance,
                            backendUrl=backend_url,
                            browser_lang=browser_lang,
@@ -115,6 +142,8 @@ def single_page_application():
                            password_reset=password_reset,
                            hsm_ready=hsm_ready,
                            customization=customization,
+                           customization_menu_file=customization_menu_file,
+                           customization_baseline_file=customization_baseline_file,
                            realms=realms,
                            external_links=external_links,
                            logo=logo)


Test patch:
diff --git a/tests/test_ui_login.py b/tests/test_ui_login.py
--- a/tests/test_ui_login.py
+++ b/tests/test_ui_login.py
@@ -4,6 +4,7 @@
 implementation is contained webui/login.py
 """
 from .base import MyTestCase
+from privacyidea.lib.policy import set_policy, SCOPE, ACTION
 
 
 class LoginUITestCase(MyTestCase):
@@ -14,5 +15,19 @@ def test_01_normal_login(self):
                                            method='GET'):
             res = self.app.full_dispatch_request()
             self.assertTrue(res.status_code == 200, res)
+            self.assertTrue("/static/templates/baseline.html" in res.data)
+            self.assertTrue("/static/templates/menu.html" in res.data)
 
+    def test_02_custom_menu_baseline(self):
+        # We provide a non-existing file, so we can not read "privacyIDEA" in the footer.
+        set_policy("custom1", scope=SCOPE.WEBUI,
+                   action="{0!s}=mytemplates/nonexist.html".format(ACTION.CUSTOM_BASELINE))
+        set_policy("custom2", scope=SCOPE.WEBUI,
+                   action="{0!s}=mytemplates/nonexist.html".format(ACTION.CUSTOM_MENU))
 
+        with self.app.test_request_context('/',
+                                           method='GET'):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 200, res)
+            self.assertTrue("/static/mytemplates/nonexist.html" in res.data)
+            self.assertTrue("/static/mytemplates/nonexist.html" in res.data)
\ No newline at end of file