From 6884fe3c096ea947d2a9c02194fade89e138897a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Fri, 10 Aug 2018 17:11:26 +0200 Subject: [PATCH] profile: add a new "user profile" cell (#25633) --- combo/data/models.py | 5 +- combo/profile/__init__.py | 26 ++++++++++ combo/profile/migrations/0002_profilecell.py | 35 +++++++++++++ combo/profile/models.py | 37 ++++++++++++++ combo/profile/templates/combo/profile.html | 14 +++++ combo/public/templatetags/combo.py | 14 +++++ .../themes/gadjo/static/css/agent-portal.scss | 7 +++ tests/settings.py | 17 +++++++ tests/test_profile.py | 51 +++++++++++++++++++ 9 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 combo/profile/migrations/0002_profilecell.py create mode 100644 combo/profile/templates/combo/profile.html create mode 100644 tests/test_profile.py diff --git a/combo/data/models.py b/combo/data/models.py index dbc3e190..628d57ed 100644 --- a/combo/data/models.py +++ b/combo/data/models.py @@ -1043,6 +1043,7 @@ class JsonCellBase(CellBase): # }, # ... # ] + first_data_key = 'json' _json_content = None @@ -1063,7 +1064,7 @@ class JsonCellBase(CellBase): context[varname] = context['request'].GET[varname] self._json_content = None - data_urls = [{'key': 'json', 'url': self.url, 'cache_duration': self.cache_duration, + data_urls = [{'key': self.first_data_key, 'url': self.url, 'cache_duration': self.cache_duration, 'log_errors': self.log_errors, 'timeout': self.timeout}] data_urls.extend(self.additional_data or []) @@ -1128,7 +1129,7 @@ class JsonCellBase(CellBase): # keep cache of first response as it may be used to find the # appropriate template. - self._json_content = extra_context['json'] + self._json_content = extra_context[self.first_data_key] return extra_context diff --git a/combo/profile/__init__.py b/combo/profile/__init__.py index e69de29b..ac170295 100644 --- a/combo/profile/__init__.py +++ b/combo/profile/__init__.py @@ -0,0 +1,26 @@ +# combo - content management system +# Copyright (C) 2014-2018 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 . + + +import django.apps +from django.utils.translation import ugettext_lazy as _ + + +class AppConfig(django.apps.AppConfig): + name = 'combo.profile' + verbose_name = _('Profile') + +default_app_config = 'combo.profile.AppConfig' diff --git a/combo/profile/migrations/0002_profilecell.py b/combo/profile/migrations/0002_profilecell.py new file mode 100644 index 00000000..1c5d27ab --- /dev/null +++ b/combo/profile/migrations/0002_profilecell.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-08-10 13:52 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0035_page_related_cells'), + ('profile', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ProfileCell', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('placeholder', models.CharField(max_length=20)), + ('order', models.PositiveIntegerField()), + ('slug', models.SlugField(blank=True, verbose_name='Slug')), + ('extra_css_class', models.CharField(blank=True, max_length=100, verbose_name='Extra classes for CSS styling')), + ('public', models.BooleanField(default=True, verbose_name='Public')), + ('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')), + ('last_update_timestamp', models.DateTimeField(auto_now=True)), + ('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Groups')), + ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')), + ], + options={ + 'verbose_name': 'Profile', + }, + ), + ] diff --git a/combo/profile/models.py b/combo/profile/models.py index 1e7824ed..75413815 100644 --- a/combo/profile/models.py +++ b/combo/profile/models.py @@ -14,10 +14,47 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from collections import OrderedDict +import copy + from django.conf import settings from django.db import models +from django.utils.dateparse import parse_date +from django.utils.translation import ugettext_lazy as _ + +from combo.data.models import JsonCellBase +from combo.data.library import register_cell_class class Profile(models.Model): user = models.OneToOneField(settings.AUTH_USER_MODEL) initial_login_view_timestamp = models.DateTimeField(null=True) + + +@register_cell_class +class ProfileCell(JsonCellBase): + template_name = 'combo/profile.html' + first_data_key = 'profile' + + class Meta: + verbose_name = _('Profile') + + @property + def url(self): + idp = settings.KNOWN_SERVICES.get('authentic').values()[0] + return '{%% load combo %%}%sapi/users/{%% firstof selected_user|name_id request.user|name_id %%}/' % idp.get('url') + + def get_cell_extra_context(self, context): + extra_context = super(ProfileCell, self).get_cell_extra_context(context) + extra_context['profile_fields'] = OrderedDict() + if extra_context.get('profile') is not None: + for attribute in settings.USER_PROFILE_CONFIG.get('fields'): + extra_context['profile_fields'][attribute['name']] = copy.copy(attribute) + value = extra_context['profile'].get(attribute['name']) + if value: + if attribute['kind'] in ('birthdate', 'date'): + value = parse_date(value) + extra_context['profile_fields'][attribute['name']]['value'] = value + else: + extra_context['error'] = 'unknown user' + return extra_context diff --git a/combo/profile/templates/combo/profile.html b/combo/profile/templates/combo/profile.html new file mode 100644 index 00000000..ccc482f9 --- /dev/null +++ b/combo/profile/templates/combo/profile.html @@ -0,0 +1,14 @@ +{% load i18n %} +{% block cell-content %} +
+

{% trans "Profile" %}

+{% for key, details in profile_fields.items %} + {% if details.value and details.user_visible %} +

{{ details.label }} {{ details.value }}

+ {% endif %} +{% endfor %} +{% if error == 'unknown user' %} +

{% trans 'Unknown User' %}

+{% endif %} +
+{% endblock %} diff --git a/combo/public/templatetags/combo.py b/combo/public/templatetags/combo.py index 72e2ac91..57191571 100644 --- a/combo/public/templatetags/combo.py +++ b/combo/public/templatetags/combo.py @@ -19,8 +19,10 @@ from __future__ import absolute_import import datetime from django import template +from django.conf import settings from django.core import signing from django.core.exceptions import PermissionDenied +from django.template import VariableDoesNotExist from django.template.base import TOKEN_BLOCK, TOKEN_VAR from django.template.defaultfilters import stringfilter from django.utils import dateparse @@ -30,6 +32,9 @@ from combo.public.menu import get_menu_context from combo.utils import NothingInCacheException, flatten_context from combo.apps.dashboard.models import DashboardCell, Tile +if 'mellon' in settings.INSTALLED_APPS: + from mellon.models import UserSAMLIdentifier + register = template.Library() def skeleton_text(context, placeholder_name, content=''): @@ -229,3 +234,12 @@ def as_list(obj): @register.filter def signed(obj): return signing.dumps(obj) + +@register.filter +def name_id(user): + saml_id = UserSAMLIdentifier.objects.filter(user=user).last() + if saml_id: + return saml_id.name_id + # it is important to raise this so get_templated_url is aborted and no call + # is tried with a missing user argument. + raise VariableDoesNotExist('name_id') diff --git a/data/themes/gadjo/static/css/agent-portal.scss b/data/themes/gadjo/static/css/agent-portal.scss index bedeaf7b..814aadd8 100644 --- a/data/themes/gadjo/static/css/agent-portal.scss +++ b/data/themes/gadjo/static/css/agent-portal.scss @@ -184,6 +184,13 @@ div.cell { } } +div.profile { + span.value { + display: block; + margin-left: 1rem; + } +} + @media screen and (min-width: 1586px) { div#page-content div.cubesbarchart { width: 49.5%; diff --git a/tests/settings.py b/tests/settings.py index ccfe998d..b7ec4d10 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -62,3 +62,20 @@ FAMILY_SERVICE = {'root': '/'} BOOKING_CALENDAR_CELL_ENABLED = True NEWSLETTERS_CELL_ENABLED = True + +USER_PROFILE_CONFIG = { + 'fields': [ + { + 'name': 'first_name', + 'kind': 'string', + 'label': 'First Name', + 'user_visible': True, + }, + { + 'name': 'birthdate', + 'kind': 'birthdate', + 'label': 'Birth Date', + 'user_visible': True, + } + ] +} diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 00000000..57681e14 --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,51 @@ +import datetime +import json +import mock +import pytest + +from django.test import override_settings + +from combo.data.models import Page +from combo.profile.models import ProfileCell + +pytestmark = pytest.mark.django_db + + +@override_settings( + KNOWN_SERVICES={'authentic': {'idp': {'title': 'IdP', 'url': 'http://example.org/'}}}) +@mock.patch('combo.public.templatetags.combo.UserSAMLIdentifier') +@mock.patch('combo.utils.requests.get') +def test_profile_cell(requests_get, user_saml, app, admin_user): + page = Page() + page.save() + + cell = ProfileCell(page=page, order=0) + cell.save() + + data = {'first_name': 'Foo', 'birthdate': '2018-08-10'} + requests_get.return_value = mock.Mock( + content=json.dumps(data), + json=lambda: data, + status_code=200) + + def filter_mock(user=None): + assert user is admin_user + return mock.Mock(last=lambda: mock.Mock(name_id='123456')) + + mocked_objects = mock.Mock() + mocked_objects.filter = mock.Mock(side_effect=filter_mock) + user_saml.objects = mocked_objects + + context = cell.get_cell_extra_context({'synchronous': True, 'selected_user': admin_user}) + assert context['profile_fields']['first_name']['value'] == 'Foo' + assert context['profile_fields']['birthdate']['value'] == datetime.date(2018, 8, 10) + assert requests_get.call_args[0][0] == 'http://example.org/api/users/123456/' + + def filter_mock_missing(user=None): + return mock.Mock(last=lambda: None) + + mocked_objects.filter = mock.Mock(side_effect=filter_mock_missing) + + context = cell.get_cell_extra_context({'synchronous': True, 'selected_user': admin_user}) + assert context['error'] == 'unknown user' + assert requests_get.call_count == 1 # no new call was made -- 2.18.0