diff --git a/.config/config.cfg b/.config/config.cfg index 17c20c5..4e58c07 100644 --- a/.config/config.cfg +++ b/.config/config.cfg @@ -8,6 +8,9 @@ ALLOWED_HOSTS=127.0.0.1 SECRET_KEY= DJANGO_DB=DEFAULT + WAM_EXCHANGE_ACCOUNT= + WAM_EXCHANGE_EMAIL= + WAM_EXCHANGE_PW= [DATABASES] [[DEFAULT]] diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a15cb3..8bd09da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,16 +18,28 @@ Here is a template for new release sections ``` ## [Unreleased] +### Added + +### Changed + +## [0.1.2] 2019-07-04 + ### Added - CHANGELOG.md - CONTRIBUTING.md - continuous integration with TravisCI (`.travis.yml`) - linting tests and their config files (`.pylintrc` and `.flake8`) - tests/ folder +- add session error logging #64 +- support custom id in InfoButton widget #63 +- add feedback form #65 +- add custom 404 and 500 error pages #70 ### Changed - fix flake8 and pylint errors - environnement.yml installs dependencies from requirements.txt so that conda is not required +- fix grid-x layout error in InfoButton widget #66 +- fix: avoid reimport of modules if multiple apps use the same #75 ## [0.1.1] 2019-05-22 @@ -54,4 +66,4 @@ Here is a template for new release sections - fixed flake8 and pylint errors - environment.yml installs dependencies - if the environment variable WAM_CONFIG_PATH or WAM_APPS do not exist, the user is prompted with a meaningful error message -- if WAM_APPS is an empty string, the app loads until the start page. \ No newline at end of file +- if WAM_APPS is an empty string, the app loads until the start page. diff --git a/doc/getting_started.rst b/doc/getting_started.rst index 50b706f..81e5366 100644 --- a/doc/getting_started.rst +++ b/doc/getting_started.rst @@ -260,8 +260,22 @@ Requirements: Additional setups: -- *settings.py* can setup additional parameters for projects *settings.py* +- *settings.py* can setup additional parameters for projects *settings.py*. +If your app requires the use of additional packages, you should list them in the settings.py of your app (not the settings.py file form wam core) in the following way + +.. code:: python + + INSTALLED_APP = ['package1', 'package2'] + +Then, wam core will manage the packages' installation and avoid duplicate installations between the different apps. + + - *app_settings.py* contains application specific settings and is loaded at start of django server at the end of *settings.py*. This file may include additional database connections, loading of config files needed for the application, etc. + + +.. warning:: Avoid using config variables for packages in your app as it may override or get overridden by package config of other app! + + - *labels.cfg* (uses configobj_) supports easy adding of labels to templates via templatetags (see :ref:`label_tags`) diff --git a/doc/helpers.rst b/doc/helpers.rst index 151008b..b7e0cdb 100644 --- a/doc/helpers.rst +++ b/doc/helpers.rst @@ -62,6 +62,42 @@ Additionally, the label template tag supports two attributes: If no label is found or given (sub-) section is not found, *None* will be returned. +Feedback Form +------------- + +A feedback form is available which can be used in all apps. The feedback is send via e-mail using an Exchange account. +Required configuration parameters for the Exchange account are *WAM_EXCHANGE_ACCOUNT*, *WAM_EXCHANGE_EMAIL* and +*WAM_EXCHANGE_PW*. They must be set in the *[WAM]* section of the *config.cfg* file, see :ref:`configuration_file` for +details. + +To use the form, just add the view to your urls like + +.. code:: python + + # my_app/urls.py + + from utils.views import FeedbackView + + admin_url_patterns = [ + path(), + ..., + path('feedback/', FeedbackView.as_view(app_name=''), name='feedback') + ] + +Make sure you have the parameter ``email`` set in your *app.cfg*, example: + +.. code:: text + + # my_app/app.cfg + category = app + name = ... + icon = ... + email = 'address_of_app_admin@domain.tld' + +This address is used to send feedback messages for the app. +If the Exchange account or app admin's e-mail address is not configured correctly, the user will be redirected to an +error page. + .. _custom_admin_site: Customizing Admin Site diff --git a/requirements.txt b/requirements.txt index c363ba6..b03d640 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ pandas whitenoise celery configobj +exchangelib django-markdownx django-crispy-forms psycopg2-binary diff --git a/static/foundation/css/app.css b/static/foundation/css/app.css index 4ae0a8b..90c2955 100644 --- a/static/foundation/css/app.css +++ b/static/foundation/css/app.css @@ -8,6 +8,9 @@ .l-bg-color--light { background-color: #D8E2ED; } +.l-bg-color--error { + background-color: #F2994A; } + a { font-weight: 700; } diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..fe3d2db --- /dev/null +++ b/templates/error.html @@ -0,0 +1,61 @@ + +{% extends 'base.html' %} + +{% load static %} + +{% block content %} +
+ +
+ +
+
+ Logo WAM +
+
+ +
+
+
+

{{ err_text }}

+
+
+
+ +
+ + + + +
+{% endblock %} \ No newline at end of file diff --git a/templates/feedback.html b/templates/feedback.html new file mode 100644 index 0000000..28df949 --- /dev/null +++ b/templates/feedback.html @@ -0,0 +1,60 @@ +{% extends 'base.html' %} + +{% load static %} +{% block content %} +
+
+
+
+

Ihr Feedback

+

App: {{ app_name }}

+ {% if intro_text %} +

{{ intro_text }}

+ {% else %} +

Hier können Sie uns eine Rückmeldung zur App geben, wir freuen uns über Ihre Nachricht!

+ {% endif %} + +
+ {% csrf_token %} + {{ form }} + +
+
+
+
+ + + +
+ +{% endblock %} \ No newline at end of file diff --git a/templates/feedback_error.html b/templates/feedback_error.html new file mode 100644 index 0000000..fe079d2 --- /dev/null +++ b/templates/feedback_error.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

{{ error_text }}

+

Zur WAM-Startseite

+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/feedback_successful.html b/templates/feedback_successful.html new file mode 100644 index 0000000..366c0c4 --- /dev/null +++ b/templates/feedback_successful.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Vielen Dank für Ihr Feedback!

+

Zur WAM-Startseite

+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/widgets/info_button.html b/templates/widgets/info_button.html index 9a74d8e..095aad4 100644 --- a/templates/widgets/info_button.html +++ b/templates/widgets/info_button.html @@ -2,9 +2,7 @@
-
- {{text|safe}} -
+ {{text|safe}} diff --git a/user_sessions/utils.py b/user_sessions/utils.py index f5e2764..8ca9328 100644 --- a/user_sessions/utils.py +++ b/user_sessions/utils.py @@ -1,6 +1,9 @@ +import logging + from django.shortcuts import render from wam.settings import SESSION_DATA +from utils.shortcuts import get_app_from_request def check_session_method(func): @@ -13,6 +16,7 @@ def func_wrapper(self, request, *args, **kwargs): try: session = SESSION_DATA.get_session(request) except KeyError: + log_session_error(request) return render(request, 'stemp/session_not_found.html') return func(self, request, session=session, *args, **kwargs) return func_wrapper @@ -28,6 +32,20 @@ def func_wrapper(request, *args, **kwargs): try: session = SESSION_DATA.get_session(request) except KeyError: + log_session_error(request) return render(request, 'stemp/session_not_found.html') return func(request, session=session, *args, **kwargs) return func_wrapper + + +def log_session_error(request): + app = get_app_from_request(request) + err_msg = ( + f'Session error for app "{app}":\n' + f'Session-Key: {request.session.session_key}\n' + f'Current session data:\n' + + '\n'.join( + [f'{k}: {str(v)}' for k, v in SESSION_DATA.sessions[app].items()] + ) + ) + logging.error(err_msg) diff --git a/utils/forms.py b/utils/forms.py new file mode 100644 index 0000000..8e7da23 --- /dev/null +++ b/utils/forms.py @@ -0,0 +1,19 @@ +from django import forms + + +class FeedbackForm(forms.Form): + """Input form for feedback page""" + from_name = forms.CharField(required=False, + max_length=100, + label='Ihr Name (optional)') + from_email = forms.EmailField(required=False, + label='Ihre E-Mail-Adresse (optional)') + subject = forms.CharField(required=True, + max_length=100, + label='Betreff') + message = forms.CharField(widget=forms.Textarea, + required=True, + label='Ihr Feedback') + + def submit(self): + pass diff --git a/utils/mail.py b/utils/mail.py new file mode 100644 index 0000000..e86f01f --- /dev/null +++ b/utils/mail.py @@ -0,0 +1,61 @@ +import logging +from requests.exceptions import ConnectionError # pylint: disable=import-error + +from exchangelib import Credentials, Account, Message, Mailbox # pylint: disable=import-error +from exchangelib.errors import AutoDiscoverFailed # pylint: disable=import-error +from wam.settings import WAM_EXCHANGE_ACCOUNT, WAM_EXCHANGE_EMAIL, WAM_EXCHANGE_PW + + +def send_email(to_email, subject, message): + """Send E-mail via MS Exchange Server using credentials from env vars + + Parameters + ---------- + to_email : :obj:`str` + Target mail address + subject : :obj:`str` + Subject of mail + message : :obj:`str` + Message body of mail + + Returns + ------- + :obj:`bool` + Success status (True: successful) + """ + + credentials = Credentials(WAM_EXCHANGE_ACCOUNT, + WAM_EXCHANGE_PW) + try: + account = Account(WAM_EXCHANGE_EMAIL, + credentials=credentials, + autodiscover=True) + except ConnectionError: + err_msg = 'Feedback-Form - Verbindungsfehler!' + logging.error(err_msg) + return False + except AutoDiscoverFailed: + err_msg = 'Feedback-Form - Konto- oder Authentifizierungsfehler!' + logging.error(err_msg) + return False + except Exception as err: # pylint: disable=broad-except + err_msg = f'Feedback-Form - Sonstiger Fehler: {err}' + logging.error(err_msg) + return False + + recipients = [Mailbox(email_address=to_email)] + + m = Message(account=account, + folder=account.sent, + subject=subject, + body=message, + to_recipients=recipients) + + try: + m.send_and_save() + except Exception as err: # pylint: disable=broad-except + err_msg = f'Feedback-Form - Fehler beim Mailversand: {err}' + logging.error(err_msg) + return False + + return True diff --git a/utils/views.py b/utils/views.py new file mode 100644 index 0000000..5830d48 --- /dev/null +++ b/utils/views.py @@ -0,0 +1,129 @@ +import os +import logging +from configobj import ConfigObj + +from django.views.generic.edit import FormView +from django.views.generic import TemplateView +from django.core.validators import validate_email, ValidationError +from django.shortcuts import redirect + +from wam.settings import BASE_DIR, WAM_EXCHANGE_ACCOUNT, WAM_EXCHANGE_EMAIL, WAM_EXCHANGE_PW +from utils.forms import FeedbackForm +from utils.mail import send_email + + +class FeedbackView(FormView): # pylint: disable=too-many-ancestors + """Feedback form which sends an E-mail to app admin""" + + app_name = None + intro_text = None + template_name = 'feedback.html' + form_class = FeedbackForm + success_url = '/feedback_thanks/' + error_url = '/feedback_error/' + + def __init__(self, + app_name=None, + intro_text=None): + """ + Parameters + ---------- + app_name : :obj:`str` + Name of app the form should be created for + intro_text : :obj:`str` + Optional. Custom introductory text (inserted before form), + defaults to standard welcome text (see template). + """ + super(FeedbackView, self).__init__() + + if app_name is not None: + self.app_name = app_name + + # read and validate app admin's mail address from app.cfg + app_config = ConfigObj(os.path.join(BASE_DIR, app_name, 'app.cfg')) + email = app_config.get('email', None) + if email is not None: + try: + validate_email(email) + self.to_email = email + except ValidationError: + raise ValidationError( + f'E-mail address in {app_name}`s app.cfg is invalid!') + else: + raise ValueError('Parameter "app_name" must be specified!') + + self.intro_text = intro_text + + def form_valid(self, form): + form.submit() + + subject, body = self.prepare_message(**form.cleaned_data) + success = send_email(to_email=self.to_email, + subject=subject, + message=body) + if success: + return super().form_valid(form) + + return redirect('feedback_error', err_type='send') + + def get_context_data(self, **kwargs): + context = super(FeedbackView, self).get_context_data(**kwargs) + + context['app_name'] = self.app_name + context['intro_text'] = self.intro_text + + return context + + def get(self, request, *args, **kwargs): + # check if env vars are set, if not redirect to error page + if WAM_EXCHANGE_ACCOUNT is None or \ + WAM_EXCHANGE_EMAIL is None or \ + WAM_EXCHANGE_PW is None: + err_msg = 'Feedback-Form - Konfigurationsfehler: ' \ + 'Umgebungsvariablen nicht gesetzt oder unvollständig!' + logging.error(err_msg) + return redirect('feedback_error', err_type='config') + + return super().get(request, *args, **kwargs) + + def prepare_message(self, **kwargs): + subject = f'Nachricht über WAM Feedback-Formular: ' \ + f'App {self.app_name}' + body = f'Sie haben eine Nachricht über das Feedback-Formular der WAM ' \ + f'erhalten.\n\n' \ + f'App: {self.app_name}\n' \ + f'Absender: {kwargs.get("from_name", "")} ' \ + f'({kwargs.get("from_email", "")})\n' \ + f'Betreff: {kwargs.get("subject", "")}\n' \ + f'========================================\n' \ + f'{kwargs.get("message", "")}\n' \ + f'========================================\n' \ + f'Bitte antworte nicht auf diese E-Mail, junger PadaWAM!\n' \ + f'Gez. Obi WAM Kenobi' + return subject, body + + +class FeedbackSuccessful(TemplateView): + template_name = 'feedback_successful.html' + + +class FeedbackError(TemplateView): + """Error page for feedback form""" + + template_name = 'feedback_error.html' + + def get(self, request, *args, **kwargs): + context = self.get_context_data() + + # get error type and include corresponding message in context + err_type = kwargs.get('err_type') + if err_type == 'config': + error_text = 'Das Feedback-Formular ist nicht richtig konfiguriert.' + elif err_type == 'send': + error_text = 'Beim Senden ist leider ein Fehler aufgetreten.' + else: + error_text = 'Das Feedback-Formular funktioniert nicht richtig.' + + context['error_text'] = error_text + + return self.render_to_response(context) diff --git a/utils/widgets.py b/utils/widgets.py index c462d78..ba01cd4 100644 --- a/utils/widgets.py +++ b/utils/widgets.py @@ -51,7 +51,8 @@ def __init__( is_markdown: bool = False, ionicon_type: str = 'ion-information-circled', ionicon_size: str = 'small', - ionicon_color: str = None + ionicon_color: str = None, + info_id: str = None ): """ @@ -71,14 +72,22 @@ def __init__( xxlarge ionicon_color : str Sets color of icon in hex color code, e.g. '#ff0000' + info_id : str + Optional. If provided, the reveal div id will be set to this value, + prepended by "info_". By default, a counter is used which results + in ids "info_0", "info_1" etc. """ - self.id = next(self.counter) self.text = markdown(text) if is_markdown else text self.tooltip = tooltip self.ionicon_type = ionicon_type self.ionicon_size = ionicon_size self.ionicon_color = ionicon_color + if info_id is not None and isinstance(info_id, str): + self.id = info_id + else: + self.id = next(self.counter) + def get_context(self): return { 'info_id': f'info_{self.id}', diff --git a/wam/settings.py b/wam/settings.py index b8919d9..ad526d4 100644 --- a/wam/settings.py +++ b/wam/settings.py @@ -191,7 +191,10 @@ for setting in dir(settings): if setting == setting.upper(): if setting in locals() and isinstance(locals()[setting], list): - locals()[setting] += getattr(settings, setting) + attr_list = getattr(settings, setting) + for attr in attr_list: + if attr not in locals()[setting]: + locals()[setting].append(attr) else: locals()[setting] = getattr(settings, setting) @@ -200,3 +203,8 @@ importlib.import_module(f'{app}.app_settings', package='wam') except ModuleNotFoundError: pass + +# E-mail config for outgoing mails (used by exchangelib) +WAM_EXCHANGE_ACCOUNT = config['WAM']['WAM_EXCHANGE_ACCOUNT'] +WAM_EXCHANGE_EMAIL = config['WAM']['WAM_EXCHANGE_EMAIL'] +WAM_EXCHANGE_PW = config['WAM']['WAM_EXCHANGE_PW'] diff --git a/wam/urls.py b/wam/urls.py index c68060b..c05b7d6 100644 --- a/wam/urls.py +++ b/wam/urls.py @@ -24,9 +24,11 @@ from meta import models from meta.views import AppListView, AssumptionsView +from utils.views import FeedbackSuccessful, FeedbackError + urlpatterns = [ - path('', views.IndexView.as_view()), + path('', views.IndexView.as_view(), name='index'), path('contact/', views.ContactView.as_view(), name='contact'), path('privacy/', views.PrivacyView.as_view(), name='privacy'), path('impressum/', views.ImpressumView.as_view(), name='impressum'), @@ -36,6 +38,8 @@ path('accounts/login/', LoginView.as_view(template_name='login.html')), path('access_denied/', TemplateView.as_view( template_name='access_denied.html'), name='access_denied'), + path('feedback_thanks/', FeedbackSuccessful.as_view(), name='feedback_thanks'), + path('feedback_error//', FeedbackError.as_view(), name='feedback_error') ] try: @@ -49,3 +53,7 @@ include(app_name + '.urls', namespace=app_name) ) urlpatterns.append(app_url) + +# error handlers (work in non-debug mode only) +handler404 = 'wam.views.handler404' +handler500 = 'wam.views.handler500' diff --git a/wam/views.py b/wam/views.py index 379af70..a06c31c 100644 --- a/wam/views.py +++ b/wam/views.py @@ -1,9 +1,11 @@ import os import importlib from collections import defaultdict -from django.views.generic import TemplateView from configobj import ConfigObj +from django.views.generic import TemplateView +from django.shortcuts import render_to_response + from wam.settings import WAM_APPS, BASE_DIR from utils.constants import AppInfo, AppCategory @@ -68,3 +70,19 @@ def get_app_infos(app_name): def get(self, request, *args, **kwargs): context = self.get_context_data() return self.render_to_response(context) + + +def handler404(request, exception, template_name='error.html'): # pylint: disable=unused-argument + """A custom 404 page""" + context = {'err_text': 'Die Seite wurde leider nicht gefunden'} + response = render_to_response(template_name, context=context) + response.status_code = 404 + return response + + +def handler500(request, template_name='error.html'): # pylint: disable=unused-argument + """A custom 500 page""" + context = {'err_text': 'Es ist ein Server-Fehler aufgetreten'} + response = render_to_response(template_name, context=context) + response.status_code = 500 + return response