use flask-themer to allow theme customization

This commit is contained in:
Eloi Rivard 2021-10-28 15:24:34 +02:00 committed by Éloi Rivard
parent 2aac2a0c6a
commit c0f53c8e6e
39 changed files with 240 additions and 37 deletions

View file

@ -11,6 +11,7 @@ It aims to be very light, simple to install and simple to maintain. Its main fea
- OpenID Connect support;
- No outdated or exotic protocol support;
- No additional database required. Everything is stored in your LDAP server;
- Customizable, themable;
- The code is easy to read and easy to edit, and you should!
# Screenshots

View file

@ -15,8 +15,9 @@ import canaille.account
import canaille.groups
import canaille.well_known
from flask import Flask, g, request, render_template, session
from flask import Flask, g, request, session
from flask_babel import Babel
from flask_themer import Themer, render_template, FileSystemThemeLoader
from .flaskutils import current_user
from .ldaputils import LDAPObject
@ -120,6 +121,18 @@ def setup_app(app):
babel = Babel(app)
additional_themes_dir = (
os.path.dirname(app.config["THEME"])
if app.config.get("THEME") and os.path.exists(app.config["THEME"])
else None
)
themer = Themer(
app,
loaders=[FileSystemThemeLoader(additional_themes_dir)]
if additional_themes_dir
else None,
)
@babel.localeselector
def get_locale():
user = getattr(g, "user", None)
@ -137,6 +150,11 @@ def setup_app(app):
if user is not None:
return user.timezone
@themer.current_theme_loader
def get_current_theme():
# if config['THEME'] may be a theme name or an absolute path
return app.config.get("THEME", "default").split("/")[-1]
@app.before_request
def before_request():
setup_ldap_connection(app)

View file

@ -7,11 +7,11 @@ from flask import (
url_for,
current_app,
abort,
render_template,
redirect,
session,
)
from flask_babel import gettext as _
from flask_themer import render_template
from werkzeug.datastructures import CombinedMultiDict, FileStorage
from .forms import (
LoginForm,

View file

@ -1,4 +1,5 @@
from flask import Blueprint, render_template
from flask import Blueprint
from flask_themer import render_template
from canaille.models import AuthorizationCode
from canaille.flaskutils import admin_needed

View file

@ -1,6 +1,7 @@
import datetime
import wtforms
from flask import Blueprint, render_template, request, flash, redirect, url_for, abort
from flask import Blueprint, request, flash, redirect, url_for, abort
from flask_themer import render_template
from flask_wtf import FlaskForm
from flask_babel import lazy_gettext as _
from werkzeug.security import gen_salt

View file

@ -1,4 +1,5 @@
from flask import Blueprint, render_template, current_app, url_for
from flask import Blueprint, current_app, url_for
from flask_themer import render_template
from flask_babel import gettext as _
from canaille.flaskutils import admin_needed
from canaille.mails import profile_hash

View file

@ -1,4 +1,5 @@
from flask import Blueprint, render_template
from flask import Blueprint
from flask_themer import render_template
from canaille.models import Token
from canaille.flaskutils import admin_needed

View file

@ -17,6 +17,11 @@ LOGO = "/static/img/canaille-head.png"
# Your favicon. If unset the LOGO will be used.
FAVICON = "/static/img/canaille-c.png"
# The name of a theme in the 'theme' directory, or an absolute path
# to a theme. Defaults to 'default'. Theming is done with
# https://github.com/tktech/flask-themer
# THEME = "default"
# If unset, language is detected
# LANGUAGE = "en"

View file

@ -8,12 +8,25 @@ from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend as crypto_default_backend
ROOT = os.path.dirname(os.path.abspath(__file__))
class ConfigurationException(Exception):
pass
def validate(config, validate_remote=False):
validate_keypair(config)
validate_theme(config)
if not validate_remote:
return
validate_ldap_configuration(config)
validate_smtp_configuration(config)
def validate_keypair(config):
if not os.path.exists(config["JWT"]["PUBLIC_KEY"]):
raise ConfigurationException(
f'Public key does not exist {config["JWT"]["PUBLIC_KEY"]}'
@ -24,12 +37,6 @@ def validate(config, validate_remote=False):
f'Private key does not exist {config["JWT"]["PRIVATE_KEY"]}'
)
if not validate_remote:
return
validate_ldap_configuration(config)
validate_smtp_configuration(config)
def validate_ldap_configuration(config):
from canaille.models import User, Group
@ -127,6 +134,16 @@ def validate_smtp_configuration(config):
) from exc
def validate_theme(config):
if not config.get("THEME"):
return
if not os.path.exists(config["THEME"]) and not os.path.exists(
os.path.join(ROOT, "themes", config["THEME"])
):
raise ConfigurationException(f'Cannot find theme \'{config["THEME"]}\'')
def setup_dev_keypair(config):
if os.path.exists(config["JWT"]["PUBLIC_KEY"]) or os.path.exists(
config["JWT"]["PRIVATE_KEY"]

View file

@ -1,4 +1,5 @@
from flask import Blueprint, render_template, flash, redirect, url_for
from flask import Blueprint, flash, redirect, url_for
from flask_themer import render_template
from flask_babel import gettext
from canaille.models import Consent, Client
from canaille.flaskutils import user_needed

View file

@ -1,6 +1,5 @@
from flask import (
Blueprint,
render_template,
redirect,
url_for,
request,
@ -9,6 +8,7 @@ from flask import (
abort,
)
from flask_babel import gettext as _
from flask_themer import render_template
from .flaskutils import moderator_needed
from .forms import GroupForm

View file

@ -1,6 +1,7 @@
import hashlib
from flask import url_for, render_template, current_app
from flask import url_for, current_app
from flask_babel import gettext as _
from flask_themer import render_template
from .apputils import logo, send_email

View file

@ -9,11 +9,11 @@ from flask import (
session,
redirect,
abort,
render_template,
jsonify,
flash,
)
from flask_babel import gettext, lazy_gettext as _
from flask_themer import render_template
from .models import User, Client, Consent
from .oauth2utils import (
authorization,

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block content %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% block style %}
<link href="/static/datatables/jquery.dataTables.min.css" rel="stylesheet">

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block content %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block content %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block script %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% block style %}
<link href="/static/datatables/jquery.dataTables.min.css" rel="stylesheet">

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% block style %}
<link href="/static/datatables/jquery.dataTables.min.css" rel="stylesheet">

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block content %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% block content %}
<div class="ui segment">

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% block style %}
<link href="/static/datatables/jquery.dataTables.min.css" rel="stylesheet">

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% block content %}
<div class="ui error message">

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block content %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block content %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block script %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% block content %}
<div class="ui segment">

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% block content %}
<div class="ui segment">

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block content %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block content %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block script %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% import 'fomanticui.j2' as sui %}
{% block content %}

View file

@ -1,4 +1,4 @@
{% extends 'base.html' %}
{% extends theme('base.html') %}
{% block style %}
<link href="/static/datatables/jquery.dataTables.min.css" rel="stylesheet">

View file

@ -17,6 +17,11 @@ LOGO = "/static/img/canaille-head.png"
# Your favicon. If unset the LOGO will be used.
FAVICON = "/static/img/canaille-c.png"
# The name of a theme in the 'theme' directory, or an absolute path
# to a theme. Defaults to 'default'. Theming is done with
# https://github.com/tktech/flask-themer
# THEME = "default"
# If unset, language is detected
# LANGUAGE = "en"

View file

@ -30,6 +30,7 @@ install_requires =
email_validator
flask
flask-babel
flask-themer
flask-wtf
python-ldap
sentry-sdk[flask]

107
tests/fixtures/themes/test/base.html vendored Normal file
View file

@ -0,0 +1,107 @@
{% import 'flask.j2' as flask %}
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{% block title %}{{ website_name|default("Canaille") }} {% trans %}authorization interface{% endtrans %}{% endblock %}</title>
<link href="/static/fomanticui/semantic.min.css" rel="stylesheet">
<link href="/static/fonts/lato.css" rel="stylesheet">
<link href="/static/css/base.css" rel="stylesheet">
{% if logo_url %}<link rel="icon" href="{{ favicon_url }}">{% endif %}
{% block style %}{% endblock %}
<!--[if lt IE 9]>
<script>window.html5 || document.write('<script src="/static/html5shiv/html5shiv.min.js"><\/script>')</script>
<script src="/static/js/respond.min.js"></script>
<![endif]-->
</head>
<body>
{% block menu %}
{% if user and menu %}
<nav class="ui stackable labeled icon menu container">
{% if logo_url %}
<div class="header item">
<a href="/" class="logo">
<img class="ui image" src="{{ logo_url }}" alt="{{ website_name }}" />
</a>
</div>
{% endif %}
<a class="item {% if menuitem == "profile" %}active{% endif %}"
href="{{ url_for('account.profile_edition', username=user.uid[0]) }}">
<i class="id card icon"></i>
{% trans %}My profile{% endtrans %}
</a>
<a class="item {% if menuitem == "consents" %}active{% endif %}"
href="{{ url_for('consents.consents') }}">
<i class="handshake icon"></i>
{% trans %}My consents{% endtrans %}
</a>
{% if user.moderator %}
<a class="item {% if menuitem == "users" %}active{% endif %}"
href="{{ url_for('account.users') }}">
<i class="users icon"></i>
{% trans %}Users{% endtrans %}
</a>
<a class="item {% if menuitem == "groups" %}active{% endif %}"
href="{{ url_for('groups.groups') }}">
<i class="users cog icon"></i>
{% trans %}Groups{% endtrans %}
</a>
{% endif %}
{% if user.admin %}
<div class="ui dropdown item {% if menuitem == "admin" %}active{% endif %}">
<i class="settings icon"></i>
Admin
<div class="menu">
<a class="item" href="{{ url_for('admin_clients.index') }}">
<i class="plug icon"></i>
{% trans %}Clients{% endtrans %}
</a>
<a class="item" href="{{ url_for('admin_tokens.index') }}">
<i class="key icon"></i>
{% trans %}Tokens{% endtrans %}
</a>
<a class="item" href="{{ url_for('admin_authorizations.index') }}">
<i class="user secret icon"></i>
{% trans %}Codes{% endtrans %}
</a>
<a class="item" href="">
<i class="handshake icon"></i>
{% trans %}Consents{% endtrans %}
</a>
</div>
</div>
{% endif %}
<a class="item" href="{{ url_for('account.logout') }}">
<i class="sign out alternate icon"></i>
{% trans %}Log out{% endtrans %}
</a>
</nav>
{% endif %}
{% endblock %}
<div class="ui container">
<div class="content">
{{ flask.messages() }}
{% block content %}{% endblock %}
</div>
</div>
<footer>
<a href="{{ url_for('account.about') }}">{{ _("About canaille") }}</a>
</footer>
<script src="/static/jquery/jquery.min.js"></script>
<script src="/static/fomanticui/semantic.min.js"></script>
<script src="/static/js/base.js"></script>
{% block script %}{% endblock %}
TEST_THEME
</body>
</html>

View file

@ -1,8 +1,11 @@
import ldap
import mock
import os
import pytest
from canaille import create_app
from canaille.commands import cli
from canaille.configuration import validate, ConfigurationException
from flask_webtest import TestApp
def test_ldap_connection_no_remote(configuration):
@ -104,3 +107,43 @@ def test_check_command_fail(testclient):
testclient.app.config["LDAP"]["URI"] = "ldap://invalid-ldap.com"
runner = testclient.app.test_cli_runner()
runner.invoke(cli, ["check"])
@pytest.fixture
def themed_testclient(app, configuration):
configuration["TESTING"] = True
root = os.path.dirname(os.path.abspath(__file__))
test_theme_path = os.path.join(root, "fixtures", "themes", "test")
configuration["THEME"] = test_theme_path
configuration["AUTHLIB_INSECURE_TRANSPORT"] = "true"
app = create_app(configuration)
return TestApp(app)
def test_theme(testclient, themed_testclient):
res = testclient.get("/login")
assert "TEST_THEME" not in res
res = themed_testclient.get("/login")
assert "TEST_THEME" in res
def test_invalid_theme(configuration):
validate(configuration, validate_remote=False)
with pytest.raises(
ConfigurationException,
match=r"Cannot find theme",
):
configuration["THEME"] = "invalid"
validate(configuration, validate_remote=False)
with pytest.raises(
ConfigurationException,
match=r"Cannot find theme",
):
configuration["THEME"] = "/path/to/invalid"
validate(configuration, validate_remote=False)