From f95f5b7847eb7bc80cfe186cce565868daa72a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Fri, 12 May 2017 14:06:55 +0200 Subject: [PATCH] general: add initial support for custom user dashboards (#15043) --- combo/apps/dashboard/__init__.py | 25 +++++++ combo/apps/dashboard/migrations/0001_initial.py | 50 +++++++++++++ combo/apps/dashboard/migrations/__init__.py | 0 combo/apps/dashboard/models.py | 66 +++++++++++++++++ .../templates/combo/dashboard_cell_icons.html | 11 +++ .../dashboard/templates/combo/dashboardcell.html | 11 +++ combo/apps/dashboard/urls.py | 28 +++++++ combo/apps/dashboard/views.py | 72 ++++++++++++++++++ combo/public/templatetags/combo.py | 16 ++++ combo/settings.py | 4 + tests/settings.py | 2 + tests/test_dashboard.py | 86 ++++++++++++++++++++++ 12 files changed, 371 insertions(+) create mode 100644 combo/apps/dashboard/__init__.py create mode 100644 combo/apps/dashboard/migrations/0001_initial.py create mode 100644 combo/apps/dashboard/migrations/__init__.py create mode 100644 combo/apps/dashboard/models.py create mode 100644 combo/apps/dashboard/templates/combo/dashboard_cell_icons.html create mode 100644 combo/apps/dashboard/templates/combo/dashboardcell.html create mode 100644 combo/apps/dashboard/urls.py create mode 100644 combo/apps/dashboard/views.py create mode 100644 tests/test_dashboard.py diff --git a/combo/apps/dashboard/__init__.py b/combo/apps/dashboard/__init__.py new file mode 100644 index 0000000..b2b584e --- /dev/null +++ b/combo/apps/dashboard/__init__.py @@ -0,0 +1,25 @@ +# combo - content management system +# Copyright (C) 2014-2017 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. + +import django.apps +from django.utils.translation import ugettext_lazy as _ + +class AppConfig(django.apps.AppConfig): + name = 'combo.apps.dashboard' + verbose_name = _('Dashboard') + + def get_before_urls(self): + from . import urls + return urls.urlpatterns + +default_app_config = 'combo.apps.dashboard.AppConfig' diff --git a/combo/apps/dashboard/migrations/0001_initial.py b/combo/apps/dashboard/migrations/0001_initial.py new file mode 100644 index 0000000..357a7bb --- /dev/null +++ b/combo/apps/dashboard/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('auth', '0006_require_contenttypes_0002'), + ('data', '0025_jsoncell_varnames_str'), + ] + + operations = [ + migrations.CreateModel( + name='DashboardCell', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('placeholder', models.CharField(max_length=20)), + ('order', models.PositiveIntegerField()), + ('slug', models.SlugField(verbose_name='Slug', blank=True)), + ('extra_css_class', models.CharField(max_length=100, verbose_name='Extra classes for CSS styling', blank=True)), + ('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(to='auth.Group', verbose_name='Groups', blank=True)), + ('page', models.ForeignKey(to='data.Page')), + ], + options={ + 'verbose_name': 'Dashboard', + }, + ), + migrations.CreateModel( + name='Tile', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('cell_pk', models.PositiveIntegerField()), + ('order', models.PositiveIntegerField()), + ('cell_type', models.ForeignKey(to='contenttypes.ContentType')), + ('dashboard', models.ForeignKey(to='dashboard.DashboardCell')), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('order',), + }, + ), + ] diff --git a/combo/apps/dashboard/migrations/__init__.py b/combo/apps/dashboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/combo/apps/dashboard/models.py b/combo/apps/dashboard/models.py new file mode 100644 index 0000000..decabf8 --- /dev/null +++ b/combo/apps/dashboard/models.py @@ -0,0 +1,66 @@ +# combo - content management system +# Copyright (C) 2014-2017 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 . + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes import fields +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from combo.data.models import CellBase +from combo.data.library import register_cell_class + + +@register_cell_class +class DashboardCell(CellBase): + # container for tiles + user_dependant = True + + class Meta: + verbose_name = _('Dashboard') + + class Media: + js = ('js/dashboard.js',) + + @classmethod + def is_enabled(cls): + return settings.COMBO_DASHBOARD_ENABLED + + def is_relevant(self, context): + if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated()): + return False + return True + + def render(self, context): + context['tiles'] = Tile.objects.filter(dashboard=self, user=context['user']) + return super(DashboardCell, self).render(context) + + +class Tile(models.Model): + dashboard = models.ForeignKey(DashboardCell) + cell_type = models.ForeignKey(ContentType) + cell_pk = models.PositiveIntegerField() + cell = fields.GenericForeignKey('cell_type', 'cell_pk') + user = models.ForeignKey(settings.AUTH_USER_MODEL) + order = models.PositiveIntegerField() + + class Meta: + ordering = ('order',) + + @classmethod + def get_by_cell(cls, cell): + cell_type = ContentType.objects.get_for_model(cell) + return cls.objects.get(cell_type__pk=cell_type.id, cell_pk=cell.id) diff --git a/combo/apps/dashboard/templates/combo/dashboard_cell_icons.html b/combo/apps/dashboard/templates/combo/dashboard_cell_icons.html new file mode 100644 index 0000000..c9cbac2 --- /dev/null +++ b/combo/apps/dashboard/templates/combo/dashboard_cell_icons.html @@ -0,0 +1,11 @@ +{% load i18n %} + +{% if not in_dashboard %} + +{% else %} + + +{% endif %} + diff --git a/combo/apps/dashboard/templates/combo/dashboardcell.html b/combo/apps/dashboard/templates/combo/dashboardcell.html new file mode 100644 index 0000000..42e61eb --- /dev/null +++ b/combo/apps/dashboard/templates/combo/dashboardcell.html @@ -0,0 +1,11 @@ +{% load combo i18n %} +{% for tile in tiles %} +{% with cell=tile.cell %} +
{% render_cell cell %}
+{% endwith %} +{% endfor %} diff --git a/combo/apps/dashboard/urls.py b/combo/apps/dashboard/urls.py new file mode 100644 index 0000000..1000e9c --- /dev/null +++ b/combo/apps/dashboard/urls.py @@ -0,0 +1,28 @@ +# combo - content management system +# Copyright (C) 2014-2017 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 . + +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^api/dashboard/add/(?P[\w_-]+)/$', + views.dashboard_add_tile, + name='combo-dashboard-add-tile'), + url(r'^api/dashboard/remove/(?P[\w_-]+)/$', + views.dashboard_remove_tile, + name='combo-dashboard-remove-tile'), +] diff --git a/combo/apps/dashboard/views.py b/combo/apps/dashboard/views.py new file mode 100644 index 0000000..22bb6ea --- /dev/null +++ b/combo/apps/dashboard/views.py @@ -0,0 +1,72 @@ +# combo - content management system +# Copyright (C) 2014-2017 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 . + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.views.generic import RedirectView + +from combo.data.models import CellBase +from combo.data.library import get_cell_class + +from .models import DashboardCell, Tile + + +class DashboardAddTileView(RedirectView): + permanent = False + + def get_redirect_url(self, cell_reference): + dashboard = DashboardCell.objects.all()[0] + + cell = CellBase.get_cell(cell_reference) + if not cell.page.is_visible(self.request.user): + raise PermissionDenied() + if not cell.is_visible(self.request.user): + raise PermissionDenied() + cell.pk = None + cell.page = dashboard.page + cell.placeholder = '_dashboard' + cell.save() + + tile = Tile(dashboard=dashboard, + cell=cell, + user=self.request.user) + tile.order = 0 + tile.save() + + return dashboard.page.get_online_url() + +dashboard_add_tile = DashboardAddTileView.as_view() + + +class DashboardRemoveTileView(RedirectView): + permanent = False + + def get_redirect_url(self, cell_reference): + cell = CellBase.get_cell(cell_reference) + try: + tile = Tile.get_by_cell(cell) + except Tile.DoesNotExist: + raise Http404() + if tile.user != self.request.user: + raise PermissionDenied() + dashboard = tile.dashboard + tile.delete() + cell.delete() + return dashboard.page.get_online_url() + +dashboard_remove_tile = DashboardRemoveTileView.as_view() diff --git a/combo/public/templatetags/combo.py b/combo/public/templatetags/combo.py index 2bde493..0c69553 100644 --- a/combo/public/templatetags/combo.py +++ b/combo/public/templatetags/combo.py @@ -19,12 +19,14 @@ from __future__ import absolute_import import datetime from django import template +from django.core.exceptions import PermissionDenied from django.template import RequestContext from django.template.base import TOKEN_BLOCK, TOKEN_VAR from django.utils import dateparse from combo.public.menu import get_menu_context from combo.utils import NothingInCacheException +from combo.apps.dashboard.models import DashboardCell, Tile register = template.Library() @@ -49,6 +51,20 @@ def placeholder(context, placeholder_name): def render_cell(context, cell): if context.get('render_skeleton') and cell.is_user_dependant(context): return template.loader.get_template('combo/deferred-cell.html').render(context) + + in_dashboard = False + if DashboardCell.is_enabled(): + # check if cell is actually a dashboard tile + try: + tile = Tile.get_by_cell(cell) + except Tile.DoesNotExist: + pass + else: + if context['request'].user != tile.user: + raise PermissionDenied() + in_dashboard = True + + context['in_dashboard'] = in_dashboard try: return cell.render(context) except NothingInCacheException: diff --git a/combo/settings.py b/combo/settings.py index 0f0247d..a360d55 100644 --- a/combo/settings.py +++ b/combo/settings.py @@ -64,6 +64,7 @@ INSTALLED_APPS = ( 'combo.profile', 'combo.manager', 'combo.public', + 'combo.apps.dashboard', 'combo.apps.wcs', 'combo.apps.publik', 'combo.apps.family', @@ -277,6 +278,9 @@ COMBO_INITIAL_LOGIN_PAGE_PATH = None # page to redirect on the first visit, to suggest user to log in. COMBO_WELCOME_PAGE_PATH = None +# dashboard support +COMBO_DASHBOARD_ENABLED = False + local_settings_file = os.environ.get('COMBO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')) if os.path.exists(local_settings_file): diff --git a/tests/settings.py b/tests/settings.py index 29f43cf..2ebafa2 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -21,6 +21,8 @@ MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' LINGO_API_SIGN_KEY = '12345' LINGO_SIGNATURE_KEY = '54321' +COMBO_DASHBOARD_ENABLED = True + import tempfile MEDIA_ROOT = tempfile.mkdtemp('combo-test') diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..21c88e4 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,86 @@ +from webtest import TestApp +import pytest + +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse + +from combo.wsgi import application +from combo.data.models import Page, CellBase, TextCell +from combo.apps.dashboard.models import DashboardCell, Tile + +pytestmark = pytest.mark.django_db + +from test_manager import admin_user, login + +@pytest.fixture +def site(admin_user): + page = Page(title='One', slug='index') + page.save() + # order=100 will be useful to get to the cell later on + cell = TextCell(page=page, order=100, placeholder='content', text='hello world') + cell.save() + + page = Page(title='Two', slug='two') + page.save() + dashboard_cell = DashboardCell(page=page, order=0, placeholder='content') + dashboard_cell.save() + + +def test_empty_dashboard(app, site): + resp = app.get('/', status=200) + assert 'hello world' in resp.body + resp = app.get('/two/', status=200) + assert not 'dashboardcell' in resp.body + app = login(app) + resp = app.get('/two/', status=200) + assert 'dashboardcell' in resp.body + +def test_add_to_dashboard(app, site): + app = login(app) + cell = TextCell.objects.get(order=100) + dashboard = DashboardCell.objects.all()[0] + user = User.objects.all()[0] + resp = app.get(reverse('combo-dashboard-add-tile', + kwargs={'cell_reference': cell.get_reference()})) + assert Tile.objects.count() == 1 + assert Tile.objects.all()[0].cell.id != cell.id + assert Tile.objects.all()[0].cell.text == cell.text + assert Tile.objects.all()[0].dashboard_id == dashboard.id + assert Tile.objects.all()[0].user_id == user.id + + app = login(app) + resp = app.get('/two/', status=200) + assert 'hello world' in resp.body + +def test_ajax_render(app, site): + test_add_to_dashboard(app, site) + app.reset() # logout + tile = Tile.objects.all()[0] + page = Page.objects.get(slug='two') + resp = app.get(reverse('combo-public-ajax-page-cell', + kwargs={'page_pk': page.id, 'cell_reference': tile.cell.get_reference()}), + status=403) + + app = login(app) + resp = app.get(reverse('combo-public-ajax-page-cell', + kwargs={'page_pk': page.id, 'cell_reference': tile.cell.get_reference()}), + status=200) + + user = User.objects.create_user('plop', email=None, password='plop') + app = login(app, username='plop', password='plop') + resp = app.get(reverse('combo-public-ajax-page-cell', + kwargs={'page_pk': page.id, 'cell_reference': tile.cell.get_reference()}), + status=403) + +def test_remove_from_dashboard(app, site): + test_add_to_dashboard(app, site) + app.reset() # logout + tile = Tile.objects.all()[0] + resp = app.get(reverse('combo-dashboard-remove-tile', + kwargs={'cell_reference': tile.cell.get_reference()}), status=403) + + app = login(app) + resp = app.get(reverse('combo-dashboard-remove-tile', + kwargs={'cell_reference': tile.cell.get_reference()}), status=302) + assert Tile.objects.count() == 0 + assert TextCell.objects.count() == 1 -- 2.11.0