general: add initial support for custom user dashboards (#15043)

This commit is contained in:
Frédéric Péters 2017-05-12 14:06:55 +02:00
parent bd2df6fb70
commit cc84f3c5be
14 changed files with 380 additions and 0 deletions

View File

@ -5,12 +5,14 @@ recursive-include combo/locale *.po *.mo
recursive-include combo/apps/usersearch/static *.css *.js *.ico *.gif *.png *.jpg
recursive-include combo/apps/lingo/static *.css *.js *.ico *.gif *.png *.jpg
recursive-include combo/apps/dataviz/static *.css *.js *.ico *.gif *.png *.jpg
recursive-include combo/apps/dashboard/static *.js
recursive-include combo/manager/static *.css *.js *.ico *.gif *.png *.jpg
recursive-include combo/public/static *.css *.js *.ico *.gif *.png *.jpg
recursive-include data/themes *.css *.js *.gif *.png *.jpg *.jpeg *.html
# templates
recursive-include combo/data/templates *.html *.txt
recursive-include combo/apps/dashboard/templates *.html
recursive-include combo/apps/search/templates *.html
recursive-include combo/apps/usersearch/templates *.html
recursive-include combo/apps/dataviz/templates *.html

View File

@ -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'

View File

@ -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',),
},
),
]

View File

@ -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 <http://www.gnu.org/licenses/>.
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)

View File

@ -0,0 +1,5 @@
$(function() {
$('.dashboardcell').delegate('a.dashboard-cell-menu', 'click', function() {
$(this).next().toggleClass('closed');
});
});

View File

@ -0,0 +1,13 @@
{% load i18n %}
{% if user.is_authenticated %}
<span class="dashboard-cell-icons">
{% if not in_dashboard %}
<a class="add-to-dashboard" href="{% url 'combo-dashboard-add-tile' cell_reference=cell.get_reference %}"></a>
{% else %}
<a class="dashboard-cell-menu"></a>
<ul class="menu closed">
<li><a href="{% url 'combo-dashboard-remove-tile' cell_reference=cell.get_reference %}">{% trans 'Remove from favorites' %}</a></li>
</ul>
{% endif %}
{% endif %}
</span>

View File

@ -0,0 +1,11 @@
{% load combo i18n %}
{% for tile in tiles %}
{% with cell=tile.cell %}
<div class="cell {{ cell.css_class_names }} {% if cell.slug %}{{cell.slug}}{% endif %}"
data-ajax-cell-url="{{ site_base }}{% url 'combo-public-ajax-page-cell' page_pk=cell.page.id cell_reference=cell.get_reference %}"
data-ajax-cell-loading-message="{% trans "Loading..." %}"
{% if cell.ajax_refresh %}
data-ajax-cell-refresh="{{ cell.ajax_refresh }}"
{% endif %}><div>{% render_cell cell %}</div></div>
{% endwith %}
{% endfor %}

View File

@ -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 <http://www.gnu.org/licenses/>.
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^api/dashboard/add/(?P<cell_reference>[\w_-]+)/$',
views.dashboard_add_tile,
name='combo-dashboard-add-tile'),
url(r'^api/dashboard/remove/(?P<cell_reference>[\w_-]+)/$',
views.dashboard_remove_tile,
name='combo-dashboard-remove-tile'),
]

View File

@ -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 <http://www.gnu.org/licenses/>.
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()

View File

@ -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:

View File

@ -65,6 +65,7 @@ INSTALLED_APPS = (
'combo.profile',
'combo.manager',
'combo.public',
'combo.apps.dashboard',
'combo.apps.wcs',
'combo.apps.publik',
'combo.apps.family',
@ -278,6 +279,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):

View File

@ -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')

86
tests/test_dashboard.py Normal file
View File

@ -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