diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000000000000000000000000000000000000..f078a1b3ca293f2762ebede8d6519cd8674c0418 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,57 @@ +import datetime +import unittest + +from flask import url_for + +# These imports are required, because otherwise we get circular imports?! +from uffd import ldap, user + +from utils import dump, UffdTestCase + +class TestServices(UffdTestCase): + def setUpApp(self): + self.app.config['SERVICES'] = [ + { + 'title': 'Service Title', + 'subtitle': 'Service Subtitle', + 'description': 'Short description of the service as plain text', + 'url': 'https://example.com/', + 'logo_url': '/static/fairy-dust-color.png', + 'required_group': 'users', + 'permission_levels': [ + {'name': 'Moderator', 'required_group': 'moderators'}, + {'name': 'Admin', 'required_group': 'uffd_admin'}, + ], + 'confidential': True, + 'groups': [ + {'name': 'Group "crew_crew"', 'required_group': 'users'}, + {'name': 'Group "crew_logistik"', 'required_group': 'uffd_admin'}, + ], + 'infos': [ + {'title': 'Documentation', 'html': '<p>Some information about the service as html</p>', 'required_group': 'users'}, + ], + 'links': [ + {'title': 'Link to an external site', 'url': '#', 'required_group': 'users'}, + ], + }, + { + 'title': 'Minimal Service Title', + } + ] + self.app.config['SERVICES_PUBLIC'] = True + + def login(self): + self.client.post(path=url_for('session.login'), + data={'loginname': 'testuser', 'password': 'userpassword'}, follow_redirects=True) + + def test_index(self): + r = self.client.get(path=url_for('services.index')) + dump('services_index_public', r) + self.assertEqual(r.status_code, 200) + self.assertNotIn(b'https://example.com/', r.data) + self.login() + r = self.client.get(path=url_for('services.index')) + dump('services_index', r) + self.assertEqual(r.status_code, 200) + self.assertIn(b'https://example.com/', r.data) + diff --git a/uffd/__init__.py b/uffd/__init__.py index 720ad165e9e3fe571f738bb796ee4a76ac8c28fe..ab98f736b3035cccf545fa662345a73edeb5e49b 100644 --- a/uffd/__init__.py +++ b/uffd/__init__.py @@ -39,10 +39,10 @@ def create_app(test_config=None): db.init_app(app) # pylint: disable=C0415 - from uffd import user, selfservice, role, mail, session, csrf, ldap, mfa, oauth2 + from uffd import user, selfservice, role, mail, session, csrf, ldap, mfa, oauth2, services # pylint: enable=C0415 - for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp + mfa.bp + oauth2.bp: + for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp + mfa.bp + oauth2.bp + services.bp: app.register_blueprint(i) @app.route("/") diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg index 5ce34d2712abb845893a6e45dce561c9c8adb986..6febfc1a6fdee606baa82da1d424ebc47690bedd 100644 --- a/uffd/default_config.cfg +++ b/uffd/default_config.cfg @@ -41,6 +41,48 @@ OAUTH2_CLIENTS={ # ... 'required_group': ['groupa', ['groupb', 'groupc']] ... allows users with group "groupa" as well as users with both "groupb" and "groupc" access } +# Service overview page (disabled if empty) +SERVICES=[ +# # Title is mandatory, all other fields are optional. +# # For permission_levels/groups/infos/links all fields are mandatory aside from required_group. +# { +# 'title': 'Service Title', +# 'subtitle': 'Service Subtitle', +# 'description': 'Short description of the service as plain text', +# 'url': 'https://example.com/', +# 'logo_url': 'https://example.com/logo.png', +# # Basic access group name, service is accessible to everyone if empty +# 'required_group': 'users', +# # Non-basic permission levels, the last matching entry is selected. +# # Users with a matching permission level are considered to have +# # access to the service (as if they have the basic access group). +# 'permission_levels': [ +# {'name': 'Moderator', 'required_group': 'moderators'}, +# {'name': 'Admin', 'required_group': 'uffd_admin'}, +# ], +# # Per default all services are listed publicly (but grayed out for +# # guests/users without access). Confidential services are only visible +# # to users with access rights to the service. +# 'confidential': True, +# # In-service groups, all matching items are visible +# 'groups': [ +# {'name': 'Group "crew_crew"', 'required_group': 'users'}, +# {'name': 'Group "crew_logistik"', 'required_group': 'uffd_admin'}, +# ], +# # Infos are small/medium amounts of information displayed in a modal +# # dialog. All matching items are visible. +# 'infos': [ +# {'title': 'Documentation', 'html': '<p>Some information about the service as html</p>', 'required_group': 'users'}, +# ], +# # Links to external sites, all matching items are visible +# 'links': [ +# {'title': 'Link to an external site', 'url': '#', 'required_group': 'users'}, +# ] +# }, +] +# Enable the service overview page for users who are not logged in +SERVICES_PUBLIC=True + # do NOT set in production #TEMPLATES_AUTO_RELOAD=True diff --git a/uffd/services/__init__.py b/uffd/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..656390049779db11f3fbd9a16498218b18982068 --- /dev/null +++ b/uffd/services/__init__.py @@ -0,0 +1,3 @@ +from .views import bp as _bp + +bp = [_bp] diff --git a/uffd/services/templates/overview.html b/uffd/services/templates/overview.html new file mode 100644 index 0000000000000000000000000000000000000000..f68eba642e4e92569ed2d2a0833e56f3f29b3cbc --- /dev/null +++ b/uffd/services/templates/overview.html @@ -0,0 +1,83 @@ +{% extends 'base.html' %} + +{% block body %} + +{% set iconstyle = 'style="width: 1.8em;"'|safe %} + +{% if not user %} +<div class="alert alert-warning" role="alert">Some services may not be publicly listed! Log in to see all services you have access to.</div> +{% endif %} + +{% macro service_card(service) %} + <div class="col mb-4"> + <div class="card h-100 {{ 'text-muted' if not service.has_access }}"> + <div class="card-body"> + {% if service.logo_url %} + {% if service.url and service.has_access %}<a href="{{ service.url }}" class="text-reset">{% endif %} + <img alt="Logo for {{ service.title }}" src="{{ service.logo_url }}" style="width: 100%; height: 10em; {{ 'filter: grayscale(100%);' if not service.has_access }}"> + {% if service.url and service.has_access %}</a>{% endif %} + {% endif %} + <h5 class="card-title"> + {% if service.url and service.has_access %} + <a href="{{ service.url }}" class="text-reset">{{ service.title }}</a> + {% else %} + {{ service.title }} + {% endif %} + </h5> + {% if service.subtitle %} + <h6 class="card-subtitle mb-2 text-muted">{{ service.subtitle }}</h6> + {% endif %} + {% if service.description %} + <p class="card-text">{{ service.description }}</p> + {% endif %} + </div> + <div class="list-group list-group-flush"> + {% if not service.has_access %} + <div class="list-group-item"><i class="fas fa-shield-alt" {{ iconstyle }}></i> No access</div> + {% elif service.permission %} + <div class="list-group-item"><i class="fas fa-shield-alt" {{ iconstyle }}></i> {{ service.permission }}</div> + {% endif %} + {% for group in service.groups %} + <div class="list-group-item"><i class="fas fa-users" {{ iconstyle }}></i> {{ group.name }}</div> + {% endfor %} + {% for info in service.infos %} + <a href="#" class="list-group-item list-group-item-action" data-toggle="modal" data-target="#info-modal-{{ info.id }}"><i class="fas fa-info-circle" {{ iconstyle }}></i> {{ info.title }}</a> + {% endfor %} + {% for link in service.links %} + <a href="{{ link.url }}" class="list-group-item list-group-item-action"><i class="fas fa-external-link-alt" {{ iconstyle }}></i> {{ link.title }}</a> + {% endfor %} + </div> + </div> + </div> +{% endmacro %} + +<div class="row row-cols-2 row-cols-md-3 mt-2"> + {% for service in services if service.has_access %} + {{ service_card(service) }} + {% endfor %} + {% for service in services if not service.has_access %} + {{ service_card(service) }} + {% endfor %} +</div> + +{% for service in services %} +{% for info in service.infos %} +<div class="modal" tabindex="-1" id="info-modal-{{ info.id }}"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title">{{ info.title }}</h5> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body"> + {{ info.html|safe }} + </div> + </div> + </div> +</div> +{% endfor %} +{% endfor %} + +{% endblock %} diff --git a/uffd/services/views.py b/uffd/services/views.py new file mode 100644 index 0000000000000000000000000000000000000000..48fcf795bd52e41ce71897a8ef7eccfebdfd007c --- /dev/null +++ b/uffd/services/views.py @@ -0,0 +1,85 @@ +from flask import Blueprint, render_template, current_app, abort + +from uffd.navbar import register_navbar +from uffd.session import is_valid_session, get_current_user + +bp = Blueprint("services", __name__, template_folder='templates', url_prefix='/services') + +# pylint: disable=too-many-branches +def get_services(user=None): + if not user and not current_app.config['SERVICES_PUBLIC']: + return [] + services = [] + for service_data in current_app.config['SERVICES']: + if not service_data.get('title'): + continue + service = { + 'title': service_data['title'], + 'subtitle': service_data.get('subtitle', ''), + 'description': service_data.get('description', ''), + 'url': service_data.get('url', ''), + 'logo_url': service_data.get('logo_url', ''), + 'has_access': True, + 'permission': '', + 'groups': [], + 'infos': [], + 'links': [], + } + if service_data.get('required_group'): + if not user or not user.is_in_group(service_data['required_group']): + service['has_access'] = False + for permission_data in service_data.get('permission_levels', []): + if permission_data.get('required_group'): + if not user or not user.is_in_group(permission_data['required_group']): + continue + if not permission_data.get('name'): + continue + service['has_access'] = True + service['permission'] = permission_data['name'] + if service_data.get('confidential', False) and not service['has_access']: + continue + for group_data in service_data.get('groups', []): + if group_data.get('required_group'): + if not user or not user.is_in_group(group_data['required_group']): + continue + if not group_data.get('name'): + continue + service['groups'].append(group_data) + for info_data in service_data.get('infos', []): + if info_data.get('required_group'): + if not user or not user.is_in_group(info_data['required_group']): + continue + if not info_data.get('title') or not info_data.get('html'): + continue + info = { + 'title': info_data['title'], + 'html': info_data['html'], + 'id': '%d-%d'%(len(services), len(service['infos'])), + } + service['infos'].append(info) + for link_data in service_data.get('links', []): + if link_data.get('required_group'): + if not user or not user.is_in_group(link_data['required_group']): + continue + if not link_data.get('url') or not link_data.get('title'): + continue + service['links'].append(link_data) + services.append(service) + return services + +def services_visible(): + user = None + if is_valid_session(): + user = get_current_user() + return len(get_services(user)) > 0 + +@bp.route("/") +@register_navbar('Services', icon='sitemap', blueprint=bp, visible=services_visible) +def index(): + user = None + if is_valid_session(): + user = get_current_user() + services = get_services(user) + if not current_app.config['SERVICES']: + abort(404) + return render_template('overview.html', user=user, services=services)