Compare commits
3 Commits
main
...
wip/galler
Author | SHA1 | Date |
---|---|---|
Frédéric Péters | 9d4bfc331c | |
Frédéric Péters | 72a80d98cd | |
Frédéric Péters | 134ce73db1 |
|
@ -1,5 +0,0 @@
|
|||
[run]
|
||||
dynamic_context = test_function
|
||||
|
||||
[html]
|
||||
show_contexts = True
|
|
@ -1,12 +0,0 @@
|
|||
# trivial: apply black
|
||||
47d67c395ef124ea4534ea7c56d48d4e302db430
|
||||
# misc: apply isort (#52797)
|
||||
29bc8e66a978b1bbadbc05186599a70ce1b8ef98
|
||||
# misc: apply pyupgrade (#55868)
|
||||
f4615c506194cf4eace0af551f14f54552f09dc5
|
||||
# misc: apply djhtml (#69709)
|
||||
4784a3990eec0aadadce99bf1da21c0b531b289d
|
||||
# misc: apply django-upgrade (#69798)
|
||||
cd498afcb0bcd8d3f2fce98c9747ca1406d9a449
|
||||
# misc: apply double-quote-string-fixer (#79788)
|
||||
9d8876e155e8a433ebafbd4ccb9de4960d1830b4
|
|
@ -11,17 +11,8 @@ combo.egg-info/
|
|||
.sass-cache/
|
||||
combo/apps/maps/static/css/combo.map.css
|
||||
combo/apps/maps/static/css/combo.map.css.map
|
||||
combo/apps/pwa/static/css/combo.manager.pwa.css
|
||||
combo/apps/pwa/static/css/combo.manager.pwa.css.map
|
||||
combo/apps/family/static/css/combo.weekly_agenda.css
|
||||
combo/apps/dataviz/static/css/combo.multiselectwidget.css
|
||||
combo/manager/static/css/combo.manager.css
|
||||
data/themes/gadjo/static/css/agent-portal.css
|
||||
data/themes/gadjo/static/css/agent-portal.css.map
|
||||
.cache
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
node_modules/
|
||||
coverage/
|
||||
package.json
|
||||
package-lock.json
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: double-quote-string-fixer
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.3.1
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: ['--keep-percent-format', '--py39-plus']
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: 1.13.0
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args: ['--target-version', '3.2']
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
args: ['--target-version', 'py39', '--skip-string-normalization', '--line-length', '110']
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
args: ['--profile', 'black', '--line-length', '110']
|
||||
- repo: https://github.com/rtts/djhtml
|
||||
rev: '3.0.5'
|
||||
hooks:
|
||||
- id: djhtml
|
||||
args: ['--tabwidth', '2']
|
||||
- repo: https://git.entrouvert.org/pre-commit-debian.git
|
||||
rev: v0.3
|
||||
hooks:
|
||||
- id: pre-commit-debian
|
|
@ -1,43 +1,29 @@
|
|||
@Library('eo-jenkins-lib@main') import eo.Utils
|
||||
@Library('eo-jenkins-lib@master') import eo.Utils
|
||||
|
||||
pipeline {
|
||||
agent any
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
timeout(time: 20, unit: 'MINUTES')
|
||||
}
|
||||
stages {
|
||||
stage('Unit Tests') {
|
||||
steps {
|
||||
sh 'nox'
|
||||
sh 'tox -rv'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
utils = new Utils()
|
||||
utils.publish_coverage('coverage.xml,coverage/cobertura-coverage.xml')
|
||||
utils.publish_coverage('coverage.xml')
|
||||
utils.publish_coverage_native('index.html')
|
||||
utils.publish_pylint('pylint.out')
|
||||
}
|
||||
mergeJunitResults()
|
||||
junit '*_results.xml'
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Packaging') {
|
||||
steps {
|
||||
script {
|
||||
env.SHORT_JOB_NAME=sh(
|
||||
returnStdout: true,
|
||||
// given JOB_NAME=gitea/project/PR-46, returns project
|
||||
// given JOB_NAME=project/main, returns project
|
||||
script: '''
|
||||
echo "${JOB_NAME}" | sed "s/gitea\\///" | awk -F/ '{print $1}'
|
||||
'''
|
||||
).trim()
|
||||
if (env.GIT_BRANCH == 'main' || env.GIT_BRANCH == 'origin/main') {
|
||||
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye,bookworm ${SHORT_JOB_NAME}"
|
||||
} else if (env.GIT_BRANCH.startsWith('hotfix/')) {
|
||||
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye,bookworm --branch ${env.GIT_BRANCH} --hotfix ${SHORT_JOB_NAME}"
|
||||
if (env.JOB_NAME == 'combo' && env.GIT_BRANCH == 'origin/master') {
|
||||
sh 'sudo -H -u eobuilder /usr/local/bin/eobuilder combo'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -47,7 +33,7 @@ pipeline {
|
|||
always {
|
||||
script {
|
||||
utils = new Utils()
|
||||
utils.mail_notify(currentBuild, env, 'ci+jenkins-combo@entrouvert.org')
|
||||
utils.mail_notify(currentBuild, env, 'admin+jenkins-combo@entrouvert.com')
|
||||
}
|
||||
}
|
||||
success {
|
||||
|
|
16
MANIFEST.in
16
MANIFEST.in
|
@ -1,16 +1,14 @@
|
|||
# locales
|
||||
recursive-include combo/locales/locale *.po *.mo
|
||||
recursive-include combo/locale *.po *.mo
|
||||
|
||||
# static
|
||||
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 *.scss
|
||||
recursive-include combo/apps/dataviz/static *.css *.js *.ico *.gif *.png *.jpg
|
||||
recursive-include combo/apps/dashboard/static *.js
|
||||
recursive-include combo/apps/family/static *.css *.scss *.js
|
||||
recursive-include combo/apps/gallery/static *.js
|
||||
recursive-include combo/apps/maps/static *.css *.scss *.js
|
||||
recursive-include combo/apps/pwa/static *.css *.scss *.js *.svg
|
||||
recursive-include combo/apps/wcs/static *.js *.css
|
||||
recursive-include combo/manager/static *.scss *.css *.js *.ico *.gif *.png *.jpg
|
||||
recursive-include combo/manager/static *.css *.js *.ico *.gif *.png *.jpg
|
||||
recursive-include combo/public/static *.css *.js *.ico *.gif *.png *.jpg
|
||||
|
||||
# templates
|
||||
|
@ -19,13 +17,13 @@ recursive-include combo/apps/assets/templates *.html
|
|||
recursive-include combo/apps/calendar/templates *.html
|
||||
recursive-include combo/apps/dashboard/templates *.html
|
||||
recursive-include combo/apps/search/templates *.html
|
||||
recursive-include combo/apps/dataviz/templates *.html *.svg
|
||||
recursive-include combo/apps/usersearch/templates *.html
|
||||
recursive-include combo/apps/dataviz/templates *.html
|
||||
recursive-include combo/apps/family/templates *.html
|
||||
recursive-include combo/apps/fargo/templates *.html
|
||||
recursive-include combo/apps/gallery/templates *.html
|
||||
recursive-include combo/apps/kb/templates *.html
|
||||
recursive-include combo/apps/lingo/templates *.html
|
||||
recursive-include combo/apps/maps/templates *.html
|
||||
recursive-include combo/apps/momo/templates *.html
|
||||
recursive-include combo/apps/newsletters/templates *.html
|
||||
recursive-include combo/apps/notifications/templates *.html
|
||||
recursive-include combo/apps/pwa/templates *.html *.js *.json
|
||||
|
|
38
README
38
README
|
@ -13,7 +13,7 @@ Dependencies can be installed with pip,
|
|||
$ pip install -r requirements.txt
|
||||
|
||||
It's then required to get the database configured (./manage.py migrate); by
|
||||
default it will create a postgresqsl DB.
|
||||
default it will create a db.sqlite3 file.
|
||||
|
||||
You can then run the Django test server for a quick try (you should refer to
|
||||
the Django documentation for production deployments).
|
||||
|
@ -88,32 +88,11 @@ Unit tests are written using py.test, and its pytest-django support library.
|
|||
|
||||
DJANGO_SETTINGS_MODULE=combo.settings COMBO_SETTINGS_FILE=tests/settings.py py.test
|
||||
|
||||
Tests for w.c.s. cells do require access to the wcsctl script, its location has
|
||||
to be given in a WCSCTL environment variable, this give a full command line:
|
||||
|
||||
Code Style
|
||||
----------
|
||||
|
||||
black is used to format the code, using thoses parameters:
|
||||
|
||||
black --target-version py37 --skip-string-normalization --line-length 110
|
||||
|
||||
isort is used to format the imports, using those parameters:
|
||||
|
||||
isort --profile black --line-length 110
|
||||
|
||||
pyupgrade is used to automatically upgrade syntax, using those parameters:
|
||||
|
||||
pyupgrade --keep-percent-format --py37-plus
|
||||
|
||||
djhtml is used to automatically indent html files, using those parameters:
|
||||
|
||||
djhtml --tabwidth 2
|
||||
|
||||
django-upgrade is used to automatically upgrade Django syntax, using those parameters:
|
||||
|
||||
django-upgrade --target-version 3.2
|
||||
|
||||
There is .pre-commit-config.yaml to use pre-commit to automatically run these tools
|
||||
before commits. (execute `pre-commit install` to install the git hook.)
|
||||
WCSCTL=$(pwd)/wcs/wcsctl.py \
|
||||
DJANGO_SETTINGS_MODULE=combo.settings COMBO_SETTINGS_FILE=tests/settings.py py.test
|
||||
|
||||
|
||||
License
|
||||
|
@ -141,10 +120,3 @@ Gauge.js
|
|||
License: MIT
|
||||
Comment:
|
||||
From http://bernii.github.io/gauge.js/
|
||||
|
||||
Pygal.tooltip.js
|
||||
Files: combo/apps/dataviz/static/js/pygal.tooltip.js
|
||||
Copyright: 2015, Florian Mounier Kozea
|
||||
License: LGPL-3+
|
||||
Comment:
|
||||
From https://github.com/Kozea/pygal.js/
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2017-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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import django.apps
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class AppConfig(django.apps.AppConfig):
|
||||
name = 'combo.apps.assets'
|
||||
verbose_name = _('Assets')
|
||||
|
||||
def get_before_urls(self):
|
||||
from . import urls
|
||||
return urls.urlpatterns
|
||||
|
||||
def get_extra_manager_actions(self):
|
||||
return [{'href': reverse('combo-manager-assets'),
|
||||
'text': _('Assets')}]
|
||||
|
||||
default_app_config = 'combo.apps.assets.AppConfig'
|
|
@ -1,62 +0,0 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2019 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/>.
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
from django.core.files import File
|
||||
from rest_framework import permissions, serializers, status
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Asset
|
||||
|
||||
|
||||
class FileSerializer(serializers.Serializer):
|
||||
content = serializers.CharField(required=True, allow_blank=False)
|
||||
content_type = serializers.CharField(required=False, allow_null=True)
|
||||
filename = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
def validate_content(self, value):
|
||||
try:
|
||||
return base64.decodebytes(value.encode('ascii'))
|
||||
except Exception as e:
|
||||
raise serializers.ValidationError('content must be base64 (%r)' % e)
|
||||
|
||||
|
||||
class AssetSerializer(serializers.Serializer):
|
||||
asset = FileSerializer(required=True)
|
||||
|
||||
|
||||
class Set(GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
serializer_class = AssetSerializer
|
||||
|
||||
def post(self, request, key, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
response = {'err': 1, 'err_desc': serializer.errors}
|
||||
return Response(response, status.HTTP_400_BAD_REQUEST)
|
||||
data = serializer.validated_data
|
||||
|
||||
asset, dummy = Asset.objects.get_or_create(key=key)
|
||||
asset.asset = File(BytesIO(data['asset']['content']), name=data['asset'].get('filename'))
|
||||
asset.save()
|
||||
response = {'err': 0, 'url': request.build_absolute_uri(f'/assets/{key}')}
|
||||
return Response(response)
|
||||
|
||||
|
||||
view_set = Set.as_view()
|
|
@ -14,30 +14,9 @@
|
|||
# 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/>.
|
||||
|
||||
import PIL
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def validate_asset_file(value):
|
||||
try:
|
||||
PIL.Image.open(value.file)
|
||||
except PIL.UnidentifiedImageError:
|
||||
pass # not an image
|
||||
except PIL.Image.DecompressionBombError as expt:
|
||||
raise ValidationError(
|
||||
_('Uploaded image exceeds size limits: %(detail)s'), params={'detail': str(expt)}
|
||||
)
|
||||
return True
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class AssetUploadForm(forms.Form):
|
||||
upload = forms.FileField(label=_('File'), validators=[validate_asset_file])
|
||||
|
||||
|
||||
class AssetsImportForm(forms.Form):
|
||||
assets_file = forms.FileField(
|
||||
label=_('Assets File'), help_text=_('Archive (.tar) with asset files as content.')
|
||||
)
|
||||
overwrite = forms.BooleanField(label=_('Overwrite Existing Files'), required=False)
|
||||
upload = forms.FileField(label=_('File'))
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.12 on 2018-06-12 11:42
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Asset',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=128, unique=True)),
|
||||
('asset', models.FileField(upload_to='assets')),
|
||||
('asset', models.FileField(upload_to=b'assets')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('assets', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='asset',
|
||||
name='key',
|
||||
field=models.CharField(max_length=256, unique=True),
|
||||
),
|
||||
]
|
|
@ -19,7 +19,6 @@ import json
|
|||
from django.core import serializers
|
||||
from django.db import models
|
||||
|
||||
|
||||
class AssetManager(models.Manager):
|
||||
def get_by_natural_key(self, key):
|
||||
return self.get(key=key)
|
||||
|
@ -28,7 +27,7 @@ class AssetManager(models.Manager):
|
|||
class Asset(models.Model):
|
||||
objects = AssetManager()
|
||||
|
||||
key = models.CharField(max_length=256, unique=True)
|
||||
key = models.CharField(max_length=128, unique=True)
|
||||
asset = models.FileField(upload_to='assets')
|
||||
|
||||
@classmethod
|
||||
|
@ -36,11 +35,8 @@ class Asset(models.Model):
|
|||
return [x.get_as_serialized_object() for x in Asset.objects.all()]
|
||||
|
||||
def get_as_serialized_object(self):
|
||||
serialized_asset = json.loads(
|
||||
serializers.serialize(
|
||||
'json', [self], use_natural_foreign_keys=True, use_natural_primary_keys=True
|
||||
)
|
||||
)[0]
|
||||
serialized_asset = json.loads(serializers.serialize('json', [self],
|
||||
use_natural_foreign_keys=True, use_natural_primary_keys=True))[0]
|
||||
del serialized_asset['model']
|
||||
del serialized_asset['pk']
|
||||
return serialized_asset
|
||||
|
@ -53,7 +49,7 @@ class Asset(models.Model):
|
|||
@classmethod
|
||||
def load_serialized_object(cls, json_asset):
|
||||
json_asset['model'] = 'assets.asset'
|
||||
asset, dummy = Asset.objects.get_or_create(key=json_asset['fields']['key'])
|
||||
asset, created = Asset.objects.get_or_create(key=json_asset['fields']['key'])
|
||||
json_asset['pk'] = asset.id
|
||||
asset = next(serializers.deserialize('json', json.dumps([json_asset]), ignorenonexistent=True))
|
||||
asset = [x for x in serializers.deserialize('json', json.dumps([json_asset]))][0]
|
||||
asset.save()
|
||||
|
|
|
@ -2,16 +2,16 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Delete Asset' %}</h2>
|
||||
<h2>{% trans 'Delete Asset' %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% blocktrans %}Are you sure you want to delete this?{% endblocktrans %}
|
||||
<div class="buttons">
|
||||
<button class="delete-button">{% trans 'Delete' %}</button>
|
||||
<a class="cancel" href="{% url 'combo-manager-assets' %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% blocktrans %}Are you sure you want to delete this?{% endblocktrans %}
|
||||
<div class="buttons">
|
||||
<button class="delete-button">{% trans 'Delete' %}</button>
|
||||
<a class="cancel" href="{% url 'combo-manager-assets' %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -2,20 +2,20 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans "Overwrite Asset" %}</h2>
|
||||
<h2>{% trans "Overwrite Asset" %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% trans "This will erase the existing file and replace it with a new one." %}
|
||||
</p>
|
||||
{{ form.as_p }}
|
||||
<div class="buttons">
|
||||
<button class="submit-button">{% trans "Upload" %}</button>
|
||||
<a class="cancel" href="{% url 'combo-manager-assets' %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% trans "This will erase the existing file and replace it with a new one." %}
|
||||
</p>
|
||||
{{ form.as_p }}
|
||||
<div class="buttons">
|
||||
<button class="submit-button">{% trans "Upload" %}</button>
|
||||
<a class="cancel" href="{% url 'combo-manager-assets' %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -2,17 +2,17 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans "Asset Upload" %}</h2>
|
||||
<h2>{% trans "Asset Upload" %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<div class="buttons">
|
||||
<button class="submit-button">{% trans "Upload" %}</button>
|
||||
<a class="cancel" href="{% url 'combo-manager-assets' %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<div class="buttons">
|
||||
<button class="submit-button">{% trans "Upload" %}</button>
|
||||
<a class="cancel" href="{% url 'combo-manager-assets' %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -2,49 +2,78 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Assets' %}</h2>
|
||||
<span class="actions">
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
<a href="{% url 'combo-manager-asset-upload' %}" rel="popup">{% trans 'Upload' %}</a>
|
||||
<ul class="extra-actions-menu">
|
||||
<li><a href="{% url 'combo-manager-assets-export' %}">{% trans 'Export assets as archive' %}</a></li>
|
||||
<li><a rel="popup" href="{% url 'combo-manager-assets-import' %}">{% trans 'Import archive of assets' %}</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
<h2>{% trans 'Assets' %}</h2>
|
||||
<span class="actions">
|
||||
<a href="{% url 'combo-manager-asset-upload' %}" rel="popup">{% trans 'Upload' %}</a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'combo-manager-assets' %}">{% trans 'Assets' %}</a>
|
||||
{{ block.super }}
|
||||
<a href="{% url 'combo-manager-assets' %}">{% trans 'Assets' %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if not object_list and not query %}
|
||||
<div class="big-msg-info">
|
||||
{% blocktrans %}
|
||||
This site doesn't have any asset yet. You can add some directly when editing
|
||||
pages, in the "Upload Image" dialog.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% if not object_list and not query %}
|
||||
<div class="big-msg-info">
|
||||
{% blocktrans %}
|
||||
This site doesn't have any asset yet. You can add some directly when editing
|
||||
pages, in the "Upload Image" dialog.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{% else %}
|
||||
|
||||
<form>
|
||||
<p><input name="q" type="search" value="{{query}}"> <button>{% trans 'Search' %}</button>
|
||||
<span class="help_text">{% trans "(case insensitive search over filenames)" %}</span>
|
||||
</p>
|
||||
</form>
|
||||
<form>
|
||||
<p><input name="q" type="search" value="{{query}}"> <button>{% trans 'Search' %}</button>
|
||||
<span class="help_text">{% trans "(case insensitive search over filenames)" %}</span>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div id="assets-browser">
|
||||
<div id="assets-listing">
|
||||
{% include "combo/manager_assets_fragment.html" %}
|
||||
</div>
|
||||
<div id="asset-preview"></div>
|
||||
</div>
|
||||
<div id="assets-browser">
|
||||
<div id="assets-listing">
|
||||
<table class="main">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Size" %}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for asset in object_list %}
|
||||
<tr class="{{ asset.css_classes }}">
|
||||
<td><a href="{{ asset.src }}">{{ asset.name }}</a></td>
|
||||
<td>{% if asset.size %}{{ asset.size|filesizeformat }}{% else %}-{% endif %}</td>
|
||||
<td class="image">{% if asset.is_image %}<img data-href="{{ asset.src }}" src="{{ asset.thumb }}"/>{% endif %}</td>
|
||||
<td class="actions">
|
||||
{% if asset.key %}{# theme asset #}
|
||||
<a href="{% url 'combo-manager-slot-asset-upload' key=asset.key %}"
|
||||
class="overwrite" rel="popup">{% trans 'Overwrite' %}</a>
|
||||
{% if asset.asset %}
|
||||
<a href="{% url 'combo-manager-slot-asset-delete' key=asset.key %}"
|
||||
class="delete" rel="popup">{% trans 'Delete' %}</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a href="{% url 'combo-manager-asset-overwrite' %}?img={{asset.filepath|iriencode}}"
|
||||
class="overwrite" rel="popup">{% trans 'Overwrite' %}</a>
|
||||
<a href="{% url 'combo-manager-asset-delete' %}?img={{asset.filepath|iriencode}}"
|
||||
class="delete" rel="popup">{% trans 'Delete' %}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="asset-preview"></div>
|
||||
</div>
|
||||
|
||||
{% include "gadjo/pagination.html" %}
|
||||
{% include "gadjo/pagination.html" %}
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
{% extends "gadjo/base.html" %}
|
||||
{% load gadjo static i18n %}
|
||||
|
||||
{% block css %}
|
||||
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/combo.manager.css" %}?{% start_timestamp %}"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrascripts %}
|
||||
<script src="{% static "js/combo.manager.js" %}?{% start_timestamp %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block user-links %}{% endblock %}
|
||||
{% block sidepage %}{% endblock %}
|
||||
{% block site-header %}{% endblock %}
|
||||
{% block bodyargs %}class="no-header"{% endblock %}
|
||||
{% block footer %}{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans "Browse for the file you want, then click 'Embed File' to continue..." %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if not object_list and not query %}
|
||||
<div class="big-msg-info">
|
||||
{% trans "No files found. Upload files using the 'Image Button' or 'Link Button' dialog's 'Upload' tab." %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
<form>
|
||||
{% for k, v in request.GET.items %}
|
||||
{% if k != 'q' %}<input type="hidden" name="{{ k }}" value="{{ v }}" />{% endif %}
|
||||
{% endfor %}
|
||||
<p><input name="q" type="search" value="{{query}}"> <button>{% trans 'Search' %}</button>
|
||||
<span class="help_text">{% trans "(case insensitive search over filenames)" %}</span>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div id="assets-browser" class="assets-ckeditor">
|
||||
<div id="assets-listing">
|
||||
{% with asset_for_ckeditor=True %}
|
||||
{% include "combo/manager_assets_fragment.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div id="asset-preview"></div>
|
||||
</div>
|
||||
|
||||
{% include "gadjo/pagination.html" %}
|
||||
|
||||
<div class="buttons">
|
||||
<input href="" id="asset-ckeditor-embed" type="submit" name="_embed" value="{% trans "Embed File" %}" disabled />
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
<script type="text/javascript">
|
||||
// helper functions
|
||||
function getUrlParam(paramName) {
|
||||
var reParam = new RegExp('(?:[\?&]|&)' + paramName + '=([^&]+)', 'i') ;
|
||||
var match = window.location.search.match(reParam) ;
|
||||
|
||||
return (match && match.length > 1) ? match[1] : '' ;
|
||||
}
|
||||
|
||||
// embedder
|
||||
$(document).on('click', '#asset-ckeditor-embed', function() {
|
||||
var funcNum = getUrlParam('CKEditorFuncNum');
|
||||
var fileUrl = $(this).attr('href');
|
||||
window.opener.CKEDITOR.tools.callFunction(funcNum, fileUrl);
|
||||
window.close();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1 +0,0 @@
|
|||
({{ size|filesizeformat }})
|
|
@ -1,46 +0,0 @@
|
|||
{% load i18n %}
|
||||
<table class="main">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Size" %}</th>
|
||||
<th></th>
|
||||
{% if not asset_for_ckeditor %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for asset in object_list %}
|
||||
<tr class="{{ asset.css_classes }}" data-href="{{ asset.src }}">
|
||||
<td>
|
||||
{% if asset_for_ckeditor %}
|
||||
{{ asset.name }}
|
||||
{% elif asset.src %}
|
||||
<a href="{{ asset.src }}">{{ asset.name }}</a>
|
||||
{% else %}
|
||||
{{ asset.name }} <span class="not-defined">({% trans "not defined" %})</span>
|
||||
{% endif %}
|
||||
<td>{% if asset.size %}{{ asset.size|filesizeformat }}{% else %}-{% endif %}</td>
|
||||
<td class="image">{% if asset.is_image %}<img data-href="{{ asset.src }}" src="{{ asset.thumb }}"/>{% endif %}</td>
|
||||
{% if not asset_for_ckeditor %}
|
||||
<td class="actions">
|
||||
{% if asset.key %}{# theme asset #}
|
||||
<a href="{% url 'combo-manager-slot-asset-upload' key=asset.key %}{% if cell_reference %}?cell_reference={{ cell_reference }}{% endif %}"
|
||||
class="overwrite" rel="popup">{% trans 'Overwrite' %}</a>
|
||||
{% if asset.asset %}
|
||||
<a href="{% url 'combo-manager-slot-asset-delete' key=asset.key %}{% if cell_reference %}?cell_reference={{ cell_reference }}{% endif %}"
|
||||
class="delete" rel="popup">{% trans 'Delete' %}</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a href="{% url 'combo-manager-asset-overwrite' %}?img={{asset.filepath|iriencode}}"
|
||||
class="overwrite" rel="popup">{% trans 'Overwrite' %}</a>
|
||||
<a href="{% url 'combo-manager-asset-delete' %}?img={{asset.filepath|iriencode}}"
|
||||
class="delete" rel="popup">{% trans 'Delete' %}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
|
@ -1,21 +0,0 @@
|
|||
{% extends "combo/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans "Assets Archive Import" %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% trans "Assets archive import allows you to integrate assets exported from another site." %}
|
||||
</p>
|
||||
{{ form.as_p }}
|
||||
<div class="buttons">
|
||||
<button class="submit-button">{% trans "Import" %}</button>
|
||||
<a class="cancel" href="{% url 'combo-manager-assets' %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,19 +0,0 @@
|
|||
{% extends "combo/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Assets' %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'combo-manager-assets' %}">{% trans 'Assets' %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="assets-listing">
|
||||
{% include "combo/manager_assets_fragment.html" %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -14,10 +14,10 @@
|
|||
# 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/>.
|
||||
|
||||
import os
|
||||
|
||||
from django import template
|
||||
from django.db.models.fields.files import ImageFieldFile
|
||||
from django.utils import six
|
||||
|
||||
from sorl.thumbnail.shortcuts import get_thumbnail
|
||||
|
||||
from ..models import Asset
|
||||
|
@ -37,7 +37,7 @@ def asset_url(*args, **kwargs):
|
|||
asset = asset_object
|
||||
break
|
||||
|
||||
if isinstance(asset_object, str):
|
||||
if isinstance(asset_object, six.string_types):
|
||||
try:
|
||||
asset = Asset.objects.get(key=asset_object).asset
|
||||
break
|
||||
|
@ -51,49 +51,15 @@ def asset_url(*args, **kwargs):
|
|||
if not asset:
|
||||
return ''
|
||||
|
||||
if not os.path.exists(asset.path):
|
||||
return asset.url
|
||||
|
||||
geometry_string = kwargs.pop('size', None)
|
||||
if not geometry_string or asset.name.endswith('svg'):
|
||||
if not geometry_string or asset.file.name.endswith('svg'):
|
||||
return asset.url
|
||||
|
||||
return get_thumbnail(asset, geometry_string, **kwargs).url
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def asset_css_url(*args, **kwargs):
|
||||
url = asset_url(*args, **kwargs)
|
||||
if url:
|
||||
return 'url(%s)' % url
|
||||
else:
|
||||
return 'none'
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def get_asset(context, *args, **kwargs):
|
||||
if context.get('traverse_cells'):
|
||||
# assets are not required when we are just searching for page placeholders
|
||||
return None
|
||||
|
||||
key = None
|
||||
if 'cell' in kwargs and 'type' in kwargs:
|
||||
cell = kwargs['cell']
|
||||
cell_type = kwargs['type']
|
||||
try:
|
||||
if not cell.can_have_assets():
|
||||
return None
|
||||
key = cell.get_asset_slot_key(cell_type)
|
||||
except AttributeError:
|
||||
return None
|
||||
if hasattr(cell, '_assets'):
|
||||
return cell._assets.get(key)
|
||||
elif len(args) == 1:
|
||||
key = args[0]
|
||||
|
||||
if not key:
|
||||
return None
|
||||
|
||||
@register.assignment_tag
|
||||
def get_asset(key):
|
||||
try:
|
||||
return Asset.objects.get(key=key)
|
||||
except Asset.DoesNotExist:
|
||||
|
|
|
@ -14,27 +14,23 @@
|
|||
# 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.urls import include, path, re_path
|
||||
from django.conf.urls import url, include
|
||||
|
||||
from combo.urls_utils import decorated_includes, staff_required
|
||||
from combo.urls_utils import decorated_includes, manager_required
|
||||
|
||||
from . import api_views, views
|
||||
from . import views
|
||||
|
||||
assets_manager_urls = [
|
||||
path('', views.assets, name='combo-manager-assets'),
|
||||
re_path(r'^slots/(?P<cell_reference>[\w_-]+)/$', views.slot_assets, name='combo-manager-slot-assets'),
|
||||
path('delete', views.asset_delete, name='combo-manager-asset-delete'),
|
||||
path('overwrite/', views.asset_overwrite, name='combo-manager-asset-overwrite'),
|
||||
path('upload/', views.asset_upload, name='combo-manager-asset-upload'),
|
||||
re_path(r'^upload/(?P<key>[\w_:-]+)/$', views.slot_asset_upload, name='combo-manager-slot-asset-upload'),
|
||||
re_path(r'^delete/(?P<key>[\w_:-]+)/$', views.slot_asset_delete, name='combo-manager-slot-asset-delete'),
|
||||
path('export/', views.assets_export, name='combo-manager-assets-export'),
|
||||
path('import/', views.assets_import, name='combo-manager-assets-import'),
|
||||
url(r'^$', views.assets, name='combo-manager-assets'),
|
||||
url(r'^delete$', views.asset_delete, name='combo-manager-asset-delete'),
|
||||
url(r'^overwrite/$', views.asset_overwrite, name='combo-manager-asset-overwrite'),
|
||||
url(r'^upload/$', views.asset_upload, name='combo-manager-asset-upload'),
|
||||
url(r'^upload/(?P<key>[\w_:-]+)/$', views.slot_asset_upload, name='combo-manager-slot-asset-upload'),
|
||||
url(r'^delete/(?P<key>[\w_:-]+)/$', views.slot_asset_delete, name='combo-manager-slot-asset-delete'),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^assets/(?P<key>[\w_:-]+)$', views.serve_asset),
|
||||
re_path(r'^manage/assets/', decorated_includes(staff_required, include(assets_manager_urls))),
|
||||
re_path(r'^api/assets/set/(?P<key>[\w_:-]+)/$', api_views.view_set, name='api-assets-set'),
|
||||
path('ajax/assets-export-size/', views.assets_export_size, name='combo-manager-assets-export-size'),
|
||||
url(r'^assets/(?P<key>[\w_:-]+)$', views.serve_asset),
|
||||
url(r'^manage/assets/', decorated_includes(manager_required,
|
||||
include(assets_manager_urls))),
|
||||
]
|
||||
|
|
|
@ -1,96 +0,0 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2020 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/>.
|
||||
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
from io import BytesIO
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from .models import Asset
|
||||
|
||||
ASSET_DIRS = [
|
||||
'assets',
|
||||
'page-pictures',
|
||||
'uploads',
|
||||
]
|
||||
|
||||
|
||||
def is_asset_dir(basedir):
|
||||
# exclude dirs like cache or applications, which contain non asset files
|
||||
media_prefix = default_storage.path('')
|
||||
asset_basedirs = [os.path.join(media_prefix, ad) for ad in ASSET_DIRS]
|
||||
for adb in asset_basedirs:
|
||||
if basedir.startswith(adb):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clean_assets_files():
|
||||
media_prefix = default_storage.path('')
|
||||
for basedir, dummy, filenames in os.walk(media_prefix):
|
||||
if not is_asset_dir(basedir):
|
||||
continue
|
||||
for filename in filenames:
|
||||
os.remove('%s/%s' % (basedir, filename))
|
||||
|
||||
|
||||
def add_tar_content(tar, filename, content):
|
||||
file = tarfile.TarInfo(filename)
|
||||
fd = BytesIO()
|
||||
fd.write(content.encode('utf-8'))
|
||||
file.size = fd.tell()
|
||||
fd.seek(0)
|
||||
tar.addfile(file, fileobj=fd)
|
||||
fd.close()
|
||||
|
||||
|
||||
def untar_assets_files(tar, overwrite=False):
|
||||
media_prefix = default_storage.path('')
|
||||
data = {}
|
||||
for tarinfo in tar.getmembers():
|
||||
filepath = default_storage.path(tarinfo.name)
|
||||
if not overwrite and os.path.exists(filepath):
|
||||
continue
|
||||
if tarinfo.name == '_assets.json':
|
||||
json_assets = tar.extractfile(tarinfo).read()
|
||||
data = json.loads(json_assets.decode('utf-8'))
|
||||
elif tarinfo.name != '_site.json':
|
||||
tar.extract(tarinfo, path=media_prefix)
|
||||
return data
|
||||
|
||||
|
||||
def tar_assets_files(tar):
|
||||
media_prefix = default_storage.path('')
|
||||
for basedir, dummy, filenames in os.walk(media_prefix):
|
||||
if not is_asset_dir(basedir):
|
||||
continue
|
||||
for filename in filenames:
|
||||
tar.add(os.path.join(basedir, filename), os.path.join(basedir, filename)[len(media_prefix) :])
|
||||
export = {'assets': Asset.export_all_for_json()}
|
||||
add_tar_content(tar, '_assets.json', json.dumps(export, indent=2))
|
||||
|
||||
|
||||
def import_assets(fd, overwrite=False):
|
||||
with tarfile.open(mode='r', fileobj=fd) as tar:
|
||||
data = untar_assets_files(tar, overwrite=overwrite)
|
||||
Asset.load_serialized_objects(data.get('assets') or [])
|
||||
|
||||
|
||||
def export_assets(fd):
|
||||
with tarfile.open(mode='w', fileobj=fd) as tar:
|
||||
tar_assets_files(tar)
|
|
@ -15,30 +15,25 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import tarfile
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.urlresolvers import reverse, reverse_lazy
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect
|
||||
from django.views.generic import TemplateView, ListView, FormView
|
||||
|
||||
import ckeditor
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.core.files.storage import default_storage
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView, ListView, TemplateView
|
||||
from sorl.thumbnail.shortcuts import get_thumbnail
|
||||
|
||||
from combo.apps.assets.utils import export_assets, import_assets
|
||||
from combo.apps.maps.models import MapLayer
|
||||
from combo.data.models import CellBase
|
||||
|
||||
from .forms import AssetsImportForm, AssetUploadForm
|
||||
from .forms import AssetUploadForm
|
||||
from .models import Asset
|
||||
|
||||
|
||||
class CkEditorAsset:
|
||||
class CkEditorAsset(object):
|
||||
def __init__(self, filepath):
|
||||
self.filepath = filepath
|
||||
self.name = os.path.basename(filepath)
|
||||
|
@ -59,7 +54,8 @@ class CkEditorAsset:
|
|||
|
||||
def thumb(self):
|
||||
if getattr(settings, 'CKEDITOR_IMAGE_BACKEND', None):
|
||||
thumb = ckeditor.utils.get_media_url(ckeditor.utils.get_thumb_filename(self.filepath))
|
||||
thumb = ckeditor.utils.get_media_url(
|
||||
ckeditor.utils.get_thumb_filename(self.filepath))
|
||||
else:
|
||||
thumb = self.src
|
||||
return thumb
|
||||
|
@ -68,7 +64,7 @@ class CkEditorAsset:
|
|||
return ckeditor.views.is_image(self.src)
|
||||
|
||||
|
||||
class SlotAsset:
|
||||
class SlotAsset(object):
|
||||
def __init__(self, key=None, name=None, asset_type='image', asset=None):
|
||||
self.key = key
|
||||
self.name = name
|
||||
|
@ -80,10 +76,7 @@ class SlotAsset:
|
|||
|
||||
def size(self):
|
||||
if self.asset:
|
||||
try:
|
||||
return os.stat(self.asset.asset.path).st_size
|
||||
except OSError:
|
||||
pass
|
||||
return os.stat(self.asset.asset.path).st_size
|
||||
return None
|
||||
|
||||
def src(self):
|
||||
|
@ -97,32 +90,25 @@ class SlotAsset:
|
|||
|
||||
@classmethod
|
||||
def get_assets(cls):
|
||||
assets = {x.key: x for x in Asset.objects.all()}
|
||||
assets = dict([(x.key, x) for x in Asset.objects.all()])
|
||||
uniq_slots = {}
|
||||
uniq_slots.update(settings.COMBO_ASSET_SLOTS)
|
||||
cells = CellBase.get_cells(select_related={'__all__': ['page'], 'data_linkcell': ['link_page']})
|
||||
for cell in cells:
|
||||
for cell in CellBase.get_cells(
|
||||
cell_filter=lambda x: bool(x.get_asset_slots)):
|
||||
uniq_slots.update(cell.get_asset_slots())
|
||||
for map_layer in MapLayer.objects.filter(kind='geojson'):
|
||||
uniq_slots.update(map_layer.get_asset_slots())
|
||||
for key, value in uniq_slots.items():
|
||||
yield cls(
|
||||
key,
|
||||
name=value.get('label') or '',
|
||||
asset_type=value.get('asset-type', 'image'),
|
||||
asset=assets.get(key),
|
||||
)
|
||||
yield cls(key,
|
||||
name=value.get('label'),
|
||||
asset_type=value.get('asset-type', 'image'),
|
||||
asset=assets.get(key))
|
||||
|
||||
|
||||
class Assets(ListView):
|
||||
template_name = 'combo/manager_assets.html'
|
||||
paginate_by = 10
|
||||
|
||||
def get_files(self):
|
||||
return list(SlotAsset.get_assets()) + CkEditorAsset.get_assets(self.request)
|
||||
|
||||
def get_queryset(self):
|
||||
files = self.get_files()
|
||||
files = list(SlotAsset.get_assets()) + CkEditorAsset.get_assets(self.request)
|
||||
q = self.request.GET.get('q')
|
||||
if q:
|
||||
files = [x for x in files if q.lower() in x.name.lower()]
|
||||
|
@ -130,7 +116,7 @@ class Assets(ListView):
|
|||
return files
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context = super(Assets, self).get_context_data(**kwargs)
|
||||
context['query'] = self.request.GET.get('q') or ''
|
||||
return context
|
||||
|
||||
|
@ -144,21 +130,9 @@ class Assets(ListView):
|
|||
return url + '?page=%s' % ((i // self.paginate_by) + 1)
|
||||
return url
|
||||
|
||||
|
||||
assets = Assets.as_view()
|
||||
|
||||
|
||||
class AssetsBrowse(Assets):
|
||||
template_name = 'combo/manager_assets_browse.html'
|
||||
paginate_by = 7
|
||||
|
||||
def get_files(self):
|
||||
return CkEditorAsset.get_assets(self.request)
|
||||
|
||||
|
||||
browse = AssetsBrowse.as_view()
|
||||
|
||||
|
||||
class AssetUpload(FormView):
|
||||
form_class = AssetUploadForm
|
||||
template_name = 'combo/manager_asset_upload.html'
|
||||
|
@ -167,14 +141,13 @@ class AssetUpload(FormView):
|
|||
# use native ckeditor view so it's available from ckeditor file/image
|
||||
# dialogs.
|
||||
ckeditor_upload_view = ckeditor.views.ImageUploadView()
|
||||
self.request.GET = {'CKEditorFuncNum': '-'} # hack
|
||||
self.request.GET = {'CKEditorFuncNum': '-'} # hack
|
||||
ckeditor_upload_view.post(self.request)
|
||||
return super().form_valid(form)
|
||||
return super(AssetUpload, self).form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return Assets(request=self.request).get_anchored_url(name=self.request.FILES['upload'].name)
|
||||
|
||||
|
||||
asset_upload = AssetUpload.as_view()
|
||||
|
||||
|
||||
|
@ -186,39 +159,21 @@ class AssetOverwrite(FormView):
|
|||
def form_valid(self, form):
|
||||
img_orig = self.request.GET['img']
|
||||
if '..' in img_orig:
|
||||
raise PermissionDenied() # better safe than sorry
|
||||
raise PermissionDenied() # better safe than sorry
|
||||
base_path = settings.CKEDITOR_UPLOAD_PATH
|
||||
if getattr(settings, 'CKEDITOR_RESTRICT_BY_USER', False):
|
||||
base_path = os.path.join(base_path, self.request.user.username)
|
||||
if not img_orig.startswith(base_path):
|
||||
raise PermissionDenied()
|
||||
try:
|
||||
os.stat(default_storage.path(img_orig))
|
||||
except ValueError:
|
||||
raise PermissionDenied()
|
||||
if '\x00' in img_orig:
|
||||
# os.stat should have raised "embedded null byte" but double check
|
||||
raise PermissionDenied()
|
||||
|
||||
upload = self.request.FILES['upload']
|
||||
|
||||
# check that the new file and the original have the same extension
|
||||
ext_orig = os.path.splitext(img_orig)[1].lower()
|
||||
ext_upload = os.path.splitext(upload.name)[1].lower()
|
||||
if ext_orig != ext_upload:
|
||||
messages.error(
|
||||
self.request,
|
||||
_('You have to upload a file with the same extension (%(ext)s).') % {'ext': ext_orig},
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
default_storage.delete(img_orig)
|
||||
if getattr(settings, 'CKEDITOR_IMAGE_BACKEND', None):
|
||||
thumb = ckeditor.utils.get_thumb_filename(img_orig)
|
||||
default_storage.delete(thumb)
|
||||
saved_path = default_storage.save(img_orig, upload)
|
||||
backend = ckeditor.image_processing.get_backend()
|
||||
upload.seek(0) # rewind file to be sure
|
||||
upload.seek(0) # rewind file to be sure
|
||||
try:
|
||||
backend.image_verify(upload)
|
||||
except ckeditor.utils.NotAnImageException:
|
||||
|
@ -226,13 +181,12 @@ class AssetOverwrite(FormView):
|
|||
else:
|
||||
if backend.should_create_thumbnail(saved_path):
|
||||
backend.create_thumbnail(saved_path)
|
||||
return super().form_valid(form)
|
||||
return super(AssetOverwrite, self).form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
img_orig = self.request.GET['img']
|
||||
return Assets(request=self.request).get_anchored_url(name=os.path.basename(img_orig))
|
||||
|
||||
|
||||
asset_overwrite = AssetOverwrite.as_view()
|
||||
|
||||
|
||||
|
@ -242,59 +196,20 @@ class AssetDelete(TemplateView):
|
|||
def post(self, request):
|
||||
img_orig = request.GET['img']
|
||||
if '..' in img_orig:
|
||||
raise PermissionDenied() # better safe than sorry
|
||||
raise PermissionDenied() # better safe than sorry
|
||||
base_path = settings.CKEDITOR_UPLOAD_PATH
|
||||
if getattr(settings, 'CKEDITOR_RESTRICT_BY_USER', False):
|
||||
base_path = os.path.join(base_path, request.user.username)
|
||||
if not img_orig.startswith(base_path):
|
||||
raise PermissionDenied()
|
||||
try:
|
||||
os.stat(default_storage.path(img_orig))
|
||||
except ValueError:
|
||||
raise PermissionDenied()
|
||||
if '\x00' in img_orig:
|
||||
# os.stat should have raised "embedded null byte" but double check
|
||||
raise PermissionDenied()
|
||||
|
||||
default_storage.delete(img_orig)
|
||||
return redirect(Assets(request=self.request).get_anchored_url(name=os.path.basename(img_orig)))
|
||||
|
||||
return redirect(
|
||||
Assets(request=self.request).get_anchored_url(
|
||||
name=os.path.basename(img_orig)))
|
||||
|
||||
asset_delete = AssetDelete.as_view()
|
||||
|
||||
|
||||
class SlotAssets(ListView):
|
||||
template_name = 'combo/manager_slot_assets.html'
|
||||
|
||||
def get_assets(self, cell):
|
||||
asset_slots = cell.get_asset_slots()
|
||||
assets = {x.key: x for x in Asset.objects.filter(key__in=asset_slots.keys())}
|
||||
for key, value in asset_slots.items():
|
||||
yield SlotAsset(
|
||||
key,
|
||||
name=value.get('short_label'),
|
||||
asset_type=value.get('asset-type', 'image'),
|
||||
asset=assets.get(key),
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
cell_reference = self.kwargs['cell_reference']
|
||||
try:
|
||||
cell = CellBase.get_cell(cell_reference)
|
||||
except ObjectDoesNotExist:
|
||||
raise Http404()
|
||||
|
||||
return self.get_assets(cell)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['cell_reference'] = self.kwargs['cell_reference']
|
||||
return context
|
||||
|
||||
|
||||
slot_assets = SlotAssets.as_view()
|
||||
|
||||
|
||||
class SlotAssetUpload(FormView):
|
||||
form_class = AssetUploadForm
|
||||
template_name = 'combo/manager_asset_upload.html'
|
||||
|
@ -307,25 +222,11 @@ class SlotAssetUpload(FormView):
|
|||
self.asset = Asset(key=self.kwargs['key'])
|
||||
self.asset.asset = self.request.FILES['upload']
|
||||
self.asset.save()
|
||||
return super().form_valid(form)
|
||||
return super(SlotAssetUpload, self).form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
if self.request.GET.get('cell_reference'):
|
||||
cell_reference = self.request.GET['cell_reference']
|
||||
try:
|
||||
cell = CellBase.get_cell(cell_reference)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
else:
|
||||
return (
|
||||
reverse('combo-manager-page-view', kwargs={'pk': cell.page_id})
|
||||
+ '#cell-'
|
||||
+ cell_reference
|
||||
)
|
||||
|
||||
return Assets(request=self.request).get_anchored_url(key=self.kwargs['key'])
|
||||
|
||||
|
||||
slot_asset_upload = SlotAssetUpload.as_view()
|
||||
|
||||
|
||||
|
@ -334,93 +235,14 @@ class SlotAssetDelete(TemplateView):
|
|||
|
||||
def post(self, request, *args, **kwargs):
|
||||
Asset.objects.filter(key=kwargs['key']).delete()
|
||||
if self.request.GET.get('cell_reference'):
|
||||
cell_reference = self.request.GET['cell_reference']
|
||||
try:
|
||||
cell = CellBase.get_cell(cell_reference)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
else:
|
||||
return redirect(
|
||||
reverse('combo-manager-page-view', kwargs={'pk': cell.page_id})
|
||||
+ '#cell-'
|
||||
+ cell_reference
|
||||
)
|
||||
return redirect(Assets(request=self.request).get_anchored_url(key=kwargs['key']))
|
||||
|
||||
|
||||
slot_asset_delete = SlotAssetDelete.as_view()
|
||||
|
||||
|
||||
class AssetsImport(FormView):
|
||||
form_class = AssetsImportForm
|
||||
template_name = 'combo/manager_assets_import.html'
|
||||
success_url = reverse_lazy('combo-manager-assets')
|
||||
|
||||
def form_valid(self, form):
|
||||
overwrite = form.cleaned_data.get('overwrite')
|
||||
try:
|
||||
import_assets(form.cleaned_data['assets_file'], overwrite)
|
||||
except tarfile.TarError:
|
||||
messages.error(self.request, _('The assets file is not valid.'))
|
||||
return super().form_valid(form)
|
||||
messages.success(self.request, _('The assets file has been imported.'))
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
assets_import = AssetsImport.as_view()
|
||||
|
||||
|
||||
def assets_export(request, *args, **kwargs):
|
||||
fd = BytesIO()
|
||||
export_assets(fd)
|
||||
return HttpResponse(fd.getvalue(), content_type='application/x-tar')
|
||||
|
||||
|
||||
def serve_asset(request, key):
|
||||
asset = get_object_or_404(Asset, key=key)
|
||||
|
||||
if not os.path.exists(asset.asset.path):
|
||||
try:
|
||||
asset = Asset.objects.get(key=key)
|
||||
return redirect(asset.asset.url)
|
||||
except (Asset.DoesNotExist, AttributeError):
|
||||
raise Http404()
|
||||
|
||||
# get options for thumbnail
|
||||
thumb_options = request.GET.dict()
|
||||
width = thumb_options.pop('width', None)
|
||||
height = thumb_options.pop('height', None)
|
||||
|
||||
geometry_string = ''
|
||||
if width:
|
||||
geometry_string += width
|
||||
if height:
|
||||
geometry_string += 'x%s' % height
|
||||
|
||||
# no thumbnail whithout geometry_string or for a svg file
|
||||
if not geometry_string or asset.asset.name.endswith('svg'):
|
||||
url = asset.asset.url
|
||||
else:
|
||||
# get or create thumbnail
|
||||
url = get_thumbnail(asset.asset, geometry_string, **thumb_options).url
|
||||
|
||||
if settings.COMBO_X_ACCEL_ASSETS and url.startswith(settings.MEDIA_URL):
|
||||
response = HttpResponse(content_type='') # let nginx set it
|
||||
response['X-Accel-Redirect'] = url
|
||||
return response
|
||||
|
||||
return redirect(url)
|
||||
|
||||
|
||||
class AssetsExportSize(TemplateView):
|
||||
template_name = 'combo/manager_assets_export_size.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
media_prefix = default_storage.path('')
|
||||
computed_size = 0
|
||||
for basedir, dummy, filenames in os.walk(media_prefix):
|
||||
for filename in filenames:
|
||||
computed_size += os.stat(os.path.join(basedir, filename)).st_size
|
||||
context['size'] = computed_size
|
||||
return context
|
||||
|
||||
|
||||
assets_export_size = AssetsExportSize.as_view()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2016 Entr'ouvert
|
||||
# Copyright (C) 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
|
||||
|
@ -15,14 +15,16 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import django.apps
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class AppConfig(django.apps.AppConfig):
|
||||
name = 'combo.apps.notifications'
|
||||
verbose_name = _('Notification')
|
||||
name = 'combo.apps.calendar'
|
||||
verbose_name = _('Calendar')
|
||||
|
||||
def get_before_urls(self):
|
||||
from . import urls
|
||||
|
||||
return urls.urlpatterns
|
||||
|
||||
|
||||
default_app_config = 'combo.apps.calendar.AppConfig'
|
|
@ -0,0 +1,79 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 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 import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.dateparse import parse_datetime, parse_time
|
||||
|
||||
from .models import BookingCalendar
|
||||
from .utils import get_agendas
|
||||
from combo.apps.wcs.utils import get_wcs_options
|
||||
|
||||
|
||||
class BookingCalendarForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = BookingCalendar
|
||||
fields = (
|
||||
'title', 'agenda_reference', 'formdef_reference',
|
||||
'slot_duration', 'minimal_booking_duration', 'days_displayed')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(BookingCalendarForm, self).__init__(*args, **kwargs)
|
||||
agenda_references = get_agendas()
|
||||
formdef_references = get_wcs_options('/api/formdefs/')
|
||||
self.fields['agenda_reference'].widget = forms.Select(choices=agenda_references)
|
||||
self.fields['formdef_reference'].widget = forms.Select(choices=formdef_references)
|
||||
|
||||
|
||||
class BookingForm(forms.Form):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.cell = kwargs.pop('cell')
|
||||
super(BookingForm, self).__init__(*args, **kwargs)
|
||||
self.cleaned_data = {}
|
||||
|
||||
def is_valid(self):
|
||||
slots = getattr(self.data, 'getlist', lambda x: [])('slots')
|
||||
# check that at least one slot if selected
|
||||
if not slots:
|
||||
raise ValueError(_('Please select slots'))
|
||||
offset = self.cell.slot_duration
|
||||
start_dt = parse_datetime(slots[0])
|
||||
end_dt = parse_datetime(slots[-1]) + offset
|
||||
slots.append(end_dt.isoformat())
|
||||
|
||||
# check that all slots are part of the same day
|
||||
for slot in slots:
|
||||
if parse_datetime(slot).date() != start_dt.date():
|
||||
raise ValueError(_('Please select slots of the same day'))
|
||||
|
||||
# check that slots datetime are contiguous
|
||||
start = start_dt
|
||||
while start <= end_dt:
|
||||
if start.isoformat() not in slots:
|
||||
raise ValueError(_('Please select contiguous slots'))
|
||||
start = start + offset
|
||||
|
||||
# check that event booking duration >= minimal booking duration
|
||||
min_duration = self.cell.minimal_booking_duration
|
||||
if not (end_dt - start_dt) >= min_duration:
|
||||
str_min_duration = parse_time(str(min_duration)).strftime('%H:%M')
|
||||
message = _("Minimal booking duration is %s") % str_min_duration
|
||||
raise ValueError(message)
|
||||
self.cleaned_data['start'] = start_dt
|
||||
self.cleaned_data['end'] = end_dt
|
||||
return True
|
|
@ -0,0 +1,39 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import datetime
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('data', '0027_page_picture'),
|
||||
('auth', '0006_require_contenttypes_0002'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BookingCalendar',
|
||||
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)),
|
||||
('title', models.CharField(max_length=128, null=True, verbose_name='Title', blank=True)),
|
||||
('agenda_reference', models.CharField(max_length=128, verbose_name='Agenda')),
|
||||
('formdef_reference', models.CharField(max_length=128, verbose_name='Form')),
|
||||
('slot_duration', models.DurationField(default=datetime.timedelta(0, 1800), help_text='Format is hours:minutes:seconds', verbose_name='Slot duration')),
|
||||
('minimal_booking_duration', models.DurationField(default=datetime.timedelta(0, 3600), help_text='Format is hours:minutes:seconds', verbose_name='Minimal booking duration')),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Booking Calendar',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('calendar', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookingcalendar',
|
||||
name='days_displayed',
|
||||
field=models.PositiveSmallIntegerField(default=7, verbose_name='Number of days to display'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,68 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 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/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
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
|
||||
|
||||
from .utils import (is_chrono_enabled, is_wcs_enabled,
|
||||
get_chrono_events, get_calendar_context_vars)
|
||||
|
||||
|
||||
@register_cell_class
|
||||
class BookingCalendar(CellBase):
|
||||
|
||||
title = models.CharField(_('Title'), max_length=128, blank=True, null=True)
|
||||
agenda_reference = models.CharField(_('Agenda'), max_length=128)
|
||||
formdef_reference = models.CharField(_('Form'), max_length=128)
|
||||
slot_duration = models.DurationField(
|
||||
_('Slot duration'), default=datetime.timedelta(minutes=30),
|
||||
help_text=_('Format is hours:minutes:seconds'))
|
||||
minimal_booking_duration = models.DurationField(
|
||||
_('Minimal booking duration'), default=datetime.timedelta(hours=1),
|
||||
help_text=_('Format is hours:minutes:seconds'))
|
||||
days_displayed = models.PositiveSmallIntegerField(_('Number of days to display'), default=7)
|
||||
|
||||
template_name = 'calendar/booking_calendar_cell.html'
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Booking Calendar')
|
||||
|
||||
def get_default_form_class(self):
|
||||
from .forms import BookingCalendarForm
|
||||
return BookingCalendarForm
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
return settings.BOOKING_CALENDAR_CELL_ENABLED and is_chrono_enabled() and is_wcs_enabled()
|
||||
|
||||
def is_visible(self, user=None):
|
||||
return self.agenda_reference and self.formdef_reference \
|
||||
and super(BookingCalendar, self).is_visible(user=user)
|
||||
|
||||
def get_cell_extra_context(self, context):
|
||||
if context.get('placeholder_search_mode'):
|
||||
return {}
|
||||
extra_context = super(BookingCalendar, self).get_cell_extra_context(context)
|
||||
events_data = get_chrono_events(self.agenda_reference, not(context.get('synchronous')))
|
||||
extra_context.update(get_calendar_context_vars(
|
||||
context['request'], extra_context['cell'], events_data))
|
||||
return extra_context
|
|
@ -0,0 +1,27 @@
|
|||
{% load i18n calendar %}
|
||||
{% block cell-content %}
|
||||
|
||||
{% if cell.title %}
|
||||
<h2>
|
||||
<span>{{cell.title}}</span>
|
||||
{% if calendar %}
|
||||
<span class="calinfo">
|
||||
{% with calendar.get_first_available_slot as slot %}
|
||||
({% if slot %}{% trans "Next available slot:" %} {{ slot.date_time|date:"DATETIME_FORMAT"}}{% else %}{% trans "No available slots." %}{% endif %})
|
||||
{% endwith %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div><p>{{ error }}</p></div>
|
||||
{% else %}
|
||||
<div class="calcontent">
|
||||
{% include 'calendar/booking_calendar_content.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<style>.calinfo { font-style: italic; font-size: 80%; }</style>
|
||||
{% endblock %}
|
|
@ -0,0 +1,57 @@
|
|||
{% load i18n calendar %}
|
||||
|
||||
{% if calendar_days.has_other_pages %}
|
||||
<p class="paginator">
|
||||
|
||||
{% if calendar_days.has_previous %}
|
||||
<a class="previous calchunk" href="?chunk_{{ cell.pk }}={{ calendar_days.previous_page_number }}" data-content-url="{% url 'ajax-calendar-content' pk=cell.pk %}?chunk_{{cell.pk}}={{ calendar_days.previous_page_number }}">{% trans "previous" %}</a>
|
||||
{% else %}
|
||||
<span class="previous">{% trans "previous" %}</span>
|
||||
{% endif %}
|
||||
<span class="current">
|
||||
{{ calendar_days.number }} / {{ calendar_days.paginator.num_pages }}
|
||||
</span>
|
||||
{% if calendar_days.has_next %}
|
||||
<a class="next calchunk" href="?chunk_{{ cell.pk }}={{ calendar_days.next_page_number }}" data-content-url="{% url 'ajax-calendar-content' pk=cell.pk %}?chunk_{{cell.pk}}={{ calendar_days.next_page_number }}">{% trans "next" %}</a>
|
||||
{% else %}
|
||||
<span class="next">{% trans "next" %}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if calendar_days %}
|
||||
<form method="POST" action="{% url 'calendar-booking' pk=cell.pk %}?chunk_{{cell.pk}}={{calendar_days.number}}">
|
||||
{% csrf_token %}
|
||||
<table id="cal-table-{{cell.pk}}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{% for day in calendar_days %}
|
||||
<th>{{day|date:"SHORT_DATE_FORMAT"}}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for slot in calendar_slots %}
|
||||
<tr>
|
||||
<th>{{slot|date:"TIME_FORMAT"}}</th>
|
||||
{% for day in calendar_days %}
|
||||
{% get_day_slot calendar day=day slot=slot as value %}
|
||||
{% if not value.exist %}
|
||||
<td class="absent"></td>
|
||||
{% elif value.available %}
|
||||
<td class="available">
|
||||
<input type="checkbox" name="slots" value="{{value.label}}" id="slot-{{cell.pk}}-{{value.label}}"/>
|
||||
<label for="slot-{{cell.pk}}-{{value.label}}"></label>
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="unavailable"></td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="submit-button">{% trans "Book" context "booking" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
|
@ -0,0 +1,29 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 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/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.assignment_tag
|
||||
def get_day_slot(cal, *args, **kwargs):
|
||||
day = kwargs.get('day')
|
||||
slot = kwargs.get('slot')
|
||||
time_slot = datetime.datetime.combine(day, slot)
|
||||
return cal.get_availability(time_slot)
|
|
@ -1,5 +1,5 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2020 Entr'ouvert
|
||||
# Copyright (C) 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
|
||||
|
@ -14,10 +14,11 @@
|
|||
# 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/>.
|
||||
|
||||
import django.apps
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.conf.urls import url
|
||||
|
||||
from .views import BookingView, CalendarContentAjaxView
|
||||
|
||||
class AppConfig(django.apps.AppConfig):
|
||||
name = 'combo.apps.kb'
|
||||
verbose_name = _('Knowledge Base')
|
||||
urlpatterns = [
|
||||
url(r'^calendar/book/(?P<pk>[\w,-]+)/$', BookingView.as_view(), name='calendar-booking'),
|
||||
url(r'^ajax/calendar/content/(?P<pk>\w+)/$', CalendarContentAjaxView.as_view(), name='ajax-calendar-content'),
|
||||
]
|
|
@ -0,0 +1,240 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 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/>.
|
||||
|
||||
import datetime
|
||||
import math
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.timezone import localtime, make_aware
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from combo.utils import requests
|
||||
|
||||
|
||||
def get_services(service_name):
|
||||
if hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get(service_name):
|
||||
return settings.KNOWN_SERVICES[service_name]
|
||||
return {}
|
||||
|
||||
|
||||
def get_wcs_services():
|
||||
return get_services('wcs')
|
||||
|
||||
|
||||
def get_chrono_service():
|
||||
for chrono_key, chrono_site in get_services('chrono').items():
|
||||
if not chrono_site.get('secondary', True):
|
||||
chrono_site['slug'] = chrono_key
|
||||
return chrono_site
|
||||
return {}
|
||||
|
||||
|
||||
def is_chrono_enabled():
|
||||
return bool(get_chrono_service())
|
||||
|
||||
|
||||
def is_wcs_enabled():
|
||||
return bool(get_wcs_services())
|
||||
|
||||
|
||||
def get_agendas():
|
||||
chrono = get_chrono_service()
|
||||
references = []
|
||||
response = requests.get('api/agenda/', remote_service=chrono, without_user=True)
|
||||
try:
|
||||
result = response.json()
|
||||
except ValueError:
|
||||
return references
|
||||
for agenda in result.get('data'):
|
||||
references.append((
|
||||
'%s:%s' % (chrono['slug'], agenda['id']), agenda['text']))
|
||||
return references
|
||||
|
||||
|
||||
def get_chrono_events(agenda_reference, synchronous):
|
||||
chrono_key, chrono_slug = agenda_reference.split(':')
|
||||
chrono = get_chrono_service()
|
||||
response = requests.get('api/agenda/%s/datetimes/' % chrono_slug, remote_service=chrono,
|
||||
without_user=True, raise_if_not_cached=synchronous)
|
||||
try:
|
||||
if response.status_code != 200:
|
||||
raise ValueError
|
||||
result = response.json()
|
||||
except ValueError:
|
||||
return {'error': _('An error occurred while retrieving calendar\'s availabilities.')}
|
||||
return result
|
||||
|
||||
|
||||
def get_calendar_context_vars(request, cell, events_data):
|
||||
page = request.GET.get('chunk_%s' % cell.pk, 1)
|
||||
if 'error' in events_data:
|
||||
return events_data
|
||||
events = events_data['data']
|
||||
calendar = get_calendar(events, cell.slot_duration, cell.days_displayed,
|
||||
cell.minimal_booking_duration)
|
||||
paginator = Paginator(calendar.get_computed_days(), cell.days_displayed)
|
||||
try:
|
||||
cal_page = paginator.page(page)
|
||||
except PageNotAnInteger:
|
||||
cal_page = paginator.page(1)
|
||||
except (EmptyPage,):
|
||||
cal_page = paginator.page(paginator.num_pages)
|
||||
return {
|
||||
'calendar': calendar,
|
||||
'calendar_days': cal_page,
|
||||
'calendar_slots': calendar.get_slots()
|
||||
}
|
||||
|
||||
|
||||
def get_calendar(events, offset, days_displayed, min_duration):
|
||||
calendar = Calendar(offset, days_displayed, min_duration)
|
||||
|
||||
for event in events:
|
||||
event_datetime = parse_datetime(event['datetime'])
|
||||
if not calendar.has_day(event_datetime.date()):
|
||||
day = WeekDay(event_datetime.date())
|
||||
calendar.days.append(day)
|
||||
else:
|
||||
day = calendar.get_day(event_datetime.date())
|
||||
# add slots to day
|
||||
day.add_slots(DaySlot(
|
||||
event_datetime, True if not event.get('disabled', True) else False))
|
||||
|
||||
return calendar
|
||||
|
||||
|
||||
def get_form_url_with_params(cell, data):
|
||||
session_vars = {
|
||||
"session_var_booking_agenda_slug": cell.agenda_reference.split(':')[1],
|
||||
"session_var_booking_start": data['start'].isoformat(),
|
||||
"session_var_booking_end": data['end'].isoformat()
|
||||
}
|
||||
wcs_key, wcs_slug = cell.formdef_reference.split(':')
|
||||
wcs = get_wcs_services().get(wcs_key)
|
||||
url = '%s%s/?%s' % (wcs['url'], wcs_slug, urlencode(session_vars))
|
||||
return url
|
||||
|
||||
|
||||
class DaySlot(object):
|
||||
|
||||
def __init__(self, date_time, available, exist=True):
|
||||
self.date_time = localtime(make_aware(date_time))
|
||||
self.available = available
|
||||
self.exist = exist
|
||||
|
||||
def __repr__(self):
|
||||
return '<DaySlot date_time=%s - available=%s>' % (self.date_time.isoformat(), self.available)
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return '%s' % self.date_time.isoformat()
|
||||
|
||||
|
||||
class WeekDay(object):
|
||||
|
||||
def __init__(self, date):
|
||||
self.date = date
|
||||
self.slots = []
|
||||
|
||||
def __repr__(self):
|
||||
return '<WeekDay %s >' % self.date.isoformat()
|
||||
|
||||
def add_slots(self, slot):
|
||||
if slot not in self.slots:
|
||||
self.slots.append(slot)
|
||||
|
||||
def get_slot(self, slot_time):
|
||||
for slot in self.slots:
|
||||
if slot.date_time.time() == slot_time:
|
||||
return slot
|
||||
slot_datetime = datetime.datetime.combine(self.date, slot_time)
|
||||
return DaySlot(slot_datetime, False, exist=False)
|
||||
|
||||
def get_minimum_slot(self):
|
||||
return min(self.slots, key=lambda x: x.date_time.time())
|
||||
|
||||
def get_maximum_slot(self):
|
||||
return max(self.slots, key=lambda x: x.date_time.time())
|
||||
|
||||
|
||||
class Calendar(object):
|
||||
|
||||
def __init__(self, offset, days_displayed, min_duration):
|
||||
self.offset = offset
|
||||
self.days_displayed = days_displayed
|
||||
self.days = []
|
||||
self.min_duration = min_duration
|
||||
|
||||
def __repr__(self):
|
||||
return '<Calendar>'
|
||||
|
||||
def get_first_available_slot(self):
|
||||
"""return the first available slot that has enough
|
||||
consecutive available slots to be allowed for booking
|
||||
"""
|
||||
required_contiguous_slots = self.min_duration.seconds // self.offset.seconds
|
||||
for day in self.days:
|
||||
slots = day.slots
|
||||
for idx in range(len(slots) - required_contiguous_slots):
|
||||
if all([x.available for x in slots[idx:idx+required_contiguous_slots]]):
|
||||
return slots[idx]
|
||||
return None
|
||||
|
||||
def get_slots(self):
|
||||
start = self.get_minimum_slot()
|
||||
end = self.get_maximum_slot()
|
||||
while start <= end:
|
||||
yield start
|
||||
start = datetime.datetime.combine(
|
||||
datetime.date.today(), start) + self.offset
|
||||
start = start.time()
|
||||
|
||||
def get_computed_days(self):
|
||||
if not self.days:
|
||||
return []
|
||||
computed_days = []
|
||||
base_day = self.days[0].date
|
||||
days_diff = (self.days[-1].date - self.days[0].date).days
|
||||
# find a number which ensures calendar days are equally chunked
|
||||
days_range = int(self.days_displayed * math.ceil(float(days_diff + 1) / self.days_displayed))
|
||||
for index in range(days_range):
|
||||
day = base_day + datetime.timedelta(days=index)
|
||||
computed_days.append(day)
|
||||
return computed_days
|
||||
|
||||
def get_day(self, date):
|
||||
for day in self.days:
|
||||
if day.date == date:
|
||||
return day
|
||||
return None
|
||||
|
||||
def has_day(self, date):
|
||||
return bool(self.get_day(date))
|
||||
|
||||
def get_availability(self, slot):
|
||||
if not self.has_day(slot.date()):
|
||||
return DaySlot(slot, False, exist=False)
|
||||
day = self.get_day(slot.date())
|
||||
return day.get_slot(slot.time())
|
||||
|
||||
def get_minimum_slot(self):
|
||||
return min([day.get_minimum_slot().date_time.time() for day in self.days])
|
||||
|
||||
def get_maximum_slot(self):
|
||||
return max([day.get_maximum_slot().date_time.time() for day in self.days])
|
|
@ -0,0 +1,58 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 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.contrib import messages
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils.encoding import force_text
|
||||
from django.views.generic import View, DetailView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
|
||||
from .forms import BookingForm
|
||||
from .models import BookingCalendar
|
||||
from .utils import (get_form_url_with_params, get_chrono_events,
|
||||
get_calendar_context_vars)
|
||||
|
||||
|
||||
class BookingView(SingleObjectMixin, View):
|
||||
|
||||
http_method_names = ['post']
|
||||
model = BookingCalendar
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
cell = self.get_object()
|
||||
form = BookingForm(request.POST, cell=cell)
|
||||
try:
|
||||
form.is_valid()
|
||||
except ValueError as exc:
|
||||
messages.error(request, force_text(exc))
|
||||
redirect_url = '%s?%s' % (
|
||||
cell.page.get_online_url(), request.GET.urlencode())
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
data = form.cleaned_data
|
||||
url = get_form_url_with_params(cell, data)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class CalendarContentAjaxView(DetailView):
|
||||
model = BookingCalendar
|
||||
template_name = 'calendar/booking_calendar_content.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(CalendarContentAjaxView, self).get_context_data(**kwargs)
|
||||
context['cell'] = self.object
|
||||
events_data = get_chrono_events(self.object.agenda_reference, context.get('synchronous'))
|
||||
context.update(get_calendar_context_vars(self.request, self.object, events_data))
|
||||
return context
|
|
@ -0,0 +1,35 @@
|
|||
# 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.timezone import now, timedelta
|
||||
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
|
||||
|
||||
def hourly(self):
|
||||
self.clean_autotiles()
|
||||
|
||||
def clean_autotiles(self):
|
||||
from combo.data.models import ConfigJsonCell
|
||||
ConfigJsonCell.objects.filter(placeholder='_auto_tile',
|
||||
last_update_timestamp__lte=now() - timedelta(days=2)).delete()
|
||||
|
||||
|
||||
default_app_config = 'combo.apps.dashboard.AppConfig'
|
|
@ -1,36 +0,0 @@
|
|||
# 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.timezone import now, timedelta
|
||||
from django.utils.translation import gettext_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
|
||||
|
||||
def hourly(self):
|
||||
self.clean_autotiles()
|
||||
|
||||
def clean_autotiles(self):
|
||||
from combo.data.models import ConfigJsonCell
|
||||
|
||||
ConfigJsonCell.objects.filter(
|
||||
placeholder='_auto_tile', last_update_timestamp__lte=now() - timedelta(days=2)
|
||||
).delete()
|
|
@ -1,8 +1,12 @@
|
|||
from django.conf import settings
|
||||
# -*- 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),
|
||||
|
@ -14,27 +18,16 @@ class Migration(migrations.Migration):
|
|||
migrations.CreateModel(
|
||||
name='DashboardCell',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True),
|
||||
),
|
||||
('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
|
||||
),
|
||||
),
|
||||
('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'),
|
||||
),
|
||||
('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='Roles', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page', on_delete=models.CASCADE)),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Dashboard',
|
||||
|
@ -43,15 +36,12 @@ class Migration(migrations.Migration):
|
|||
migrations.CreateModel(
|
||||
name='Tile',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True),
|
||||
),
|
||||
('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', on_delete=models.CASCADE)),
|
||||
('dashboard', models.ForeignKey(to='dashboard.DashboardCell', on_delete=models.CASCADE)),
|
||||
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
|
||||
('cell_type', models.ForeignKey(to='contenttypes.ContentType')),
|
||||
('dashboard', models.ForeignKey(to='dashboard.DashboardCell')),
|
||||
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('order',),
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dashboard', '0001_initial'),
|
||||
]
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
# Generated by Django 2.2.21 on 2021-07-23 11:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dashboard', '0002_auto_20180105_0846'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dashboardcell',
|
||||
name='template_name',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Cell Template'),
|
||||
),
|
||||
]
|
|
@ -1,15 +0,0 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dashboard', '0003_dashboardcell_template_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='dashboardcell',
|
||||
name='condition',
|
||||
field=models.CharField(blank=True, max_length=1000, null=True, verbose_name='Display condition'),
|
||||
),
|
||||
]
|
|
@ -1,17 +0,0 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dashboard', '0004_display_condition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='dashboardcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -14,17 +14,14 @@
|
|||
# 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/>.
|
||||
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes import fields
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes import fields
|
||||
from django.db import models
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from combo.data.models import CellBase
|
||||
from combo.data.library import register_cell_class
|
||||
from combo.data.models import CellBase, ValidityInfo
|
||||
|
||||
|
||||
@register_cell_class
|
||||
|
@ -43,26 +40,21 @@ class DashboardCell(CellBase):
|
|||
return settings.COMBO_DASHBOARD_ENABLED
|
||||
|
||||
def is_relevant(self, context):
|
||||
if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated):
|
||||
if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated()):
|
||||
return False
|
||||
return True
|
||||
|
||||
def render(self, context):
|
||||
tiles = Tile.objects.filter(dashboard=self, user=context['user'])
|
||||
validity_info_dict = {
|
||||
(x.content_type_id, x.object_id): True
|
||||
for x in ValidityInfo.objects.filter(invalid_since__lt=now() - datetime.timedelta(days=2))
|
||||
}
|
||||
context['tiles'] = [x for x in tiles if (x.cell_type_id, x.cell_pk) not in validity_info_dict]
|
||||
return super().render(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, on_delete=models.CASCADE)
|
||||
cell_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
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, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL)
|
||||
order = models.IntegerField()
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
{% load i18n dashboard %}
|
||||
{% block content %}
|
||||
{% if user.is_authenticated %}
|
||||
<span class="dashboard-cell-icons">
|
||||
{% if not in_dashboard %}
|
||||
{% with tile=cell|as_dashboard_cell:request.user %}
|
||||
{% if tile %}
|
||||
<a class="remove-from-dashboard" href="{% url 'combo-dashboard-remove-tile' cell_reference=tile.cell.get_reference %}"></a>
|
||||
{% else %}
|
||||
<a class="add-to-dashboard" href="{% url 'combo-dashboard-add-tile' cell_reference=cell.get_reference %}"></a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<a class="remove-from-dashboard" href="{% url 'combo-dashboard-remove-tile' cell_reference=cell.get_reference %}"></a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<span class="dashboard-cell-icons">
|
||||
{% if not in_dashboard %}
|
||||
{% with tile=cell|as_dashboard_cell:request.user %}
|
||||
{% if tile %}
|
||||
<a class="remove-from-dashboard" href="{% url 'combo-dashboard-remove-tile' cell_reference=tile.cell.get_reference %}"></a>
|
||||
{% else %}
|
||||
<a class="add-to-dashboard" href="{% url 'combo-dashboard-add-tile' cell_reference=cell.get_reference %}"></a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<a class="remove-from-dashboard" href="{% url 'combo-dashboard-remove-tile' cell_reference=cell.get_reference %}"></a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
{% load i18n %}
|
||||
{% load combo i18n %}
|
||||
{% block cell-content %}
|
||||
{% 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 }}{{ cell.get_ajax_url }}"
|
||||
data-ajax-cell-loading-message="{{ cell.loading_message }}"
|
||||
data-ajax-cell-error-message="{% trans "Loading error" %}"
|
||||
{% if cell.ajax_refresh %}
|
||||
data-ajax-cell-refresh="{{ cell.ajax_refresh }}"
|
||||
{% endif %}><div>{% render_cell cell %}</div></div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% 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="{{ cell.loading_message }}"
|
||||
{% if cell.ajax_refresh %}
|
||||
data-ajax-cell-refresh="{{ cell.ajax_refresh }}"
|
||||
{% endif %}><div>{% render_cell cell %}</div></div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -21,26 +21,17 @@ from ..models import Tile
|
|||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def get_cell_data(cell):
|
||||
# return a dictionary with cell parameters relevant for tile comparison
|
||||
if cell is None:
|
||||
return {}
|
||||
cell_data = serializers.serialize('python', [cell])[0]
|
||||
del cell_data['pk']
|
||||
for key in (
|
||||
'restricted_to_unlogged',
|
||||
'groups',
|
||||
'last_update_timestamp',
|
||||
'order',
|
||||
'placeholder',
|
||||
'public',
|
||||
'page',
|
||||
):
|
||||
for key in ('restricted_to_unlogged', 'groups', 'last_update_timestamp',
|
||||
'order', 'placeholder', 'public', 'page'):
|
||||
del cell_data['fields'][key]
|
||||
return cell_data
|
||||
|
||||
|
||||
@register.filter
|
||||
def as_dashboard_cell(cell, user):
|
||||
cell_data = get_cell_data(cell)
|
||||
|
|
|
@ -14,34 +14,21 @@
|
|||
# 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.urls import path, re_path
|
||||
from django.conf.urls import url
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
re_path(
|
||||
r'^api/dashboard/add/(?P<cell_reference>[\w_-]+)/$',
|
||||
url(r'^api/dashboard/add/(?P<cell_reference>[\w_-]+)/$',
|
||||
views.dashboard_add_tile,
|
||||
name='combo-dashboard-add-tile',
|
||||
),
|
||||
re_path(
|
||||
r'^api/dashboard/remove/(?P<cell_reference>[\w_-]+)/$',
|
||||
name='combo-dashboard-add-tile'),
|
||||
url(r'^api/dashboard/remove/(?P<cell_reference>[\w_-]+)/$',
|
||||
views.dashboard_remove_tile,
|
||||
name='combo-dashboard-remove-tile',
|
||||
),
|
||||
re_path(
|
||||
r'^api/dashboard/auto-tile/(?P<key>[\w_-]+)/$',
|
||||
name='combo-dashboard-remove-tile'),
|
||||
url(r'^api/dashboard/auto-tile/(?P<key>[\w_-]+)/$',
|
||||
views.dashboard_auto_tile,
|
||||
name='combo-dashboard-auto-tile',
|
||||
),
|
||||
re_path(
|
||||
r'^api/dashboard/reorder/(?P<dashboard_id>[\w]+)/$',
|
||||
name='combo-dashboard-auto-tile'),
|
||||
url(r'^api/dashboard/reorder/(?P<dashboard_id>[\w]+)/$',
|
||||
views.dashboard_reorder_tiles,
|
||||
name='combo-dashboard-reorder-tiles',
|
||||
),
|
||||
path(
|
||||
'api/dashboard/tile-stats/',
|
||||
views.dashboard_tile_stats,
|
||||
name='combo-dashboard-tile-stats',
|
||||
),
|
||||
name='combo-dashboard-reorder-tiles'),
|
||||
]
|
||||
|
|
|
@ -17,27 +17,17 @@
|
|||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db.models import Max, Min
|
||||
from django.http import (
|
||||
Http404,
|
||||
HttpResponse,
|
||||
HttpResponseBadRequest,
|
||||
HttpResponseNotAllowed,
|
||||
HttpResponseRedirect,
|
||||
)
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import force_str
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import View
|
||||
from rest_framework import permissions
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from combo.data.models import CellBase, ConfigJsonCell
|
||||
from combo.data.library import get_cell_class
|
||||
from combo.public.views import render_cell
|
||||
from combo.utils import is_ajax
|
||||
|
||||
from .models import DashboardCell, Tile
|
||||
from .templatetags.dashboard import get_cell_data
|
||||
|
@ -46,58 +36,52 @@ from .templatetags.dashboard import get_cell_data
|
|||
def dashboard_success(request, dashboard, cell_data):
|
||||
dashboard_url = dashboard.page.get_online_url()
|
||||
|
||||
if is_ajax(request):
|
||||
if request.is_ajax():
|
||||
return HttpResponse(
|
||||
json.dumps({'err': 0, 'url': request.build_absolute_uri(dashboard_url), 'cell_data': cell_data}),
|
||||
content_type='application/json',
|
||||
)
|
||||
json.dumps({
|
||||
'err': 0,
|
||||
'url': request.build_absolute_uri(dashboard_url),
|
||||
'cell_data': cell_data}),
|
||||
content_type='application/json')
|
||||
|
||||
return HttpResponseRedirect(dashboard_url)
|
||||
|
||||
|
||||
class DashboardAddTileView(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
if not request.user.is_authenticated():
|
||||
raise PermissionDenied()
|
||||
|
||||
dashboard = DashboardCell.objects.filter(page__snapshot__isnull=True).first()
|
||||
if dashboard is None:
|
||||
raise Http404()
|
||||
dashboard = DashboardCell.objects.all()[0]
|
||||
cell = CellBase.get_cell(kwargs['cell_reference'])
|
||||
if not cell.page.is_visible(request.user):
|
||||
raise PermissionDenied()
|
||||
if not cell.is_visible(request):
|
||||
if not cell.is_visible(request.user):
|
||||
raise PermissionDenied()
|
||||
cell.pk = None
|
||||
cell.page = dashboard.page
|
||||
cell.placeholder = '_dashboard'
|
||||
cell.save()
|
||||
|
||||
tile = Tile(dashboard=dashboard, cell=cell, user=request.user, order=0)
|
||||
tile = Tile(dashboard=dashboard,
|
||||
cell=cell,
|
||||
user=request.user,
|
||||
order=0)
|
||||
if settings.COMBO_DASHBOARD_NEW_TILE_POSITION == 'first':
|
||||
order = (
|
||||
Tile.objects.filter(dashboard=dashboard, user=request.user)
|
||||
.aggregate(Min('order'))
|
||||
.get('order__min')
|
||||
)
|
||||
order = Tile.objects.filter(dashboard=dashboard, user=request.user).aggregate(Min('order')).get('order__min')
|
||||
tile.order = order - 1 if order is not None else 0
|
||||
elif settings.COMBO_DASHBOARD_NEW_TILE_POSITION == 'last':
|
||||
order = (
|
||||
Tile.objects.filter(dashboard=dashboard, user=request.user)
|
||||
.aggregate(Max('order'))
|
||||
.get('order__max')
|
||||
)
|
||||
order = Tile.objects.filter(dashboard=dashboard, user=request.user).aggregate(Max('order')).get('order_max')
|
||||
tile.order = order + 1 if order is not None else 0
|
||||
tile.save()
|
||||
|
||||
cell_data = get_cell_data(cell)
|
||||
cell_data['remove_url'] = reverse(
|
||||
'combo-dashboard-remove-tile', kwargs={'cell_reference': cell.get_reference()}
|
||||
)
|
||||
'combo-dashboard-remove-tile',
|
||||
kwargs={'cell_reference': cell.get_reference()})
|
||||
|
||||
return dashboard_success(request, dashboard, cell_data)
|
||||
|
||||
|
||||
dashboard_add_tile = DashboardAddTileView.as_view()
|
||||
|
||||
|
||||
|
@ -116,55 +100,38 @@ class DashboardRemoveTileView(View):
|
|||
|
||||
# do not remove cell so it can directly be added back
|
||||
cell_data['add_url'] = reverse(
|
||||
'combo-dashboard-add-tile', kwargs={'cell_reference': cell.get_reference()}
|
||||
)
|
||||
'combo-dashboard-add-tile',
|
||||
kwargs={'cell_reference': cell.get_reference()})
|
||||
|
||||
return dashboard_success(request, dashboard, cell_data)
|
||||
|
||||
|
||||
dashboard_remove_tile = DashboardRemoveTileView.as_view()
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def dashboard_auto_tile(request, *args, **kwargs):
|
||||
if request.method != 'POST':
|
||||
return HttpResponseNotAllowed(['post'])
|
||||
|
||||
try:
|
||||
request_body = json.loads(force_str(request.body))
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest('bad json request: "%s"' % request.body)
|
||||
|
||||
dashboard = DashboardCell.objects.filter(page__snapshot__isnull=True).first()
|
||||
if dashboard is None:
|
||||
raise Http404()
|
||||
cell = ConfigJsonCell(key=kwargs.get('key'), order=1, page_id=dashboard.page_id, placeholder='_auto_tile')
|
||||
|
||||
if cell.key not in settings.JSON_CELL_TYPES:
|
||||
return HttpResponseBadRequest('bad request, invalid cell type: "%s"' % cell.key)
|
||||
dashboard = DashboardCell.objects.all()[0]
|
||||
cell = ConfigJsonCell(key=kwargs.get('key'), order=1,
|
||||
page_id=dashboard.page_id, placeholder='_auto_tile')
|
||||
|
||||
# only keep parameters that are actually defined for this cell type.
|
||||
cell_form_keys = [x['varname'] for x in settings.JSON_CELL_TYPES[cell.key].get('form') or {}]
|
||||
cell.parameters = {}
|
||||
for field in settings.JSON_CELL_TYPES[cell.key].get('form') or []:
|
||||
key = field['varname']
|
||||
request_body = json.loads(request.body)
|
||||
for key in cell_form_keys:
|
||||
cell.parameters[key] = request_body.get(key)
|
||||
if cell.parameters[key] is None and field.get('required', True):
|
||||
return HttpResponseBadRequest('missing key: %s' % key)
|
||||
|
||||
# save cell so it can be reused later, for example to be added to
|
||||
# dashboard, or to be used as reference in another page, etc.
|
||||
cell.save()
|
||||
|
||||
response = render_cell(request, cell=cell)
|
||||
response['x-add-to-dashboard-url'] = reverse(
|
||||
'combo-dashboard-add-tile', kwargs={'cell_reference': cell.get_reference()}
|
||||
)
|
||||
return response
|
||||
return render_cell(request, cell=cell)
|
||||
|
||||
|
||||
def dashboard_reorder_tiles(request, *args, **kwargs):
|
||||
dashboard = DashboardCell.objects.all()[0]
|
||||
new_order = request.GET['order'].split(',')
|
||||
tiles = {str(x.id): x for x in Tile.objects.filter(id__in=new_order)}
|
||||
tiles = dict((str(x.id), x) for x in Tile.objects.filter(id__in=new_order))
|
||||
for i, tile_id in enumerate(new_order):
|
||||
tile = tiles.get(tile_id)
|
||||
if tile.user != request.user:
|
||||
|
@ -174,63 +141,3 @@ def dashboard_reorder_tiles(request, *args, **kwargs):
|
|||
for tile in tiles.values():
|
||||
tile.save()
|
||||
return HttpResponse(status=204)
|
||||
|
||||
|
||||
class TileStats(GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
data = {}
|
||||
data['users'] = {}
|
||||
data['users']['count'] = User.objects.all().count()
|
||||
data['tiles'] = {}
|
||||
tiles_by_user = {}
|
||||
manual_tiles_by_user = {}
|
||||
|
||||
# preload
|
||||
cells = {}
|
||||
for cell in ConfigJsonCell.objects.filter(placeholder__in=['_dashboard', '_suggested_tile']):
|
||||
cells[cell.id] = cell
|
||||
|
||||
for tile in Tile.objects.filter(dashboard__isnull=False):
|
||||
try:
|
||||
cell = cells[tile.cell_pk] # no db access
|
||||
except KeyError:
|
||||
# likely added after the preload request
|
||||
continue
|
||||
|
||||
if cell.key not in settings.JSON_CELL_TYPES:
|
||||
continue
|
||||
|
||||
if tile.user_id not in tiles_by_user:
|
||||
tiles_by_user[tile.user_id] = []
|
||||
tiles_by_user[tile.user_id].append(cell.key)
|
||||
|
||||
if cell.key not in data['tiles']:
|
||||
data['tiles'][cell.key] = {
|
||||
'name': settings.JSON_CELL_TYPES[cell.key]['name'],
|
||||
'count': 0,
|
||||
'manual': 0,
|
||||
}
|
||||
|
||||
data['tiles'][cell.key]['count'] += 1
|
||||
|
||||
if cell.placeholder != '_suggested_tile':
|
||||
data['tiles'][cell.key]['manual'] += 1
|
||||
if tile.user_id not in manual_tiles_by_user:
|
||||
manual_tiles_by_user[tile.user_id] = []
|
||||
manual_tiles_by_user[tile.user_id].append(cell.key)
|
||||
|
||||
data['users']['have-tiles'] = len(tiles_by_user.keys())
|
||||
data['users']['have-more-than-suggested-tiles'] = len(manual_tiles_by_user.keys())
|
||||
data['users']['have-no-tiles'] = data['users']['count'] - data['users']['have-tiles']
|
||||
dashboard_lengths = [len(x) for x in tiles_by_user.values()]
|
||||
if dashboard_lengths:
|
||||
dashboard_lengths.sort()
|
||||
data['users']['max-tiles'] = max(dashboard_lengths)
|
||||
data['users']['median-tiles'] = dashboard_lengths[len(dashboard_lengths) // 2]
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
dashboard_tile_stats = TileStats.as_view()
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2015 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/>.
|
||||
|
||||
import re
|
||||
|
||||
import django.apps
|
||||
from django.core import checks
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class AppConfig(django.apps.AppConfig):
|
||||
name = 'combo.apps.dataviz'
|
||||
verbose_name = _('Data Visualisation')
|
||||
|
||||
def get_before_urls(self):
|
||||
from . import urls
|
||||
return urls.urlpatterns
|
||||
|
||||
default_app_config = 'combo.apps.dataviz.AppConfig'
|
|
@ -1,8 +0,0 @@
|
|||
from django import forms
|
||||
|
||||
|
||||
class StaticField(forms.Field):
|
||||
widget = forms.HiddenInput
|
||||
|
||||
def bound_data(self, data, initial):
|
||||
return initial
|
|
@ -14,39 +14,12 @@
|
|||
# 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/>.
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
from collections import OrderedDict, defaultdict
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models.fields import BLANK_CHOICE_DASH
|
||||
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from combo.utils import cache_during_request, requests, spooler
|
||||
from combo.utils import requests
|
||||
|
||||
from .fields import StaticField
|
||||
from .models import ChartCell, ChartFiltersCell, ChartNgCell
|
||||
from .widgets import MultipleSelect2Widget, Select2Widget
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Choice:
|
||||
id: str
|
||||
label: str
|
||||
group: str = None
|
||||
|
||||
@staticmethod
|
||||
def get_field_choices(choices):
|
||||
choices_by_group = defaultdict(list)
|
||||
for choice in choices:
|
||||
choices_by_group[choice.group].append((choice.id, choice.label))
|
||||
|
||||
return list(choices_by_group.items())
|
||||
from .models import ChartCell
|
||||
|
||||
|
||||
class ChartForm(forms.ModelForm):
|
||||
|
@ -55,456 +28,12 @@ class ChartForm(forms.ModelForm):
|
|||
fields = ('title', 'url')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
super(ChartForm, self).__init__(*args, **kwargs)
|
||||
available_charts = []
|
||||
for site_dict in (settings.KNOWN_SERVICES.get('bijoe') or {}).values():
|
||||
result = requests.get(
|
||||
'/visualization/json/',
|
||||
remote_service=site_dict,
|
||||
without_user=True,
|
||||
headers={'accept': 'application/json'},
|
||||
).json()
|
||||
for site_key, site_dict in settings.KNOWN_SERVICES.get('bijoe').items():
|
||||
result = requests.get('/visualization/json/',
|
||||
remote_service=site_dict, without_user=True,
|
||||
headers={'accept': 'application/json'}).json()
|
||||
available_charts.extend([(x['path'], x['name']) for x in result])
|
||||
available_charts.sort(key=lambda x: x[1])
|
||||
self.fields['url'].widget = forms.Select(choices=available_charts)
|
||||
|
||||
|
||||
@cache_during_request
|
||||
def trigger_statistics_list_refresh():
|
||||
transaction.on_commit(spooler.refresh_statistics_list)
|
||||
|
||||
|
||||
class ChartFiltersMixin:
|
||||
ajax_choices = True
|
||||
time_intervals = (
|
||||
Choice('week', _('Week')),
|
||||
Choice('month', _('Month')),
|
||||
Choice('year', _('Year')),
|
||||
Choice('weekday', _('Week day')),
|
||||
)
|
||||
|
||||
def get_filter_fields(self, cell):
|
||||
fields = OrderedDict()
|
||||
for filter_ in cell.available_filters:
|
||||
filter_id = filter_['id']
|
||||
if filter_.get('deprecated') and (
|
||||
filter_id not in cell.filter_params
|
||||
or cell.filter_params.get(filter_id) == filter_.get('default')
|
||||
):
|
||||
continue
|
||||
|
||||
initial = cell.filter_params.get(filter_id, filter_.get('default'))
|
||||
required = filter_.get('required', False)
|
||||
|
||||
if required and {x['id'] for x in filter_['options'] if isinstance(x, dict)} == {'true', 'false'}:
|
||||
field = self.build_boolean_field(cell, filter_, initial)
|
||||
else:
|
||||
field = self.build_choice_field(cell, filter_, initial)
|
||||
|
||||
fields[filter_id] = field
|
||||
if filter_.get('deprecated'):
|
||||
fields[filter_id].label += ' (%s)' % _('deprecated')
|
||||
fields[filter_id].help_text = filter_.get('deprecation_hint')
|
||||
fields[filter_id].is_filter_field = True
|
||||
|
||||
return fields
|
||||
|
||||
@classmethod
|
||||
def get_filter_options(cls, cell, filter_, initial):
|
||||
filter_id = filter_['id']
|
||||
|
||||
filter_options = filter_['options']
|
||||
if not isinstance(filter_options[0], list):
|
||||
# no option groups, add empty one for consistency
|
||||
filter_options = [(None, filter_options)]
|
||||
choices = [
|
||||
Choice(id=opt['id'], label=opt['label'], group=group)
|
||||
for group, options in filter_options
|
||||
for opt in options
|
||||
]
|
||||
|
||||
if filter_id == 'time_interval':
|
||||
cls.extend_time_interval_choices(choices)
|
||||
|
||||
required = filter_.get('required', False)
|
||||
multiple = filter_.get('multiple')
|
||||
if not required and not multiple:
|
||||
choices.insert(0, Choice(*BLANK_CHOICE_DASH[0]))
|
||||
|
||||
extra_variables = cell.page.get_extra_variables_keys()
|
||||
variable_choices = [
|
||||
Choice(id='variable:' + key, label=key, group=_('Page variables')) for key in extra_variables
|
||||
]
|
||||
|
||||
for choice in initial if isinstance(initial, list) else [initial]:
|
||||
if not choice:
|
||||
continue
|
||||
if choice.startswith('variable:'):
|
||||
variable = choice.replace('variable:', '')
|
||||
if not variable in extra_variables:
|
||||
variable_choices.append(
|
||||
Choice(id=choice, label=_('%s (unavailable)') % variable, group=_('Page variables'))
|
||||
)
|
||||
elif not any(x.id == choice for x in choices):
|
||||
choices.append(Choice(id=choice, label=_('%s (unavailable)') % choice))
|
||||
|
||||
if variable_choices and not multiple and filter_id != 'time_interval':
|
||||
choices.extend(variable_choices)
|
||||
|
||||
return choices
|
||||
|
||||
def build_choice_field(self, cell, filter_, initial):
|
||||
multiple = filter_.get('multiple')
|
||||
required = filter_.get('required', False)
|
||||
choices = self.get_filter_options(cell, filter_, initial)
|
||||
|
||||
widget_class = MultipleSelect2Widget if multiple else Select2Widget
|
||||
widget = widget_class(cell, filter_['id'], choices, initial, self.ajax_choices)
|
||||
|
||||
field_class = forms.MultipleChoiceField if multiple else forms.ChoiceField
|
||||
field = field_class(
|
||||
label=filter_['label'],
|
||||
choices=Choice.get_field_choices(choices),
|
||||
required=required,
|
||||
initial=initial,
|
||||
)
|
||||
field.widget = widget
|
||||
field.dataviz_choices = choices
|
||||
return field
|
||||
|
||||
def build_boolean_field(self, cell, filter_, initial):
|
||||
return forms.BooleanField(
|
||||
label=filter_['label'],
|
||||
required=False,
|
||||
initial=bool(initial == 'true'),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def extend_time_interval_choices(cls, choices):
|
||||
if any(choice.id == 'day' for choice in choices):
|
||||
for choice in cls.time_intervals:
|
||||
if choice not in choices:
|
||||
choices.append(choice)
|
||||
|
||||
def update_time_range_choices(self, statistic, exclude_template_choice=False):
|
||||
choices = self.fields['time_range'].choices
|
||||
|
||||
if not statistic.has_future_data:
|
||||
choices = [choice for choice in choices if not choice[0].startswith('next')]
|
||||
|
||||
if exclude_template_choice:
|
||||
choices = [choice for choice in choices if choice[0] != 'range-template']
|
||||
|
||||
self.fields['time_range'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
for field, value in self.cleaned_data.items():
|
||||
if hasattr(self.fields[field], 'is_filter_field') and isinstance(value, bool):
|
||||
self.cleaned_data[field] = 'true' if value is True else 'false'
|
||||
|
||||
|
||||
class ChartNgForm(ChartFiltersMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = ChartNgCell
|
||||
fields = (
|
||||
'statistic',
|
||||
'time_range',
|
||||
'time_range_start',
|
||||
'time_range_end',
|
||||
'time_range_start_template',
|
||||
'time_range_end_template',
|
||||
'chart_type',
|
||||
'display_total',
|
||||
'height',
|
||||
'sort_order',
|
||||
'hide_null_values',
|
||||
'print_values',
|
||||
)
|
||||
widgets = {
|
||||
'time_range_start': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'time_range_end': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
trigger_statistics_list_refresh()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
stat_field = self.fields['statistic']
|
||||
if not self.instance.statistic:
|
||||
stat_field.queryset = stat_field.queryset.filter(available=True)
|
||||
else:
|
||||
# display current statistic in choices even if unavailable
|
||||
stat_field.queryset = stat_field.queryset.filter(
|
||||
Q(available=True) | Q(pk=self.instance.statistic.pk)
|
||||
)
|
||||
self.add_filter_fields()
|
||||
self.update_time_range_choices(self.instance.statistic)
|
||||
|
||||
if not self.instance.statistic or self.instance.statistic.service_slug == 'bijoe':
|
||||
for field in (
|
||||
'time_range',
|
||||
'time_range_start',
|
||||
'time_range_end',
|
||||
'time_range_start_template',
|
||||
'time_range_end_template',
|
||||
'display_total',
|
||||
):
|
||||
del self.fields[field]
|
||||
else:
|
||||
if self.instance.time_range != 'range':
|
||||
del self.fields['time_range_start']
|
||||
del self.fields['time_range_end']
|
||||
|
||||
if self.instance.time_range != 'range-template':
|
||||
del self.fields['time_range_start_template']
|
||||
del self.fields['time_range_end_template']
|
||||
|
||||
if not self.instance.is_table_chart() or self.instance.statistic.data_type:
|
||||
del self.fields['display_total']
|
||||
|
||||
def add_filter_fields(self):
|
||||
new_fields = OrderedDict()
|
||||
for field_name, field in self.fields.items():
|
||||
new_fields[field_name] = field
|
||||
if field_name == 'statistic':
|
||||
# insert filter fields after statistic field
|
||||
new_fields.update(self.get_filter_fields(self.instance))
|
||||
self.fields = new_fields
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if 'statistic' in self.changed_data:
|
||||
self.instance.filter_params.clear()
|
||||
self.instance.time_range = ''
|
||||
self.instance.subfilters.clear()
|
||||
for filter_ in self.instance.available_filters:
|
||||
if 'default' in filter_:
|
||||
self.instance.filter_params[filter_['id']] = filter_['default']
|
||||
elif filter_.get('required'):
|
||||
options = (
|
||||
filter_['options'][0][1]
|
||||
if isinstance(filter_['options'][0], list)
|
||||
else filter_['options']
|
||||
)
|
||||
self.instance.filter_params[filter_['id']] = options[0]['id']
|
||||
else:
|
||||
for filter_ in self.instance.available_filters:
|
||||
if filter_['id'] in self.cleaned_data:
|
||||
self.instance.filter_params[filter_['id']] = self.cleaned_data[filter_['id']]
|
||||
|
||||
cell = super().save(*args, **kwargs)
|
||||
|
||||
for filter_ in cell.available_filters:
|
||||
if filter_.get('has_subfilters') and filter_['id'] in self.changed_data:
|
||||
cell.update_subfilters()
|
||||
self.fields = OrderedDict(
|
||||
(name, field)
|
||||
for name, field in self.fields.items()
|
||||
if not hasattr(field, 'is_filter_field')
|
||||
)
|
||||
self.add_filter_fields()
|
||||
break
|
||||
|
||||
return cell
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
for template_field in ('time_range_start_template', 'time_range_end_template'):
|
||||
if not self.cleaned_data.get(template_field):
|
||||
continue
|
||||
context = {'now': datetime.datetime.now, 'today': datetime.datetime.now}
|
||||
try:
|
||||
Template('{{ %s|date:"Y-m-d" }}' % self.cleaned_data[template_field]).render(Context(context))
|
||||
except (VariableDoesNotExist, TemplateSyntaxError) as e:
|
||||
self.add_error(template_field, e)
|
||||
|
||||
|
||||
class ChartNgPartialForm(ChartFiltersMixin, forms.ModelForm):
|
||||
overridden_filters = forms.CharField()
|
||||
|
||||
prefix = 'filter'
|
||||
|
||||
class Meta:
|
||||
model = ChartNgCell
|
||||
fields = (
|
||||
'time_range',
|
||||
'time_range_start',
|
||||
'time_range_end',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields.update(self.get_filter_fields(self.instance))
|
||||
for field in self.fields.values():
|
||||
field.required = False
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
for field in self._meta.fields:
|
||||
if field not in self.cleaned_data['overridden_filters']:
|
||||
self.cleaned_data[field] = self.initial[field]
|
||||
|
||||
for filter_ in self.instance.available_filters:
|
||||
if filter_['id'] in self.cleaned_data['overridden_filters']:
|
||||
self.instance.filter_params[filter_['id']] = self.cleaned_data.get(filter_['id'])
|
||||
|
||||
def clean_overridden_filters(self):
|
||||
if not self.cleaned_data['overridden_filters']:
|
||||
return []
|
||||
|
||||
return self.cleaned_data['overridden_filters'].split(',')
|
||||
|
||||
|
||||
class ChartFiltersForm(ChartFiltersMixin, forms.ModelForm):
|
||||
ajax_choices = False
|
||||
overridden_filters = StaticField()
|
||||
|
||||
prefix = 'filter'
|
||||
|
||||
class Meta:
|
||||
model = ChartNgCell
|
||||
fields = (
|
||||
'time_range',
|
||||
'time_range_start',
|
||||
'time_range_end',
|
||||
)
|
||||
widgets = {
|
||||
'time_range_start': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'time_range_end': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
page = kwargs.pop('page')
|
||||
filters_cell = kwargs.pop('filters_cell')
|
||||
filters_cell_id = kwargs.pop('filters_cell_id', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
chart_cells = []
|
||||
for cell in ChartNgCell.objects.filter(page=page, statistic__isnull=False).order_by('order'):
|
||||
cell.page = page # use cached placeholders
|
||||
if cell.is_placeholder_active(traverse_cells=False):
|
||||
chart_cells.append(cell)
|
||||
|
||||
if not chart_cells:
|
||||
self.fields.clear()
|
||||
return
|
||||
|
||||
if filters_cell_id:
|
||||
for cell in chart_cells:
|
||||
cell.subfilters = cache.get(cell.get_cache_key(filters_cell_id), cell.subfilters)
|
||||
|
||||
first_cell = chart_cells[0]
|
||||
for field in self._meta.fields:
|
||||
self.fields[field].initial = getattr(first_cell, field)
|
||||
dynamic_fields = self.get_filter_fields(first_cell)
|
||||
dynamic_fields_values = {k: v for k, v in first_cell.filter_params.items()}
|
||||
|
||||
if first_cell.time_range == 'range-template':
|
||||
for field in self._meta.fields:
|
||||
self.fields.pop(field, None)
|
||||
else:
|
||||
self.update_time_range_choices(first_cell.statistic, exclude_template_choice=True)
|
||||
if not self.is_bound and first_cell.time_range != 'range':
|
||||
del self.fields['time_range_start']
|
||||
del self.fields['time_range_end']
|
||||
|
||||
for cell in chart_cells[1:]:
|
||||
cell_filter_fields = self.get_filter_fields(cell)
|
||||
|
||||
# keep only common fields
|
||||
dynamic_fields = {k: v for k, v in dynamic_fields.items() if k in cell_filter_fields}
|
||||
|
||||
# keep only same value fields
|
||||
for field, value in cell.filter_params.items():
|
||||
if field in dynamic_fields and value != dynamic_fields_values.get(field, ''):
|
||||
del dynamic_fields[field]
|
||||
|
||||
if cell.time_range != first_cell.time_range:
|
||||
for field in self._meta.fields:
|
||||
self.fields.pop(field, None)
|
||||
|
||||
# ensure compatible choices lists
|
||||
for field_name, field in cell_filter_fields.items():
|
||||
if field_name not in dynamic_fields or isinstance(field, forms.BooleanField):
|
||||
continue
|
||||
|
||||
dynamic_fields[field_name].dataviz_choices = [
|
||||
x for x in dynamic_fields[field_name].dataviz_choices if x in field.dataviz_choices
|
||||
]
|
||||
dynamic_fields[field_name].choices = Choice.get_field_choices(
|
||||
dynamic_fields[field_name].dataviz_choices
|
||||
)
|
||||
|
||||
if dynamic_fields[field_name].choices == []:
|
||||
del dynamic_fields[field_name]
|
||||
|
||||
if 'time_range' in self.fields:
|
||||
self.update_time_range_choices(cell.statistic)
|
||||
|
||||
self.update_backoffice_filter_choices(filters_cell, dynamic_fields)
|
||||
dynamic_fields = {
|
||||
name: field for name, field in dynamic_fields.items() if filters_cell.filters[name]['enabled']
|
||||
}
|
||||
self.fields.update(dynamic_fields)
|
||||
self.fields['overridden_filters'].initial = ','.join(self.fields)
|
||||
|
||||
@staticmethod
|
||||
def update_backoffice_filter_choices(filters_cell, dynamic_fields):
|
||||
# remove absent filters from cell configuration, except if it was disabled
|
||||
filters_cell.filters = {
|
||||
k: v for k, v in filters_cell.filters.items() if k in dynamic_fields or not v['enabled']
|
||||
}
|
||||
|
||||
# add filters to cell configuration
|
||||
for field_name, field in dynamic_fields.items():
|
||||
if not field_name in filters_cell.filters:
|
||||
filters_cell.filters[field_name] = {'label': str(field.label), 'enabled': True}
|
||||
continue
|
||||
|
||||
filters_cell.save()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.cleaned_data.get('time_range') != 'range':
|
||||
self.fields.pop('time_range_start', None)
|
||||
self.fields.pop('time_range_end', None)
|
||||
|
||||
|
||||
class ChartFiltersConfigForm(forms.ModelForm):
|
||||
filters = forms.MultipleChoiceField(
|
||||
label=_('Filters'), widget=forms.CheckboxSelectMultiple, required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ChartFiltersCell
|
||||
fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.instance.filters:
|
||||
del self.fields['filters']
|
||||
return
|
||||
|
||||
self.initial['filters'] = []
|
||||
self.fields['filters'].choices = []
|
||||
for filter_id, config in self.instance.filters.items():
|
||||
self.fields['filters'].choices.append((filter_id, config['label']))
|
||||
|
||||
if config['enabled']:
|
||||
self.initial['filters'].append(filter_id)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
for filter_id in self.instance.filters:
|
||||
self.instance.filters[filter_id]['enabled'] = bool(filter_id in self.cleaned_data['filters'])
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ChartNgExportForm(forms.Form):
|
||||
export_format = forms.ChoiceField(
|
||||
label=_('Format'),
|
||||
choices=(
|
||||
('svg', _('Picture (SVG)')),
|
||||
('ods', _('Table (ODS)')),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
from django.db import migrations, models
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0001_initial'),
|
||||
('data', '0012_auto_20151029_1535'),
|
||||
|
@ -11,27 +15,18 @@ class Migration(migrations.Migration):
|
|||
migrations.CreateModel(
|
||||
name='Gauge',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True),
|
||||
),
|
||||
('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)),
|
||||
('public', models.BooleanField(default=True, verbose_name='Public')),
|
||||
(
|
||||
'restricted_to_unlogged',
|
||||
models.BooleanField(default=False, verbose_name='Restrict to unlogged users'),
|
||||
),
|
||||
('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
|
||||
('title', models.CharField(max_length=150, null=True, verbose_name='Title', blank=True)),
|
||||
('url', models.CharField(max_length=150, null=True, verbose_name='URL', blank=True)),
|
||||
(
|
||||
'data_source',
|
||||
models.CharField(max_length=150, null=True, verbose_name='Data Source', blank=True),
|
||||
),
|
||||
('data_source', models.CharField(max_length=150, null=True, verbose_name='Data Source', blank=True)),
|
||||
('max_value', models.PositiveIntegerField(null=True, verbose_name='Max Value', blank=True)),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Roles', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page', on_delete=models.CASCADE)),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Gauge',
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
from django.db import migrations, models
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dataviz', '0001_initial'),
|
||||
]
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
from django.db import migrations, models
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0001_initial'),
|
||||
('data', '0012_auto_20151029_1535'),
|
||||
|
@ -12,39 +16,21 @@ class Migration(migrations.Migration):
|
|||
migrations.CreateModel(
|
||||
name='CubesBarChart',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True),
|
||||
),
|
||||
('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)),
|
||||
('public', models.BooleanField(default=True, verbose_name='Public')),
|
||||
(
|
||||
'restricted_to_unlogged',
|
||||
models.BooleanField(default=False, verbose_name='Restrict to unlogged users'),
|
||||
),
|
||||
('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
|
||||
('title', models.CharField(max_length=150, null=True, verbose_name='Title', blank=True)),
|
||||
('url', models.URLField(max_length=150, null=True, verbose_name='URL', blank=True)),
|
||||
('cube', models.CharField(max_length=256, null=True, verbose_name='Cube', blank=True)),
|
||||
(
|
||||
'aggregate1',
|
||||
models.CharField(max_length=64, null=True, verbose_name='Aggregate', blank=True),
|
||||
),
|
||||
(
|
||||
'drilldown1',
|
||||
models.CharField(max_length=64, null=True, verbose_name='Drilldown 1', blank=True),
|
||||
),
|
||||
(
|
||||
'drilldown2',
|
||||
models.CharField(max_length=64, null=True, verbose_name='Drilldown 2', blank=True),
|
||||
),
|
||||
(
|
||||
'other_parameters',
|
||||
models.TextField(null=True, verbose_name='Other parameters', blank=True),
|
||||
),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Roles', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page', on_delete=models.CASCADE)),
|
||||
('aggregate1', models.CharField(max_length=64, null=True, verbose_name='Aggregate', blank=True)),
|
||||
('drilldown1', models.CharField(max_length=64, null=True, verbose_name='Drilldown 1', blank=True)),
|
||||
('drilldown2', models.CharField(max_length=64, null=True, verbose_name='Drilldown 2', blank=True)),
|
||||
('other_parameters', models.TextField(null=True, verbose_name='Other parameters', blank=True)),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Cubes Barchart',
|
||||
|
@ -54,39 +40,21 @@ class Migration(migrations.Migration):
|
|||
migrations.CreateModel(
|
||||
name='CubesTable',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True),
|
||||
),
|
||||
('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)),
|
||||
('public', models.BooleanField(default=True, verbose_name='Public')),
|
||||
(
|
||||
'restricted_to_unlogged',
|
||||
models.BooleanField(default=False, verbose_name='Restrict to unlogged users'),
|
||||
),
|
||||
('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
|
||||
('title', models.CharField(max_length=150, null=True, verbose_name='Title', blank=True)),
|
||||
('url', models.URLField(max_length=150, null=True, verbose_name='URL', blank=True)),
|
||||
('cube', models.CharField(max_length=256, null=True, verbose_name='Cube', blank=True)),
|
||||
(
|
||||
'aggregate1',
|
||||
models.CharField(max_length=64, null=True, verbose_name='Aggregate', blank=True),
|
||||
),
|
||||
(
|
||||
'drilldown1',
|
||||
models.CharField(max_length=64, null=True, verbose_name='Drilldown 1', blank=True),
|
||||
),
|
||||
(
|
||||
'drilldown2',
|
||||
models.CharField(max_length=64, null=True, verbose_name='Drilldown 2', blank=True),
|
||||
),
|
||||
(
|
||||
'other_parameters',
|
||||
models.TextField(null=True, verbose_name='Other parameters', blank=True),
|
||||
),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Roles', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page', on_delete=models.CASCADE)),
|
||||
('aggregate1', models.CharField(max_length=64, null=True, verbose_name='Aggregate', blank=True)),
|
||||
('drilldown1', models.CharField(max_length=64, null=True, verbose_name='Drilldown 1', blank=True)),
|
||||
('drilldown2', models.CharField(max_length=64, null=True, verbose_name='Drilldown 2', blank=True)),
|
||||
('other_parameters', models.TextField(null=True, verbose_name='Other parameters', blank=True)),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Cubes Table',
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
from django.db import migrations, models
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dataviz', '0003_cubesbarchart_cubestable'),
|
||||
]
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dataviz', '0004_auto_20160108_1048'),
|
||||
]
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('data', '0020_auto_20160928_1152'),
|
||||
('dataviz', '0005_auto_20160928_1152'),
|
||||
|
@ -11,28 +15,17 @@ class Migration(migrations.Migration):
|
|||
migrations.CreateModel(
|
||||
name='ChartCell',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True),
|
||||
),
|
||||
('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
|
||||
),
|
||||
),
|
||||
('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'),
|
||||
),
|
||||
('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
|
||||
('title', models.CharField(max_length=150, null=True, verbose_name='Title', blank=True)),
|
||||
('url', models.URLField(max_length=150, null=True, verbose_name='URL', blank=True)),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Roles', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page', on_delete=models.CASCADE)),
|
||||
('groups', models.ManyToManyField(to='auth.Group', verbose_name='Groups', blank=True)),
|
||||
('page', models.ForeignKey(to='data.Page')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Chart',
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import datetime
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import datetime
|
||||
from django.utils.timezone import utc
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dataviz', '0006_chartcell'),
|
||||
]
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.12 on 2019-03-28 07:57
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dataviz', '0007_auto_20170214_2006'),
|
||||
]
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
# Generated by Django 1.11.12 on 2019-06-17 10:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0008_auto_20190328_0857'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='chartcell',
|
||||
name='url',
|
||||
field=models.URLField(blank=True, max_length=250, null=True, verbose_name='URL'),
|
||||
),
|
||||
]
|
|
@ -1,81 +0,0 @@
|
|||
# Generated by Django 1.11.12 on 2019-03-28 10:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.models import JSONField
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('data', '0036_page_sub_slug'),
|
||||
('dataviz', '0009_auto_20190617_1214'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ChartNgCell',
|
||||
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)),
|
||||
('data_reference', models.CharField(max_length=150, verbose_name='Data')),
|
||||
('title', models.CharField(blank=True, max_length=150, verbose_name='Title')),
|
||||
('cached_json', JSONField(blank=True, default=dict)),
|
||||
(
|
||||
'chart_type',
|
||||
models.CharField(
|
||||
choices=[
|
||||
(b'bar', 'Bar'),
|
||||
(b'horizontal-bar', 'Horizontal Bar'),
|
||||
(b'stacked-bar', 'Stacked Bar'),
|
||||
(b'line', 'Line'),
|
||||
(b'pie', 'Pie'),
|
||||
(b'dot', 'Dot'),
|
||||
(b'table', 'Table'),
|
||||
],
|
||||
default=b'bar',
|
||||
max_length=20,
|
||||
verbose_name='Chart Type',
|
||||
),
|
||||
),
|
||||
(
|
||||
'height',
|
||||
models.CharField(
|
||||
choices=[
|
||||
(b'150', 'Short (150px)'),
|
||||
(b'250', 'Average (250px)'),
|
||||
(b'350', 'Tall (350px)'),
|
||||
],
|
||||
default=b'250',
|
||||
max_length=20,
|
||||
verbose_name='Height',
|
||||
),
|
||||
),
|
||||
('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Roles')),
|
||||
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Chart',
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='chartcell',
|
||||
options={'verbose_name': 'Chart (legacy)'},
|
||||
),
|
||||
]
|
|
@ -1,68 +0,0 @@
|
|||
# Generated by Django 1.11.18 on 2020-08-13 09:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0010_auto_20190328_1111'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='hide_null_values',
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text='This setting only applies for one-dimensional charts.',
|
||||
verbose_name='Hide null values',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='sort_order',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('none', 'None'),
|
||||
('alpha', 'Alphabetically'),
|
||||
('asc', 'Increasing values'),
|
||||
('desc', 'Decreasing values'),
|
||||
],
|
||||
default='none',
|
||||
help_text='This setting only applies for one-dimensional charts.',
|
||||
max_length=5,
|
||||
verbose_name='Sort data',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='chartngcell',
|
||||
name='chart_type',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('bar', 'Bar'),
|
||||
('horizontal-bar', 'Horizontal Bar'),
|
||||
('stacked-bar', 'Stacked Bar'),
|
||||
('stacked-bar-percent', 'Stacked Bar (%)'),
|
||||
('line', 'Line'),
|
||||
('pie', 'Pie'),
|
||||
('pie-percent', 'Pie (%)'),
|
||||
('dot', 'Dot'),
|
||||
('table', 'Table'),
|
||||
('table-inverted', 'Table (inverted)'),
|
||||
],
|
||||
default='bar',
|
||||
max_length=20,
|
||||
verbose_name='Chart Type',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='chartngcell',
|
||||
name='height',
|
||||
field=models.CharField(
|
||||
choices=[('150', 'Short (150px)'), ('250', 'Average (250px)'), ('350', 'Tall (350px)')],
|
||||
default='250',
|
||||
max_length=20,
|
||||
verbose_name='Height',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,49 +0,0 @@
|
|||
# Generated by Django 1.11.29 on 2020-11-26 14:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0011_auto_20200813_1100'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Statistic',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('slug', models.SlugField(max_length=256, verbose_name='Slug')),
|
||||
('label', models.CharField(max_length=256, verbose_name='Label')),
|
||||
('site_slug', models.SlugField(max_length=256, verbose_name='Site slug')),
|
||||
('service_slug', models.SlugField(max_length=256, verbose_name='Service slug')),
|
||||
('site_title', models.CharField(max_length=256, verbose_name='Site title')),
|
||||
('url', models.URLField(verbose_name='Data URL')),
|
||||
('last_update', models.DateTimeField(auto_now=True, null=True, verbose_name='Last update')),
|
||||
('available', models.BooleanField(default=True, verbose_name='Available data')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-available', 'site_title', 'label'],
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='statistic',
|
||||
unique_together={('slug', 'site_slug', 'service_slug')},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='statistic',
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='cells',
|
||||
to='dataviz.Statistic',
|
||||
verbose_name='Data',
|
||||
help_text='This list may take a few seconds to be updated, please refresh the page if an item is missing.',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,37 +0,0 @@
|
|||
# Generated by Django 1.11.29 on 2020-11-30 14:26
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_cells(apps, schema_editor):
|
||||
Statistic = apps.get_model('dataviz', 'Statistic')
|
||||
ChartNgCell = apps.get_model('dataviz', 'ChartNgCell')
|
||||
bijoe_sites = settings.KNOWN_SERVICES.get('bijoe', {})
|
||||
|
||||
for cell in ChartNgCell.objects.filter(statistic__isnull=True):
|
||||
if not cell.data_reference or not cell.cached_json:
|
||||
continue
|
||||
site_slug, slug = cell.data_reference.split(':')
|
||||
statistic, _ = Statistic.objects.get_or_create(
|
||||
slug=slug,
|
||||
site_slug=site_slug,
|
||||
service_slug='bijoe',
|
||||
defaults={
|
||||
'label': cell.cached_json['name'],
|
||||
'url': cell.cached_json['data-url'],
|
||||
'site_title': bijoe_sites.get(site_slug, {}).get('title'),
|
||||
},
|
||||
)
|
||||
cell.statistic = statistic
|
||||
cell.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0012_auto_20201126_1557'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_cells, migrations.RunPython.noop),
|
||||
]
|
|
@ -1,20 +0,0 @@
|
|||
# Generated by Django 1.11.29 on 2020-11-30 14:34
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0013_update_chartng_cells'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='chartngcell',
|
||||
name='cached_json',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='chartngcell',
|
||||
name='data_reference',
|
||||
),
|
||||
]
|
|
@ -1,23 +0,0 @@
|
|||
# Generated by Django 1.11.29 on 2020-12-02 13:24
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import JSONField
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0014_auto_20201130_1534'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='filter_params',
|
||||
field=JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='statistic',
|
||||
name='filters',
|
||||
field=JSONField(default=list),
|
||||
),
|
||||
]
|
|
@ -1,34 +0,0 @@
|
|||
# Generated by Django 1.11.29 on 2020-12-15 15:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from combo.apps.dataviz.models import TIME_FILTERS
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0015_auto_20201202_1424'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='time_range',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=TIME_FILTERS,
|
||||
max_length=20,
|
||||
verbose_name='Shown period',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='time_range_end',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='To'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='time_range_start',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='From'),
|
||||
),
|
||||
]
|
|
@ -1,11 +0,0 @@
|
|||
# Generated by Django 2.2.12 on 2021-04-11 15:54
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0016_auto_20201215_1624'),
|
||||
]
|
||||
|
||||
operations = []
|
|
@ -1,27 +0,0 @@
|
|||
# Generated by Django 2.2.21 on 2021-07-23 11:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0017_text_to_jsonb'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chartcell',
|
||||
name='template_name',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Cell Template'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='template_name',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Cell Template'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gauge',
|
||||
name='template_name',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Cell Template'),
|
||||
),
|
||||
]
|
|
@ -1,38 +0,0 @@
|
|||
# Generated by Django 2.2.19 on 2021-10-06 13:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import combo.data.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0018_auto_20210723_1318'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='time_range_end_template',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
max_length=200,
|
||||
validators=[combo.data.models.django_template_validator],
|
||||
verbose_name='To',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='time_range_start_template',
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
max_length=200,
|
||||
validators=[combo.data.models.django_template_validator],
|
||||
verbose_name='From',
|
||||
help_text=(
|
||||
'Template code returning a date. For example, Monday in two weeks would be '
|
||||
'today|add_days:"14"|adjust_to_week_monday. Page variables are also accessible.'
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,26 +0,0 @@
|
|||
# Generated by Django 2.2.19 on 2022-01-18 10:03
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_time_intervals(apps, schema_editor):
|
||||
ChartNgCell = apps.get_model('dataviz', 'ChartNgCell')
|
||||
|
||||
for cell in ChartNgCell.objects.all():
|
||||
save = False
|
||||
for param in cell.filter_params:
|
||||
if param == 'time_interval' and cell.filter_params[param].startswith('_'):
|
||||
cell.filter_params[param] = cell.filter_params[param][1:]
|
||||
save = True
|
||||
if save:
|
||||
cell.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0019_auto_20211006_1525'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_time_intervals, migrations.RunPython.noop),
|
||||
]
|
|
@ -1,48 +0,0 @@
|
|||
# Generated by Django 2.2.19 on 2022-01-18 10:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('data', '0051_link_cell_max_length'),
|
||||
('auth', '0011_update_proxy_permissions'),
|
||||
('dataviz', '0020_auto_20220118_1103'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ChartFiltersCell',
|
||||
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'
|
||||
),
|
||||
),
|
||||
(
|
||||
'template_name',
|
||||
models.CharField(blank=True, max_length=50, null=True, verbose_name='Cell Template'),
|
||||
),
|
||||
('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='Roles')),
|
||||
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Filters',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 2.2.19 on 2022-01-25 16:21
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0021_chartfilterscell'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='subfilters',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(default=list),
|
||||
),
|
||||
]
|
|
@ -1,17 +0,0 @@
|
|||
# Generated by Django 2.2.19 on 2022-03-17 10:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0022_chartngcell_subfilters'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='statistic',
|
||||
name='has_future_data',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -1,30 +0,0 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0023_statistic_has_future_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chartcell',
|
||||
name='condition',
|
||||
field=models.CharField(blank=True, max_length=1000, null=True, verbose_name='Display condition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chartfilterscell',
|
||||
name='condition',
|
||||
field=models.CharField(blank=True, max_length=1000, null=True, verbose_name='Display condition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='condition',
|
||||
field=models.CharField(blank=True, max_length=1000, null=True, verbose_name='Display condition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gauge',
|
||||
name='condition',
|
||||
field=models.CharField(blank=True, max_length=1000, null=True, verbose_name='Display condition'),
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 2.2.26 on 2022-12-06 17:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0024_display_condition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='statistic',
|
||||
name='data_type',
|
||||
field=models.CharField(default='', max_length=32),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 2.2.26 on 2022-12-12 13:28
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0025_statistic_data_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chartfilterscell',
|
||||
name='filters',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(default=dict, verbose_name='Filters'),
|
||||
),
|
||||
]
|
|
@ -1,21 +0,0 @@
|
|||
# Generated by Django 2.2.26 on 2023-02-22 09:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0026_chartfilterscell_filters'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='statistic',
|
||||
options={'ordering': ['-available', 'deprecated', 'site_title', 'label']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='statistic',
|
||||
name='deprecated',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -1,42 +0,0 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0027_auto_20230222_1001'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='chartcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='chartfilterscell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='chartfilterscell',
|
||||
name='filters',
|
||||
field=models.JSONField(default=dict, verbose_name='Filters'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='chartngcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='chartngcell',
|
||||
name='subfilters',
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gauge',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -1,27 +0,0 @@
|
|||
# Generated by Django 3.2.18 on 2024-02-14 11:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0028_increase_extra_css_class'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='display_total',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('none', 'None'),
|
||||
('line-and-column', 'Total line and total column'),
|
||||
('line', 'Total line'),
|
||||
('column', 'Total column'),
|
||||
],
|
||||
default='line-and-column',
|
||||
max_length=20,
|
||||
verbose_name='Display of total',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,29 +0,0 @@
|
|||
# Generated by Django 3.2.18 on 2024-04-29 12:05
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def remove_all_forms_option(apps, schema_editor):
|
||||
ChartNgCell = apps.get_model('dataviz', 'ChartNgCell')
|
||||
|
||||
for cell in ChartNgCell.objects.filter(statistic__slug='forms_counts'):
|
||||
form_filter = cell.filter_params.get('form')
|
||||
if not form_filter or not isinstance(form_filter, list):
|
||||
continue
|
||||
|
||||
try:
|
||||
form_filter.remove('_all')
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
cell.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0029_chartngcell_display_total'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(remove_all_forms_option, migrations.RunPython.noop),
|
||||
]
|
|
@ -1,21 +0,0 @@
|
|||
# Generated by Django 3.2.18 on 2024-04-30 09:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dataviz', '0030_remove_all_forms_option'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chartngcell',
|
||||
name='print_values',
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text='When not checked values are printed on hover.',
|
||||
verbose_name='Print values on chart',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -14,47 +14,14 @@
|
|||
# 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/>.
|
||||
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
from collections import OrderedDict
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import pygal
|
||||
import pygal.util
|
||||
from dateutil.relativedelta import MO, relativedelta
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings
|
||||
from django.db import models, transaction
|
||||
from django.db.models import JSONField
|
||||
from django.template import RequestContext, Template, TemplateSyntaxError, VariableDoesNotExist
|
||||
from django.template.defaultfilters import date as format_date
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.dates import WEEKDAYS
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timesince import timesince
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from combo.data.models import CellBase
|
||||
from combo.data.library import register_cell_class
|
||||
from combo.data.models import CellBase, django_template_validator
|
||||
from combo.middleware import get_request
|
||||
from combo.utils import get_templated_url, requests, spooler
|
||||
|
||||
|
||||
class UnsupportedDataSet(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingRequest(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingVariable(Exception):
|
||||
pass
|
||||
from combo.utils import get_templated_url
|
||||
|
||||
|
||||
@register_cell_class
|
||||
|
@ -65,7 +32,7 @@ class Gauge(CellBase):
|
|||
jsonp_data_source = models.BooleanField(_('Use JSONP to get data'), default=True)
|
||||
max_value = models.PositiveIntegerField(_('Max Value'), blank=True, null=True)
|
||||
|
||||
default_template_name = 'combo/gauge-cell.html'
|
||||
template_name = 'combo/gauge-cell.html'
|
||||
|
||||
class Media:
|
||||
js = ('js/gauge.min.js', 'js/combo.gauge.js')
|
||||
|
@ -84,37 +51,31 @@ class Gauge(CellBase):
|
|||
data_source_url = get_templated_url(self.data_source)
|
||||
else:
|
||||
data_source_url = reverse('combo-ajax-gauge-count', kwargs={'cell': self.id})
|
||||
return {
|
||||
'cell': self,
|
||||
'title': self.title,
|
||||
'url': get_templated_url(self.url) if self.url else None,
|
||||
'max_value': self.max_value,
|
||||
'data_source_url': data_source_url,
|
||||
'jsonp': self.jsonp_data_source,
|
||||
}
|
||||
return {'cell': self,
|
||||
'title': self.title,
|
||||
'url': get_templated_url(self.url) if self.url else None,
|
||||
'max_value': self.max_value,
|
||||
'data_source_url': data_source_url,
|
||||
'jsonp': self.jsonp_data_source,
|
||||
}
|
||||
|
||||
|
||||
@register_cell_class
|
||||
class ChartCell(CellBase):
|
||||
default_template_name = 'combo/dataviz-chart.html'
|
||||
template_name = 'combo/dataviz-chart.html'
|
||||
|
||||
title = models.CharField(_('Title'), max_length=150, blank=True, null=True)
|
||||
url = models.URLField(_('URL'), max_length=250, blank=True, null=True)
|
||||
url = models.URLField(_('URL'), max_length=150, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Chart (legacy)')
|
||||
verbose_name = _('Chart')
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
return (
|
||||
settings.LEGACY_CHART_CELL_ENABLED
|
||||
and hasattr(settings, 'KNOWN_SERVICES')
|
||||
and settings.KNOWN_SERVICES.get('bijoe')
|
||||
)
|
||||
def is_enabled(self):
|
||||
return hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('bijoe')
|
||||
|
||||
def get_default_form_class(self):
|
||||
from .forms import ChartForm
|
||||
|
||||
return ChartForm
|
||||
|
||||
def get_additional_label(self):
|
||||
|
@ -123,773 +84,7 @@ class ChartCell(CellBase):
|
|||
return ''
|
||||
|
||||
def get_cell_extra_context(self, context):
|
||||
context = super().get_cell_extra_context(context)
|
||||
context = super(ChartCell, self).get_cell_extra_context(context)
|
||||
context['title'] = self.title
|
||||
context['url'] = self.url
|
||||
return context
|
||||
|
||||
|
||||
class StatisticManager(models.Manager):
|
||||
def get_by_natural_key(self, slug, site_slug, service_slug):
|
||||
return self.get_or_create(slug=slug, site_slug=site_slug, service_slug=service_slug)[0]
|
||||
|
||||
|
||||
class Statistic(models.Model):
|
||||
slug = models.SlugField(_('Slug'), max_length=256)
|
||||
label = models.CharField(_('Label'), max_length=256)
|
||||
site_slug = models.SlugField(_('Site slug'), max_length=256)
|
||||
service_slug = models.SlugField(_('Service slug'), max_length=256)
|
||||
site_title = models.CharField(_('Site title'), max_length=256)
|
||||
url = models.URLField(_('Data URL'))
|
||||
filters = JSONField(default=list)
|
||||
has_future_data = models.BooleanField(default=False)
|
||||
data_type = models.CharField(max_length=32)
|
||||
deprecated = models.BooleanField(default=False)
|
||||
available = models.BooleanField(_('Available data'), default=True)
|
||||
last_update = models.DateTimeField(_('Last update'), null=True, auto_now=True)
|
||||
|
||||
objects = StatisticManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-available', 'deprecated', 'site_title', 'label']
|
||||
unique_together = ['slug', 'site_slug', 'service_slug']
|
||||
|
||||
def __str__(self):
|
||||
name = _('%(title)s: %(label)s') % {'title': self.site_title or self.site_slug, 'label': self.label}
|
||||
if not self.available:
|
||||
name = _('%s (unavailable)') % name
|
||||
elif self.deprecated:
|
||||
name = _('%s (deprecated)') % name
|
||||
return name
|
||||
|
||||
def natural_key(self):
|
||||
return (self.slug, self.site_slug, self.service_slug)
|
||||
|
||||
def has_native_support_for_interval(self, time_interval):
|
||||
# pylint: disable=not-an-iterable
|
||||
return any(
|
||||
time_interval == x['id']
|
||||
for filter_ in self.filters
|
||||
for x in filter_['options']
|
||||
if filter_['id'] == 'time_interval'
|
||||
)
|
||||
|
||||
|
||||
TIME_FILTERS = [
|
||||
('previous-year', _('Previous year')),
|
||||
('current-year', _('Current year')),
|
||||
('next-year', _('Next year')),
|
||||
('previous-month', _('Previous month')),
|
||||
('current-month', _('Current month')),
|
||||
('next-month', _('Next month')),
|
||||
('previous-week', _('Previous week')),
|
||||
('current-week', _('Current week')),
|
||||
('next-week', _('Next week')),
|
||||
('range', _('Free range (date)')),
|
||||
('range-template', _('Free range (template)')),
|
||||
]
|
||||
|
||||
|
||||
@register_cell_class
|
||||
class ChartNgCell(CellBase):
|
||||
statistic = models.ForeignKey(
|
||||
verbose_name=_('Data'),
|
||||
to=Statistic,
|
||||
blank=False,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='cells',
|
||||
help_text=_(
|
||||
'This list may take a few seconds to be updated, please refresh the page if an item is missing.'
|
||||
),
|
||||
)
|
||||
subfilters = JSONField(default=list)
|
||||
filter_params = JSONField(default=dict)
|
||||
title = models.CharField(_('Title'), max_length=150, blank=True)
|
||||
time_range = models.CharField(
|
||||
_('Shown period'),
|
||||
max_length=20,
|
||||
blank=True,
|
||||
choices=TIME_FILTERS,
|
||||
)
|
||||
time_range_start = models.DateField(_('From'), null=True, blank=True)
|
||||
time_range_end = models.DateField(_('To'), null=True, blank=True)
|
||||
time_range_start_template = models.CharField(
|
||||
_('From'),
|
||||
max_length=200,
|
||||
blank=True,
|
||||
validators=[django_template_validator],
|
||||
help_text=_(
|
||||
'Template code returning a date. For example, Monday in two weeks would be '
|
||||
'today|add_days:"14"|adjust_to_week_monday. Page variables are also accessible.'
|
||||
),
|
||||
)
|
||||
time_range_end_template = models.CharField(
|
||||
_('To'),
|
||||
max_length=200,
|
||||
blank=True,
|
||||
validators=[django_template_validator],
|
||||
)
|
||||
chart_type = models.CharField(
|
||||
_('Chart Type'),
|
||||
max_length=20,
|
||||
default='bar',
|
||||
choices=(
|
||||
('bar', _('Bar')),
|
||||
('horizontal-bar', _('Horizontal Bar')),
|
||||
('stacked-bar', _('Stacked Bar')),
|
||||
('stacked-bar-percent', _('Stacked Bar (%)')),
|
||||
('line', _('Line')),
|
||||
('pie', _('Pie')),
|
||||
('pie-percent', _('Pie (%)')),
|
||||
('dot', _('Dot')),
|
||||
('table', _('Table')),
|
||||
('table-inverted', _('Table (inverted)')),
|
||||
),
|
||||
)
|
||||
display_total = models.CharField(
|
||||
_('Display of total'),
|
||||
max_length=20,
|
||||
default='line-and-column',
|
||||
choices=(
|
||||
('none', _('None')),
|
||||
('line-and-column', _('Total line and total column')),
|
||||
('line', _('Total line')),
|
||||
('column', _('Total column')),
|
||||
),
|
||||
)
|
||||
|
||||
height = models.CharField(
|
||||
_('Height'),
|
||||
max_length=20,
|
||||
default='250',
|
||||
choices=(
|
||||
('150', _('Short (150px)')),
|
||||
('250', _('Average (250px)')),
|
||||
('350', _('Tall (350px)')),
|
||||
),
|
||||
)
|
||||
|
||||
sort_order = models.CharField(
|
||||
_('Sort data'),
|
||||
max_length=5,
|
||||
default='none',
|
||||
help_text=_('This setting only applies for one-dimensional charts.'),
|
||||
choices=(
|
||||
('none', _('None')),
|
||||
('alpha', _('Alphabetically')),
|
||||
('asc', _('Increasing values')),
|
||||
('desc', _('Decreasing values')),
|
||||
),
|
||||
)
|
||||
|
||||
hide_null_values = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Hide null values'),
|
||||
help_text=_('This setting only applies for one-dimensional charts.'),
|
||||
)
|
||||
|
||||
print_values = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Print values on chart'),
|
||||
help_text=_('When not checked values are printed on hover.'),
|
||||
)
|
||||
|
||||
manager_form_template = 'combo/chartngcell_form.html'
|
||||
|
||||
invalid_reason_codes = {
|
||||
'missing_statistic_url': _('No statistic URL set'),
|
||||
'statistic_data_not_found': _('Statistic URL seems to unexist'),
|
||||
'statistic_url_invalid': _('Statistic URL seems to be invalid'),
|
||||
}
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Chart')
|
||||
|
||||
class Media:
|
||||
js = ('js/chartngcell.js',)
|
||||
css = {'all': ('css/combo.chartngcell.css',)}
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
return settings.KNOWN_SERVICES.get('bijoe') or settings.STATISTICS_PROVIDERS
|
||||
|
||||
def get_default_form_class(self):
|
||||
from .forms import ChartNgForm
|
||||
|
||||
return ChartNgForm
|
||||
|
||||
def get_additional_label(self):
|
||||
return self.title
|
||||
|
||||
def get_download_filename(self):
|
||||
label = slugify(self.title or self.statistic.label)
|
||||
return 'export-%s-%s' % (label, date.today().strftime('%Y%m%d'))
|
||||
|
||||
def is_relevant(self, context):
|
||||
return bool(self.statistic)
|
||||
|
||||
def is_table_chart(self):
|
||||
return bool(self.chart_type in ('table', 'table-inverted'))
|
||||
|
||||
def check_validity(self):
|
||||
if not self.statistic:
|
||||
return
|
||||
|
||||
if not self.statistic.url:
|
||||
self.mark_as_invalid('missing_statistic_url')
|
||||
return
|
||||
|
||||
resp = None
|
||||
try:
|
||||
resp = self.get_statistic_data()
|
||||
except (RequestException, MissingRequest, MissingVariable):
|
||||
pass
|
||||
|
||||
self.set_validity_from_url(
|
||||
resp, not_found_code='statistic_data_not_found', invalid_code='statistic_url_invalid'
|
||||
)
|
||||
|
||||
def get_statistic_data(self, filter_params=None, raise_if_not_cached=False, invalidate_cache=False):
|
||||
headers = {
|
||||
'X-Statistics-Page-URL': urllib.parse.urljoin(
|
||||
settings.SITE_BASE_URL,
|
||||
reverse('combo-manager-page-view', kwargs={'pk': self.page_id})
|
||||
+ '#cell-%s' % self.get_reference(),
|
||||
)
|
||||
}
|
||||
return requests.get(
|
||||
self.statistic.url,
|
||||
params=filter_params or self.get_filter_params(),
|
||||
headers=headers,
|
||||
cache_duration=300,
|
||||
remote_service='auto',
|
||||
without_user=True,
|
||||
raise_if_not_cached=raise_if_not_cached,
|
||||
log_errors=False,
|
||||
invalidate_cache=invalidate_cache,
|
||||
)
|
||||
|
||||
def get_chart(self, width=None, height=None, raise_if_not_cached=False):
|
||||
filter_params = self.get_filter_params()
|
||||
transaction.on_commit(
|
||||
lambda: spooler.refresh_statistics_data(cell_pk=self.pk, filter_params=filter_params)
|
||||
)
|
||||
response = self.get_statistic_data(filter_params, raise_if_not_cached)
|
||||
response.raise_for_status()
|
||||
response = response.json()
|
||||
|
||||
style = pygal.style.DefaultStyle(font_family='"Open Sans", sans-serif', background='transparent')
|
||||
|
||||
chart = {
|
||||
'bar': pygal.Bar,
|
||||
'horizontal-bar': pygal.HorizontalBar,
|
||||
'stacked-bar': pygal.StackedBar,
|
||||
'stacked-bar-percent': pygal.StackedBar,
|
||||
'line': pygal.Line,
|
||||
'pie': pygal.Pie,
|
||||
'pie-percent': pygal.Pie,
|
||||
'dot': pygal.Dot,
|
||||
'table': pygal.Bar,
|
||||
'table-inverted': pygal.Bar,
|
||||
}[self.chart_type](
|
||||
config=pygal.Config(
|
||||
style=copy.copy(style), order_min=0.1, max_scale=5, print_values=self.print_values
|
||||
)
|
||||
)
|
||||
|
||||
if self.statistic.service_slug == 'bijoe':
|
||||
x_labels, y_labels, data = self.parse_response(response, chart)
|
||||
chart.x_labels = x_labels
|
||||
|
||||
if chart.axis_count == 1:
|
||||
data = self.process_one_dimensional_data(chart, data)
|
||||
if getattr(chart, 'compute_sum', True) and self.is_table_chart():
|
||||
data = self.add_total_to_line_table(chart, data)
|
||||
self.add_data_to_chart(chart, data, y_labels)
|
||||
else:
|
||||
data = response['data']
|
||||
|
||||
interval = self.filter_params.get('time_interval', '')
|
||||
if data['x_labels'] and interval:
|
||||
if interval == 'day' or not self.statistic.has_native_support_for_interval(interval):
|
||||
self.aggregate_data(data, interval)
|
||||
elif interval == 'month':
|
||||
data['x_labels'] = [
|
||||
format_date(datetime.strptime(x, '%Y-%m'), 'M Y') for x in data['x_labels']
|
||||
]
|
||||
|
||||
chart.x_labels = data['x_labels']
|
||||
chart.axis_count = min(len(data['series']), 2)
|
||||
chart.compute_sum = False
|
||||
|
||||
if self.statistic.data_type:
|
||||
chart.config.value_formatter = self.get_value_formatter(self.statistic.data_type)
|
||||
|
||||
if chart.axis_count == 1:
|
||||
data['series'][0]['data'] = self.process_one_dimensional_data(
|
||||
chart, data['series'][0]['data']
|
||||
)
|
||||
if self.chart_type in ('pie', 'pie-percent'):
|
||||
data['series'] = [
|
||||
{'label': chart.config.x_value_formatter(label), 'data': [data]}
|
||||
for label, data in zip(chart.x_labels, data['series'][0]['data'])
|
||||
if data
|
||||
]
|
||||
elif self.chart_type == 'dot':
|
||||
data['series'][0]['label'] = ''
|
||||
|
||||
if self.chart_type == 'stacked-bar-percent':
|
||||
self.make_percent([serie['data'] for serie in data['series']])
|
||||
elif self.chart_type == 'pie-percent':
|
||||
self.make_global_percent([serie['data'] for serie in data['series']])
|
||||
chart.config.value_formatter = self.get_value_formatter(measure='percent')
|
||||
|
||||
for serie in data['series']:
|
||||
chart.add(serie['label'], serie['data'])
|
||||
|
||||
if self.is_table_chart() and not self.statistic.data_type:
|
||||
self.add_total_to_table(chart, [serie['data'] for serie in data['series']])
|
||||
|
||||
self.configure_chart(chart, width, height)
|
||||
|
||||
return chart
|
||||
|
||||
def get_filter_params(self):
|
||||
params = {k: self.evaluate_filter_value(v) for k, v in self.filter_params.items() if v}
|
||||
|
||||
now = timezone.now().date()
|
||||
if self.time_range == 'current-year':
|
||||
params['start'] = date(year=now.year, month=1, day=1)
|
||||
params['end'] = date(year=now.year + 1, month=1, day=1)
|
||||
elif self.time_range == 'previous-year':
|
||||
params['start'] = date(year=now.year - 1, month=1, day=1)
|
||||
params['end'] = date(year=now.year, month=1, day=1)
|
||||
elif self.time_range == 'next-year':
|
||||
params['start'] = date(year=now.year + 1, month=1, day=1)
|
||||
params['end'] = date(year=now.year + 2, month=1, day=1)
|
||||
elif self.time_range == 'current-month':
|
||||
params['start'] = now.replace(day=1)
|
||||
params['end'] = now + relativedelta(day=1, months=1)
|
||||
elif self.time_range == 'previous-month':
|
||||
params['start'] = now + relativedelta(day=1, months=-1)
|
||||
params['end'] = now.replace(day=1)
|
||||
elif self.time_range == 'next-month':
|
||||
params['start'] = now + relativedelta(day=1, months=1)
|
||||
params['end'] = now + relativedelta(day=1, months=2)
|
||||
elif self.time_range == 'current-week':
|
||||
params['start'] = now + relativedelta(weekday=MO(-1))
|
||||
params['end'] = now + relativedelta(weekday=MO(+1), days=+1)
|
||||
elif self.time_range == 'previous-week':
|
||||
params['start'] = now + relativedelta(weekday=MO(-2))
|
||||
params['end'] = now + relativedelta(weekday=MO(-1))
|
||||
elif self.time_range == 'next-week':
|
||||
params['start'] = now + relativedelta(weekday=MO(+1), days=+1)
|
||||
params['end'] = now + relativedelta(weekday=MO(+2), days=+1)
|
||||
elif self.time_range == 'range':
|
||||
if self.time_range_start:
|
||||
params['start'] = self.time_range_start
|
||||
if self.time_range_end:
|
||||
params['end'] = self.time_range_end
|
||||
elif self.time_range == 'range-template':
|
||||
if self.time_range_start_template:
|
||||
start = self.evaluate_range_template(self.time_range_start_template)
|
||||
if start:
|
||||
params['start'] = start
|
||||
if self.time_range_end_template:
|
||||
end = self.evaluate_range_template(self.time_range_end_template)
|
||||
if end:
|
||||
params['end'] = end
|
||||
if 'time_interval' in params and not self.statistic.has_native_support_for_interval(
|
||||
params['time_interval']
|
||||
):
|
||||
params['time_interval'] = 'day'
|
||||
return params
|
||||
|
||||
def evaluate_range_template(self, value):
|
||||
if value in self.page.extra_variables:
|
||||
value = self.page.extra_variables[value].strip('{ }')
|
||||
|
||||
context = self.request_context
|
||||
context.update({'now': datetime.now, 'today': datetime.now})
|
||||
try:
|
||||
return Template('{{ %s|date:"Y-m-d" }}' % value).render(context)
|
||||
except (VariableDoesNotExist, TemplateSyntaxError):
|
||||
return None
|
||||
|
||||
def evaluate_filter_value(self, value):
|
||||
if isinstance(value, list) or not value.startswith('variable:'):
|
||||
return value
|
||||
|
||||
try:
|
||||
variable = self.page.extra_variables[value.replace('variable:', '')]
|
||||
except KeyError:
|
||||
raise MissingVariable
|
||||
|
||||
return Template(variable).render(self.request_context)
|
||||
|
||||
@cached_property
|
||||
def request_context(self):
|
||||
if not getattr(self, '_request', None):
|
||||
raise MissingRequest
|
||||
|
||||
ctx = RequestContext(self._request, getattr(self._request, 'extra_context', {}))
|
||||
ctx['request'] = self._request
|
||||
return ctx
|
||||
|
||||
def parse_response(self, response, chart):
|
||||
# normalize axis to have a fake axis when there are no dimensions and
|
||||
# always a x axis when there is a single dimension.
|
||||
data = response['data']
|
||||
loop_labels = response['axis'].get('loop') or []
|
||||
x_labels = response['axis'].get('x_labels') or []
|
||||
y_labels = response['axis'].get('y_labels') or []
|
||||
if loop_labels:
|
||||
if 'x_labels' in response['axis'] and 'y_labels' in response['axis']:
|
||||
# no support for three dimensions
|
||||
raise UnsupportedDataSet()
|
||||
if not y_labels:
|
||||
y_labels = loop_labels
|
||||
else:
|
||||
x_labels, y_labels = y_labels, loop_labels
|
||||
if (
|
||||
x_labels
|
||||
and y_labels
|
||||
and (len(y_labels) != len(data) or not all([len(x) == len(x_labels) for x in data]))
|
||||
):
|
||||
# varying dimensions
|
||||
raise UnsupportedDataSet()
|
||||
if not x_labels and not y_labels: # unidata
|
||||
x_labels = ['']
|
||||
y_labels = ['']
|
||||
data = [data]
|
||||
chart.axis_count = 0
|
||||
elif not x_labels:
|
||||
x_labels = y_labels
|
||||
y_labels = ['']
|
||||
chart.axis_count = 1
|
||||
elif not y_labels:
|
||||
y_labels = ['']
|
||||
chart.axis_count = 1
|
||||
else:
|
||||
chart.axis_count = 2
|
||||
|
||||
chart.compute_sum = bool(response.get('measure') == 'integer' and chart.axis_count > 0)
|
||||
|
||||
formatter = self.get_value_formatter(response.get('unit'), response.get('measure'))
|
||||
if formatter is not None:
|
||||
chart.config.value_formatter = formatter
|
||||
|
||||
return x_labels, y_labels, data
|
||||
|
||||
def configure_dot_chart(self, chart, width, height):
|
||||
chart.show_legend = False
|
||||
# use a single colour for dots
|
||||
chart.config.style.colors = ('#1f77b4',) * max(len(chart.x_labels), 1)
|
||||
|
||||
def configure_horizontal_bar_chart(self, chart, width, height):
|
||||
if width and width < 500:
|
||||
# truncate labels
|
||||
chart.x_labels = [pygal.util.truncate(x, 15) for x in chart.x_labels]
|
||||
|
||||
def configure_pie_chart(self, chart, width, height):
|
||||
chart.show_legend = True
|
||||
if width and height:
|
||||
# pies are as tall as wide, reserve the appropriate space and distribute
|
||||
# the rest for the legend.
|
||||
chart.truncate_legend = (width - height) // 10
|
||||
elif width:
|
||||
chart.truncate_legend = width // 20
|
||||
|
||||
def configure_pie_percent_chart(self, *args, **kwargs):
|
||||
self.configure_pie_chart(*args, **kwargs)
|
||||
|
||||
def configure_chart(self, chart, width, height):
|
||||
auto_height_scale = pygal.style.DefaultStyle.legend_font_size * 1.75
|
||||
chart.config.margin = 0
|
||||
if width:
|
||||
chart.config.width = width
|
||||
height = height or int(self.height)
|
||||
# adapt chart's height to legend length
|
||||
chart.config.height = max(height, auto_height_scale * len(chart.raw_series))
|
||||
|
||||
if width or height:
|
||||
chart.config.explicit_size = True
|
||||
|
||||
chart.config.js = [os.path.join(settings.STATIC_URL, 'js/pygal-tooltips.js')]
|
||||
|
||||
chart.show_legend = bool(chart.axis_count > 1)
|
||||
chart.truncate_legend = 30
|
||||
# matplotlib tab10 palette
|
||||
chart.config.style.colors = (
|
||||
'#1f77b4',
|
||||
'#ff7f0e',
|
||||
'#2ca02c',
|
||||
'#d62728',
|
||||
'#9467bd',
|
||||
'#8c564b',
|
||||
'#e377c2',
|
||||
'#7f7f7f',
|
||||
'#bcbd22',
|
||||
'#17becf',
|
||||
)
|
||||
|
||||
custom_configure_method_name = 'configure_%s_chart' % self.chart_type.replace('-', '_')
|
||||
if hasattr(self, custom_configure_method_name):
|
||||
getattr(self, custom_configure_method_name)(chart, width, height)
|
||||
|
||||
if self.chart_type not in ('pie', 'pie-percent'):
|
||||
if width and width < 500:
|
||||
chart.legend_at_bottom = True
|
||||
# restore demanded chart's height
|
||||
chart.config.height = height
|
||||
|
||||
def process_one_dimensional_data(self, chart, data):
|
||||
if self.hide_null_values:
|
||||
data = self.hide_values(chart, data)
|
||||
if data and self.sort_order != 'none':
|
||||
data = self.sort_values(chart, data)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def hide_values(chart, data):
|
||||
x_labels, new_data = [], []
|
||||
for label, value in zip(chart.x_labels, data):
|
||||
if value:
|
||||
x_labels.append(label)
|
||||
new_data.append(value)
|
||||
chart.x_labels = x_labels
|
||||
return new_data
|
||||
|
||||
def sort_values(self, chart, data):
|
||||
if self.sort_order == 'alpha':
|
||||
digit_re = re.compile('([0-9]+)')
|
||||
|
||||
def natural_sort_key(item):
|
||||
return [
|
||||
int(text) if text.isdigit() else text.lower() for text in digit_re.split(str(item[0]))
|
||||
]
|
||||
|
||||
tmp_items = sorted(zip(chart.x_labels, data), key=natural_sort_key)
|
||||
|
||||
elif self.sort_order == 'asc':
|
||||
tmp_items = sorted(zip(chart.x_labels, data), key=lambda x: (x[1] or 0))
|
||||
elif self.sort_order == 'desc':
|
||||
tmp_items = sorted(zip(chart.x_labels, data), key=lambda x: (x[1] or 0), reverse=True)
|
||||
x_labels, sorted_data = zip(*((label, value) for label, value in tmp_items))
|
||||
chart.x_labels = list(x_labels)
|
||||
return list(sorted_data)
|
||||
|
||||
@staticmethod
|
||||
def add_total_to_line_table(chart, data):
|
||||
# workaround pygal
|
||||
chart.compute_sum = False
|
||||
|
||||
# do not add total for single point
|
||||
if len(data) <= 1:
|
||||
return data
|
||||
|
||||
data.append(sum(x for x in data if x is not None))
|
||||
chart.x_labels.append(gettext('Total'))
|
||||
return data
|
||||
|
||||
def add_total_to_table(self, chart, series_data):
|
||||
if chart.axis_count == 0:
|
||||
return
|
||||
|
||||
# do not add total for single point
|
||||
if len(series_data) == 1 and len(series_data[0]) == 1:
|
||||
return
|
||||
|
||||
if self.display_total in ('line', 'line-and-column'):
|
||||
chart.x_labels.append(gettext('Total'))
|
||||
for serie in series_data:
|
||||
serie.append(sum(x for x in serie if x is not None))
|
||||
|
||||
if chart.axis_count == 1:
|
||||
return
|
||||
|
||||
if self.display_total in ('column', 'line-and-column'):
|
||||
line_totals = []
|
||||
for line in zip(*series_data):
|
||||
line_totals.append(sum(x for x in line if x is not None))
|
||||
chart.add(gettext('Total'), line_totals)
|
||||
|
||||
def add_data_to_chart(self, chart, data, y_labels):
|
||||
if self.chart_type not in ('pie', 'pie-percent'):
|
||||
series_data = []
|
||||
for i, serie_label in enumerate(y_labels):
|
||||
if chart.axis_count < 2:
|
||||
values = data
|
||||
else:
|
||||
values = [data[i][j] for j in range(len(chart.x_labels))]
|
||||
series_data.append(values)
|
||||
chart.add(serie_label, values)
|
||||
if self.chart_type == 'stacked-bar-percent':
|
||||
self.make_percent(series_data)
|
||||
else:
|
||||
# pie, create a serie by data, to get different colours
|
||||
values = data
|
||||
for label, value in zip(chart.x_labels, values):
|
||||
if not value:
|
||||
continue
|
||||
chart.add(label, value)
|
||||
|
||||
@staticmethod
|
||||
def get_value_formatter(unit=None, measure='duration'):
|
||||
if unit == 'seconds' or measure == 'duration':
|
||||
|
||||
def format_duration(value):
|
||||
if value is None:
|
||||
return '-'
|
||||
base_date = datetime(1871, 3, 18)
|
||||
return timesince(base_date, base_date + timedelta(seconds=value))
|
||||
|
||||
return format_duration
|
||||
elif measure == 'percent':
|
||||
percent_formatter = lambda x: f'{x:.1f}%'
|
||||
return percent_formatter
|
||||
|
||||
def make_percent(self, series_data):
|
||||
for i, values in enumerate(zip(*series_data)):
|
||||
sum_values = sum(v for v in values if v is not None)
|
||||
if sum_values == 0:
|
||||
continue
|
||||
|
||||
factor = 100 / sum_values
|
||||
for values in series_data:
|
||||
if values[i] is not None:
|
||||
values[i] = round(values[i] * factor, 1)
|
||||
|
||||
def make_global_percent(self, series_data):
|
||||
sum_values = sum(v for values in series_data for v in values if v is not None)
|
||||
if sum_values == 0:
|
||||
return
|
||||
|
||||
factor = 100 / sum_values
|
||||
for serie in series_data:
|
||||
for i, value in enumerate(serie):
|
||||
if value is not None:
|
||||
serie[i] = round(value * factor, 1)
|
||||
|
||||
@staticmethod
|
||||
def aggregate_data(data, interval):
|
||||
series_data = [serie['data'] for serie in data['series']]
|
||||
dates = [datetime.strptime(label, '%Y-%m-%d') for label in data['x_labels']]
|
||||
min_date, max_date = min(dates), max(dates)
|
||||
|
||||
date_formats = {
|
||||
'day': 'd M Y',
|
||||
# Translators: This indicates week number followed by year, for example it can yield W2-2021.
|
||||
# First "W" is the first letter of the word "week" and should be translated accordingly, second
|
||||
# "W" and "o" are interpreted by Django's date filter and should be left as is. First W is
|
||||
# backslash escaped to prevent it from being interpreted, translators should refer to Django's
|
||||
# documentation in order to know if the new letter resulting of translation should be escaped or not.
|
||||
'week': gettext(r'\WW-o'),
|
||||
'month': 'M Y',
|
||||
'year': 'Y',
|
||||
'weekday': 'l',
|
||||
}
|
||||
if interval == 'day':
|
||||
x_labels = [
|
||||
format_date(min_date + timedelta(days=i), date_formats['day'])
|
||||
for i in range((max_date - min_date).days + 1)
|
||||
]
|
||||
elif interval == 'month':
|
||||
month_difference = max_date.month - min_date.month + (max_date.year - min_date.year) * 12
|
||||
x_labels = [
|
||||
format_date(min_date + relativedelta(months=i), date_formats['month'])
|
||||
for i in range(month_difference + 1)
|
||||
]
|
||||
elif interval == 'year':
|
||||
x_labels = [str(year) for year in range(min_date.year, max_date.year + 1)]
|
||||
elif interval == 'weekday':
|
||||
x_labels = [str(label) for label in WEEKDAYS.values()]
|
||||
elif interval == 'week':
|
||||
x_labels = []
|
||||
date, last_date = min_date, max_date
|
||||
if min_date.weekday() > max_date.weekday():
|
||||
last_date += relativedelta(weeks=1)
|
||||
while date <= last_date:
|
||||
x_labels.append(format_date(date, date_formats['week']))
|
||||
date += relativedelta(weeks=1)
|
||||
|
||||
aggregates = OrderedDict((label, [0] * len(series_data)) for label in x_labels)
|
||||
for i, date in enumerate(dates):
|
||||
key = format_date(date, date_formats[interval])
|
||||
for j, dummy in enumerate(series_data):
|
||||
aggregates[key][j] += series_data[j][i] or 0
|
||||
|
||||
data['x_labels'] = x_labels
|
||||
for i, serie in enumerate(data['series']):
|
||||
serie['data'] = [values[i] for values in aggregates.values()]
|
||||
|
||||
@property
|
||||
def available_filters(self):
|
||||
return self.statistic.filters + self.subfilters
|
||||
|
||||
def update_subfilters(self, filter_params=None):
|
||||
self._request = get_request()
|
||||
try:
|
||||
response = self.get_statistic_data(filter_params=filter_params)
|
||||
except (TemplateSyntaxError, VariableDoesNotExist):
|
||||
return
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
data = response.json()['data']
|
||||
except Exception:
|
||||
return
|
||||
|
||||
new_subfilters = data.get('subfilters', [])
|
||||
if self.subfilters != new_subfilters:
|
||||
self.subfilters = new_subfilters
|
||||
subfilter_ids = {filter_['id'] for filter_ in self.available_filters}
|
||||
self.filter_params = {k: v for k, v in self.filter_params.items() if k in subfilter_ids}
|
||||
self.save()
|
||||
|
||||
def get_cache_key(self, filters_cell_id):
|
||||
return 'dataviz:%s:%s' % (filters_cell_id, self.pk)
|
||||
|
||||
|
||||
@register_cell_class
|
||||
class ChartFiltersCell(CellBase):
|
||||
filters = JSONField(_('Filters'), default=dict)
|
||||
|
||||
title = _('Filters')
|
||||
default_template_name = 'combo/chart-filters.html'
|
||||
manager_form_template = 'combo/chartfilterscell_form.html'
|
||||
max_one_by_page = True
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Filters')
|
||||
|
||||
class Media:
|
||||
js = ('xstatic/select2.min.js', 'xstatic/i18n/fr.js')
|
||||
css = {'all': ('xstatic/select2.min.css',)}
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
return settings.CHART_FILTERS_CELL_ENABLED and settings.STATISTICS_PROVIDERS
|
||||
|
||||
def get_cell_extra_context(self, context):
|
||||
from .forms import ChartFiltersForm
|
||||
|
||||
ctx = super().get_cell_extra_context(context)
|
||||
if 'filters_cell_id' in context['request'].GET: # detect refresh on submit
|
||||
ctx['form'] = ChartFiltersForm(
|
||||
data=context['request'].GET,
|
||||
page=self.page,
|
||||
filters_cell=self,
|
||||
filters_cell_id=context['request'].GET['filters_cell_id'],
|
||||
)
|
||||
ctx['form'].errors.clear()
|
||||
else:
|
||||
ctx['form'] = ChartFiltersForm(page=self.page, filters_cell=self)
|
||||
|
||||
return ctx
|
||||
|
||||
def get_default_form_class(self):
|
||||
from .forms import ChartFiltersConfigForm
|
||||
|
||||
return ChartFiltersConfigForm
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
.cell.chart-ng-cell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-ng-cell .download-button {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
line-height: unset;
|
||||
}
|
||||
|
||||
.chart-ng-cell .download-button:after {
|
||||
font-family: FontAwesome;
|
||||
content: "\f019"; /* download */
|
||||
}
|
||||
|
||||
.dataviz-table.total-line tr:last-child,
|
||||
.dataviz-table.total-line-and-column tr:last-child {
|
||||
font-weight: 600;
|
||||
background: #f7f7f7;
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
function get_graph_querystring(extra_context, width=undefined) {
|
||||
qs = [];
|
||||
if ($('#chart-filters').length) {
|
||||
qs.push($('#chart-filters').serialize());
|
||||
qs.push('filters_cell_id=' + $('body').data('filters-cell-id'));
|
||||
}
|
||||
if (extra_context)
|
||||
qs.push('ctx=' + extra_context);
|
||||
if (window.location.search)
|
||||
qs.push(window.location.search.slice(1));
|
||||
if (width)
|
||||
qs.push('width=' + width);
|
||||
return '?' + qs.join('&');
|
||||
};
|
|
@ -1,353 +0,0 @@
|
|||
(function() {
|
||||
var $, get_translation, init, init_svg, matches, padding, r_translation, sibl, svg_ns, tooltip_timeout, xlink_ns;
|
||||
|
||||
svg_ns = 'http://www.w3.org/2000/svg';
|
||||
|
||||
xlink_ns = 'http://www.w3.org/1999/xlink';
|
||||
|
||||
$ = function(sel, ctx) {
|
||||
if (ctx == null) {
|
||||
ctx = null;
|
||||
}
|
||||
ctx = ctx || document;
|
||||
return Array.prototype.slice.call(ctx.querySelectorAll(sel), 0).filter(function(e) {
|
||||
return e !== ctx;
|
||||
});
|
||||
};
|
||||
|
||||
matches = function(el, selector) {
|
||||
return (el.matches || el.matchesSelector || el.msMatchesSelector || el.mozMatchesSelector || el.webkitMatchesSelector || el.oMatchesSelector).call(el, selector);
|
||||
};
|
||||
|
||||
sibl = function(el, match) {
|
||||
if (match == null) {
|
||||
match = null;
|
||||
}
|
||||
return Array.prototype.filter.call(el.parentElement.children, function(child) {
|
||||
return child !== el && (!match || matches(child, match));
|
||||
});
|
||||
};
|
||||
|
||||
Array.prototype.one = function() {
|
||||
return this.length > 0 && this[0] || {};
|
||||
};
|
||||
|
||||
padding = 5;
|
||||
|
||||
tooltip_timeout = null;
|
||||
|
||||
r_translation = /translate\((\d+)[ ,]+(\d+)\)/;
|
||||
|
||||
get_translation = function(el) {
|
||||
return (r_translation.exec(el.getAttribute('transform')) || []).slice(1).map(function(x) {
|
||||
return +x;
|
||||
});
|
||||
};
|
||||
|
||||
init = function(ctx) {
|
||||
var bbox, box, config, el, graph, inner_svg, num, parent, tooltip, tooltip_el, tt, uid, untooltip, xconvert, yconvert, _i, _j, _k, _len, _len1, _len2, _ref, _ref1, _ref2, _ref3;
|
||||
if ($('svg', ctx).length) {
|
||||
inner_svg = $('svg', ctx).one();
|
||||
parent = inner_svg.parentElement;
|
||||
box = inner_svg.viewBox.baseVal;
|
||||
bbox = parent.getBBox();
|
||||
xconvert = function(x) {
|
||||
return ((x - box.x) / box.width) * bbox.width;
|
||||
};
|
||||
yconvert = function(y) {
|
||||
return ((y - box.y) / box.height) * bbox.height;
|
||||
};
|
||||
} else {
|
||||
xconvert = yconvert = function(x) {
|
||||
return x;
|
||||
};
|
||||
}
|
||||
if (((_ref = window.pygal) != null ? _ref.config : void 0) != null) {
|
||||
if (window.pygal.config.no_prefix != null) {
|
||||
config = window.pygal.config;
|
||||
} else {
|
||||
uid = ctx.id.replace('chart-', '');
|
||||
config = window.pygal.config[uid];
|
||||
}
|
||||
} else {
|
||||
config = window.config;
|
||||
}
|
||||
tooltip_el = null;
|
||||
graph = $('.graph').one();
|
||||
tt = $('.tooltip', ctx).one();
|
||||
_ref1 = $('.reactive', ctx);
|
||||
for (_i = 0, _len = _ref1.length; _i < _len; _i++) {
|
||||
el = _ref1[_i];
|
||||
el.addEventListener('mouseenter', (function(el) {
|
||||
return function() {
|
||||
return el.classList.add('active');
|
||||
};
|
||||
})(el));
|
||||
el.addEventListener('mouseleave', (function(el) {
|
||||
return function() {
|
||||
return el.classList.remove('active');
|
||||
};
|
||||
})(el));
|
||||
}
|
||||
_ref2 = $('.activate-serie', ctx);
|
||||
for (_j = 0, _len1 = _ref2.length; _j < _len1; _j++) {
|
||||
el = _ref2[_j];
|
||||
num = el.id.replace('activate-serie-', '');
|
||||
el.addEventListener('mouseenter', (function(num) {
|
||||
return function() {
|
||||
var re, _k, _l, _len2, _len3, _ref3, _ref4, _results;
|
||||
_ref3 = $('.serie-' + num + ' .reactive', ctx);
|
||||
for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
|
||||
re = _ref3[_k];
|
||||
re.classList.add('active');
|
||||
}
|
||||
_ref4 = $('.serie-' + num + ' .showable', ctx);
|
||||
_results = [];
|
||||
for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
|
||||
re = _ref4[_l];
|
||||
_results.push(re.classList.add('shown'));
|
||||
}
|
||||
return _results;
|
||||
};
|
||||
})(num));
|
||||
el.addEventListener('mouseleave', (function(num) {
|
||||
return function() {
|
||||
var re, _k, _l, _len2, _len3, _ref3, _ref4, _results;
|
||||
_ref3 = $('.serie-' + num + ' .reactive', ctx);
|
||||
for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
|
||||
re = _ref3[_k];
|
||||
re.classList.remove('active');
|
||||
}
|
||||
_ref4 = $('.serie-' + num + ' .showable', ctx);
|
||||
_results = [];
|
||||
for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
|
||||
re = _ref4[_l];
|
||||
_results.push(re.classList.remove('shown'));
|
||||
}
|
||||
return _results;
|
||||
};
|
||||
})(num));
|
||||
el.addEventListener('click', (function(el, num) {
|
||||
return function() {
|
||||
var ov, re, rect, show, _k, _l, _len2, _len3, _ref3, _ref4, _results;
|
||||
rect = $('rect', el).one();
|
||||
show = rect.style.fill !== '';
|
||||
rect.style.fill = show ? '' : 'transparent';
|
||||
_ref3 = $('.serie-' + num + ' .reactive', ctx);
|
||||
for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
|
||||
re = _ref3[_k];
|
||||
re.style.display = show ? '' : 'none';
|
||||
}
|
||||
_ref4 = $('.text-overlay .serie-' + num, ctx);
|
||||
_results = [];
|
||||
for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
|
||||
ov = _ref4[_l];
|
||||
_results.push(ov.style.display = show ? '' : 'none');
|
||||
}
|
||||
return _results;
|
||||
};
|
||||
})(el, num));
|
||||
}
|
||||
_ref3 = $('.tooltip-trigger', ctx);
|
||||
for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
|
||||
el = _ref3[_k];
|
||||
el.addEventListener('mouseenter', (function(el) {
|
||||
return function() {
|
||||
return tooltip_el = tooltip(el);
|
||||
};
|
||||
})(el));
|
||||
}
|
||||
tt.addEventListener('mouseenter', function() {
|
||||
return tooltip_el != null ? tooltip_el.classList.add('active') : void 0;
|
||||
});
|
||||
tt.addEventListener('mouseleave', function() {
|
||||
return tooltip_el != null ? tooltip_el.classList.remove('active') : void 0;
|
||||
});
|
||||
ctx.addEventListener('mouseleave', function() {
|
||||
if (tooltip_timeout) {
|
||||
clearTimeout(tooltip_timeout);
|
||||
}
|
||||
return untooltip(0);
|
||||
});
|
||||
graph.addEventListener('mousemove', function(el) {
|
||||
if (tooltip_timeout) {
|
||||
return;
|
||||
}
|
||||
if (!matches(el.target, '.background')) {
|
||||
return;
|
||||
}
|
||||
return untooltip(1000);
|
||||
});
|
||||
tooltip = function(el) {
|
||||
var a, baseline, cls, current_x, current_y, dy, h, i, key, keys, label, legend, name, plot_x, plot_y, rect, serie_index, subval, text, text_group, texts, traversal, value, w, x, x_elt, x_label, xlink, y, y_elt, _l, _len3, _len4, _len5, _m, _n, _ref4, _ref5, _ref6, _ref7, _ref8;
|
||||
clearTimeout(tooltip_timeout);
|
||||
tooltip_timeout = null;
|
||||
tt.style.opacity = 1;
|
||||
tt.style.display = '';
|
||||
text_group = $('g.text', tt).one();
|
||||
rect = $('rect', tt).one();
|
||||
text_group.innerHTML = '';
|
||||
label = sibl(el, '.label').one().textContent;
|
||||
x_label = sibl(el, '.x_label').one().textContent;
|
||||
value = sibl(el, '.value').one().textContent;
|
||||
xlink = sibl(el, '.xlink').one().textContent;
|
||||
serie_index = null;
|
||||
parent = el;
|
||||
traversal = [];
|
||||
while (parent) {
|
||||
traversal.push(parent);
|
||||
if (parent.classList.contains('series')) {
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
if (parent) {
|
||||
_ref4 = parent.classList;
|
||||
for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
|
||||
cls = _ref4[_l];
|
||||
if (cls.indexOf('serie-') === 0) {
|
||||
serie_index = +cls.replace('serie-', '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
legend = null;
|
||||
if (serie_index !== null) {
|
||||
legend = config.legends[serie_index];
|
||||
}
|
||||
dy = 0;
|
||||
keys = [[label, 'label']];
|
||||
_ref5 = value.split('\n');
|
||||
for (i = _m = 0, _len4 = _ref5.length; _m < _len4; i = ++_m) {
|
||||
subval = _ref5[i];
|
||||
keys.push([subval, 'value-' + i]);
|
||||
}
|
||||
if (config.tooltip_fancy_mode) {
|
||||
keys.push([xlink, 'xlink']);
|
||||
keys.unshift([x_label, 'x_label']);
|
||||
keys.unshift([legend, 'legend']);
|
||||
}
|
||||
texts = {};
|
||||
for (_n = 0, _len5 = keys.length; _n < _len5; _n++) {
|
||||
_ref6 = keys[_n], key = _ref6[0], name = _ref6[1];
|
||||
if (key) {
|
||||
text = document.createElementNS(svg_ns, 'text');
|
||||
text.textContent = key;
|
||||
text.setAttribute('x', padding);
|
||||
text.setAttribute('dy', dy);
|
||||
text.classList.add(name.indexOf('value') === 0 ? 'value' : name);
|
||||
if (name.indexOf('value') === 0 && config.tooltip_fancy_mode) {
|
||||
text.classList.add('color-' + serie_index);
|
||||
}
|
||||
if (name === 'xlink') {
|
||||
a = document.createElementNS(svg_ns, 'a');
|
||||
a.setAttributeNS(xlink_ns, 'href', key);
|
||||
a.textContent = void 0;
|
||||
a.appendChild(text);
|
||||
text.textContent = 'Link >';
|
||||
text_group.appendChild(a);
|
||||
} else {
|
||||
text_group.appendChild(text);
|
||||
}
|
||||
dy += text.getBBox().height + padding / 2;
|
||||
baseline = padding;
|
||||
if (text.style.dominantBaseline !== void 0) {
|
||||
text.style.dominantBaseline = 'text-before-edge';
|
||||
} else {
|
||||
baseline += text.getBBox().height * .8;
|
||||
}
|
||||
text.setAttribute('y', baseline);
|
||||
texts[name] = text;
|
||||
}
|
||||
}
|
||||
w = text_group.getBBox().width + 2 * padding;
|
||||
h = text_group.getBBox().height + 2 * padding;
|
||||
rect.setAttribute('width', w);
|
||||
rect.setAttribute('height', h);
|
||||
if (texts.value) {
|
||||
texts.value.setAttribute('dx', (w - texts.value.getBBox().width) / 2 - padding);
|
||||
}
|
||||
if (texts.x_label) {
|
||||
texts.x_label.setAttribute('dx', w - texts.x_label.getBBox().width - 2 * padding);
|
||||
}
|
||||
if (texts.xlink) {
|
||||
texts.xlink.setAttribute('dx', w - texts.xlink.getBBox().width - 2 * padding);
|
||||
}
|
||||
x_elt = sibl(el, '.x').one();
|
||||
y_elt = sibl(el, '.y').one();
|
||||
x = parseInt(x_elt.textContent);
|
||||
if (x_elt.classList.contains('centered')) {
|
||||
x -= w / 2;
|
||||
} else if (x_elt.classList.contains('left')) {
|
||||
x -= w;
|
||||
} else if (x_elt.classList.contains('auto')) {
|
||||
x = xconvert(el.getBBox().x + el.getBBox().width / 2) - w / 2;
|
||||
}
|
||||
y = parseInt(y_elt.textContent);
|
||||
if (y_elt.classList.contains('centered')) {
|
||||
y -= h / 2;
|
||||
} else if (y_elt.classList.contains('top')) {
|
||||
y -= h;
|
||||
} else if (y_elt.classList.contains('auto')) {
|
||||
y = yconvert(el.getBBox().y + el.getBBox().height / 2) - h / 2;
|
||||
}
|
||||
_ref7 = get_translation(tt.parentElement), plot_x = _ref7[0], plot_y = _ref7[1];
|
||||
if (x + w + plot_x > config.width) {
|
||||
x = config.width - w - plot_x;
|
||||
}
|
||||
if (y + h + plot_y > config.height) {
|
||||
y = config.height - h - plot_y;
|
||||
}
|
||||
if (x + plot_x < 0) {
|
||||
x = -plot_x;
|
||||
}
|
||||
if (y + plot_y < 0) {
|
||||
y = -plot_y;
|
||||
}
|
||||
_ref8 = get_translation(tt), current_x = _ref8[0], current_y = _ref8[1];
|
||||
if (current_x === x && current_y === y) {
|
||||
return el;
|
||||
}
|
||||
tt.setAttribute('transform', "translate(" + x + " " + y + ")");
|
||||
return el;
|
||||
};
|
||||
return untooltip = function(ms) {
|
||||
return tooltip_timeout = setTimeout(function() {
|
||||
tt.style.display = 'none';
|
||||
tt.style.opacity = 0;
|
||||
if (tooltip_el != null) {
|
||||
tooltip_el.classList.remove('active');
|
||||
}
|
||||
return tooltip_timeout = null;
|
||||
}, ms);
|
||||
};
|
||||
};
|
||||
|
||||
init_svg = function() {
|
||||
var chart, charts, _i, _len, _results;
|
||||
charts = $('.pygal-chart');
|
||||
if (charts.length) {
|
||||
_results = [];
|
||||
for (_i = 0, _len = charts.length; _i < _len; _i++) {
|
||||
chart = charts[_i];
|
||||
_results.push(init(chart));
|
||||
}
|
||||
return _results;
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState !== 'loading') {
|
||||
init_svg();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
return init_svg();
|
||||
});
|
||||
}
|
||||
|
||||
window.pygal = window.pygal || {};
|
||||
|
||||
window.pygal.init = init;
|
||||
|
||||
window.pygal.init_svg = init_svg;
|
||||
|
||||
}).call(this);
|
|
@ -1,63 +0,0 @@
|
|||
{% load i18n gadjo %}
|
||||
|
||||
{% block cell-content %}
|
||||
<h2>{{ cell.title }}</h2>
|
||||
|
||||
<div>
|
||||
{% if form.fields|length > 1 %}
|
||||
<form method='get' enctype='multipart/form-data' id='chart-filters'>
|
||||
{{ form|with_template }}
|
||||
<div class='buttons'>
|
||||
<button class='submit-button'>{% trans 'Refresh' %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
No filters are available. Note that only filters that are shared between all chart cells will appear. Furthermore, in case they have a value, it must be the same accross all cells.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
const form = $('#chart-filters');
|
||||
var loaded_cell_count = 0;
|
||||
|
||||
if (!$('body').data('filters-cell-id')) {
|
||||
$('body').data('filters-cell-id', Math.random().toString(36).slice(2, 7));
|
||||
|
||||
function load_filters_cell_last() {
|
||||
if (++loaded_cell_count == $('div.chartngcell').length) {
|
||||
combo_load_cell($('.chart-filters-cell'));
|
||||
loaded_cell_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('div.dataviz-table').forEach(graph => {
|
||||
graph.addEventListener('DOMSubtreeModified', load_filters_cell_last);
|
||||
});
|
||||
document.querySelectorAll('div.chartngcell embed').forEach(graph => {
|
||||
graph.addEventListener('load', load_filters_cell_last)
|
||||
});
|
||||
}
|
||||
|
||||
form.submit(function(e) {
|
||||
e.preventDefault();
|
||||
$(window).trigger('combo:refresh-graphs');
|
||||
chart_cell = $(this).parents('.cell');
|
||||
ajax_cell_url = "{{ site_base }}{{ cell.get_ajax_url }}";
|
||||
new_url = ajax_cell_url + '?filters_cell_id=' + $('body').data('filters-cell-id') + '&' + $(this).serialize();
|
||||
chart_cell.data('ajax-cell-url', new_url);
|
||||
});
|
||||
|
||||
form.change(function() {
|
||||
if(loaded_cell_count == 0) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,6 +0,0 @@
|
|||
{% load gadjo %}
|
||||
|
||||
{% block cell-form %}
|
||||
{{form|with_template}}
|
||||
{% endblock %}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
{% load i18n %}
|
||||
{% if cell.title %}<h2>{{cell.title}}</h2>{% endif %}
|
||||
{% if cell.is_table_chart %}
|
||||
<div id="chart-{{cell.id}}" class="dataviz-table total-{{ cell.display_total }}"></div>
|
||||
<script>
|
||||
$(function() {
|
||||
var extra_context = $('#chart-{{cell.id}}').parents('.cell').data('extra-context');
|
||||
$(window).on('combo:refresh-graphs', function() {
|
||||
var url = "{% url 'combo-dataviz-graph' cell=cell.id %}" + get_graph_querystring(extra_context);
|
||||
$('#chart-{{cell.id}}-download').attr('href', url + '&export-format=ods');
|
||||
$.ajax({
|
||||
url : url,
|
||||
type: 'GET',
|
||||
success: function(data) {
|
||||
$('#chart-{{cell.id}}').html(data);
|
||||
}
|
||||
});
|
||||
}).trigger('combo:refresh-graphs');
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<div style="min-height: {{cell.height}}px">
|
||||
<embed id="chart-{{cell.id}}" type="image/svg+xml" style="width: 100%"/>
|
||||
</div>
|
||||
<script>
|
||||
$(function() {
|
||||
var last_width = 1;
|
||||
var extra_context = $('#chart-{{cell.id}}').parents('.cell').data('extra-context');
|
||||
$(window).on('load resize gadjo:sidepage-toggled combo:resize-graphs', function() {
|
||||
var chart_cell = $('#chart-{{cell.id}}').parent();
|
||||
var new_width = Math.floor($(chart_cell).width());
|
||||
var ratio = new_width / last_width;
|
||||
if (ratio > 1.2 || ratio < 0.8) {
|
||||
var querystring = get_graph_querystring(extra_context, new_width);
|
||||
$('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + querystring);
|
||||
$('#chart-{{cell.id}}-download').attr('href', "{% url 'combo-dataviz-graph-export' cell=cell.id %}" + querystring);
|
||||
last_width = new_width;
|
||||
}
|
||||
}).trigger('combo:resize-graphs');
|
||||
$(window).on('combo:refresh-graphs', function() {
|
||||
var querystring = get_graph_querystring(extra_context, last_width);
|
||||
$('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + querystring);
|
||||
$('#chart-{{cell.id}}-download').attr('href', "{% url 'combo-dataviz-graph-export' cell=cell.id %}" + querystring);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<a
|
||||
class="button download-button"
|
||||
id="chart-{{ cell.id }}-download"
|
||||
title="{% trans "Download" %}"
|
||||
href="{% url 'combo-dataviz-graph-export' cell=cell.id %}"
|
||||
{% if cell.is_table_chart %}
|
||||
download
|
||||
{% else %}
|
||||
rel="popup"
|
||||
data-autoclose-dialog="true"
|
||||
{% endif %}
|
||||
>
|
||||
<span class="sr-only">{% trans "Download" %}</span>
|
||||
</a>
|
||||
<script>
|
||||
$(function() {
|
||||
$('#chart-{{cell.id}}').parents('.cell').on('mouseenter', function() {
|
||||
$('#chart-{{ cell.id }}-download').show();
|
||||
}).on('mouseleave', function() {
|
||||
$('#chart-{{ cell.id }}-download').hide();
|
||||
}).trigger('mouseleave');
|
||||
});
|
||||
</script>
|
|
@ -1,17 +0,0 @@
|
|||
{% extends "combo/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans "Export data" %}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<div class="buttons">
|
||||
<button class="submit-button">{% trans "Download" %}</button>
|
||||
<a class="cancel" href="{% url 'combo-manager-page-view' pk=object.pk %}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,18 +0,0 @@
|
|||
{% load gadjo %}
|
||||
|
||||
<div style="position: relative">
|
||||
{{ form|with_template }}
|
||||
{% if cell.statistic and not cell.is_table_chart %}
|
||||
<div style="position: absolute; right: 0; top: 0; width: 300px; height: 150px">
|
||||
<embed type="image/svg+xml" src="{% url 'combo-dataviz-graph' cell=cell.id %}?width=300&height=150"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
$('div#panel-dataviz_chartngcell-{{ cell.pk }}-general div.content').change(function() {
|
||||
$('div#cell-dataviz_chartngcell-{{ cell.pk }} button.save').click();
|
||||
});
|
||||
});
|
||||
</script>
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1"
|
||||
viewBox="0 0 {{ width }} 30" width="{{ width }}" height="30">
|
||||
<text
|
||||
y="20"
|
||||
x="10"
|
||||
style="font-family: sans-serif; font-size: 16px; fill:#000000;">{{ text }}</text>
|
||||
</svg>
|
Before Width: | Height: | Size: 305 B |
|
@ -1,16 +1,16 @@
|
|||
{% block cell-content %}
|
||||
<div
|
||||
data-combo-gauge="true"
|
||||
{% if jsonp %}
|
||||
data-gauge-count-jsonp-url="{{data_source_url}}"
|
||||
{% else %}
|
||||
data-gauge-count-url="{% url 'combo-ajax-gauge-count' cell=cell.id %}"
|
||||
{% endif %}
|
||||
data-gauge-max-value="{{max_value|default_if_none:100}}" class="bo-block">
|
||||
<canvas style="width: 100%;">
|
||||
</canvas>
|
||||
{% if title %}
|
||||
{% if url %}<a href="{{url}}">{% endif %}{{title}}{% if url %}</a>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div
|
||||
data-combo-gauge="true"
|
||||
{% if jsonp %}
|
||||
data-gauge-count-jsonp-url="{{data_source_url}}"
|
||||
{% else %}
|
||||
data-gauge-count-url="{% url 'combo-ajax-gauge-count' cell=cell.id %}"
|
||||
{% endif %}
|
||||
data-gauge-max-value="{{max_value|default_if_none:100}}" class="bo-block">
|
||||
<canvas style="width: 100%;">
|
||||
</canvas>
|
||||
{% if title %}
|
||||
{% if url %}<a href="{{url}}">{% endif %}{{title}}{% if url %}</a>{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -14,21 +14,11 @@
|
|||
# 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.urls import path, re_path
|
||||
from django.conf.urls import url
|
||||
|
||||
from combo.urls_utils import manager_required
|
||||
|
||||
from .views import ajax_gauge_count, dataviz_choices, dataviz_graph, dataviz_graph_export
|
||||
from .views import ajax_gauge_count
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^ajax/gauge-count/(?P<cell>[\w_-]+)/$', ajax_gauge_count, name='combo-ajax-gauge-count'),
|
||||
re_path(r'^api/dataviz/graph/(?P<cell>[\w_-]+)/$', dataviz_graph, name='combo-dataviz-graph'),
|
||||
re_path(
|
||||
r'^dataviz/graph/(?P<cell>[\w_-]+)/export/$', dataviz_graph_export, name='combo-dataviz-graph-export'
|
||||
),
|
||||
path(
|
||||
'api/dataviz/graph/<int:cell_id>/<filter_id>/ajax-choices',
|
||||
manager_required(dataviz_choices),
|
||||
name='combo-dataviz-choices',
|
||||
),
|
||||
url(r'^ajax/gauge-count/(?P<cell>[\w_-]+)/$',
|
||||
ajax_gauge_count, name='combo-ajax-gauge-count'),
|
||||
]
|
||||
|
|
|
@ -1,122 +0,0 @@
|
|||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import now
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from combo.utils import requests
|
||||
|
||||
from .models import Statistic
|
||||
|
||||
logger = logging.getLogger('combo.apps.dataviz')
|
||||
|
||||
|
||||
def update_available_statistics():
|
||||
if not settings.KNOWN_SERVICES:
|
||||
return
|
||||
|
||||
results = []
|
||||
temporary_unavailable_sites = []
|
||||
for provider in settings.STATISTICS_PROVIDERS:
|
||||
if isinstance(provider, dict):
|
||||
url = provider['url']
|
||||
sites = {provider['id']: {'title': provider['name']}}
|
||||
provider = provider['id']
|
||||
else:
|
||||
sites = settings.KNOWN_SERVICES.get(provider, {})
|
||||
url = '/visualization/json/' if provider == 'bijoe' else '/api/statistics/'
|
||||
|
||||
for site_key, site_dict in sites.items():
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
allow_redirects=False,
|
||||
timeout=5,
|
||||
remote_service=site_dict if provider in settings.KNOWN_SERVICES else {},
|
||||
without_user=True,
|
||||
headers={'accept': 'application/json'},
|
||||
log_errors='warn',
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException:
|
||||
temporary_unavailable_sites.append((provider, site_key))
|
||||
continue
|
||||
|
||||
try:
|
||||
result = response.json()
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(result, dict):
|
||||
result = result['data'] # detect new api
|
||||
|
||||
for stat in result:
|
||||
results.append(
|
||||
Statistic(
|
||||
slug=stat.get('slug') or stat['id'],
|
||||
site_slug=site_key,
|
||||
service_slug=provider,
|
||||
label=stat['name'],
|
||||
url=stat.get('data-url') or stat['url'],
|
||||
site_title=site_dict.get('title', ''),
|
||||
filters=stat.get('filters', []),
|
||||
has_future_data=stat.get('future_data', False),
|
||||
data_type=stat.get('data_type', ''),
|
||||
deprecated=stat.get('deprecated', False),
|
||||
available=True,
|
||||
)
|
||||
)
|
||||
|
||||
update_fields = (
|
||||
'label',
|
||||
'url',
|
||||
'site_title',
|
||||
'filters',
|
||||
'available',
|
||||
'has_future_data',
|
||||
'data_type',
|
||||
'deprecated',
|
||||
)
|
||||
all_statistics = {stat.natural_key(): stat for stat in Statistic.objects.all()}
|
||||
statistics_to_create = []
|
||||
statistics_to_update = {}
|
||||
for stat in results:
|
||||
existing_stat = all_statistics.get(stat.natural_key())
|
||||
if existing_stat:
|
||||
for field in update_fields:
|
||||
new_value = getattr(stat, field)
|
||||
if getattr(existing_stat, field) != new_value:
|
||||
setattr(existing_stat, field, new_value)
|
||||
statistics_to_update[existing_stat.pk] = existing_stat
|
||||
else:
|
||||
statistics_to_create.append(stat)
|
||||
|
||||
Statistic.objects.bulk_create(statistics_to_create)
|
||||
Statistic.objects.bulk_update(statistics_to_update.values(), update_fields)
|
||||
|
||||
available_stats = Statistic.objects.filter(available=True)
|
||||
for stat in results:
|
||||
available_stats = available_stats.exclude(
|
||||
slug=stat.slug, site_slug=stat.site_slug, service_slug=stat.service_slug
|
||||
)
|
||||
|
||||
# set last_update on all seen statistics
|
||||
Statistic.objects.exclude(pk__in=available_stats).update(last_update=now())
|
||||
|
||||
for service_slug, site_slug in temporary_unavailable_sites:
|
||||
available_stats = available_stats.exclude(site_slug=site_slug, service_slug=service_slug)
|
||||
available_stats.update(available=False)
|
||||
|
||||
# log errors for outdated statistics
|
||||
sites_with_outdated_statistics = set()
|
||||
outdated_hours = 48
|
||||
for available_stat in Statistic.objects.filter(available=True):
|
||||
time_since_last_update = now() - available_stat.last_update
|
||||
if time_since_last_update > datetime.timedelta(hours=outdated_hours):
|
||||
sites_with_outdated_statistics.add(available_stat.site_title)
|
||||
|
||||
for title in sites_with_outdated_statistics:
|
||||
logger.error(
|
||||
f'statistics from "{title}" have not been available for more than %s hours.', outdated_hours
|
||||
)
|
|
@ -1,5 +1,5 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2015-2019 Entr'ouvert
|
||||
# Copyright (C) 2015 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
|
||||
|
@ -14,234 +14,13 @@
|
|||
# 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/>.
|
||||
|
||||
import io
|
||||
import unicodedata
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pyexcel_ods
|
||||
from django.core import signing
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
|
||||
from django.shortcuts import render, reverse
|
||||
from django.template import TemplateSyntaxError, VariableDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.generic import DetailView, FormView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from combo.utils import NothingInCacheException, get_templated_url, requests
|
||||
|
||||
from .forms import ChartFiltersMixin, ChartNgExportForm, ChartNgPartialForm, Choice
|
||||
from .models import ChartNgCell, Gauge, MissingVariable, UnsupportedDataSet
|
||||
from combo.utils import get_templated_url, requests
|
||||
from .models import Gauge
|
||||
|
||||
|
||||
def ajax_gauge_count(request, *args, **kwargs):
|
||||
gauge = Gauge.objects.get(id=kwargs['cell'])
|
||||
response = requests.get(get_templated_url(gauge.data_source))
|
||||
return HttpResponse(response.content, content_type='text/json')
|
||||
|
||||
|
||||
class DatavizGraphView(DetailView):
|
||||
model = ChartNgCell
|
||||
pk_url_kwarg = 'cell'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.cell = self.get_object()
|
||||
self.filters_cell_id = request.GET.get('filters_cell_id')
|
||||
|
||||
if not self.cell.page.is_visible(request.user):
|
||||
raise PermissionDenied()
|
||||
if not self.cell.is_visible(request):
|
||||
raise PermissionDenied()
|
||||
if not self.cell.statistic or not self.cell.statistic.url:
|
||||
raise Http404('misconfigured cell')
|
||||
|
||||
if self.filters_cell_id:
|
||||
self.cell.subfilters = cache.get(
|
||||
self.cell.get_cache_key(self.filters_cell_id), self.cell.subfilters
|
||||
)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
form = ChartNgPartialForm(request.GET, instance=self.cell)
|
||||
if not form.is_valid():
|
||||
return self.error(_('Wrong parameters.'))
|
||||
|
||||
if request.GET.get('ctx'):
|
||||
try:
|
||||
request.extra_context = signing.loads(request.GET['ctx'])
|
||||
except signing.BadSignature:
|
||||
return HttpResponseBadRequest('bad signature')
|
||||
|
||||
form.instance._request = request
|
||||
try:
|
||||
chart = form.instance.get_chart(
|
||||
width=int(request.GET['width']) if request.GET.get('width') else None,
|
||||
height=int(request.GET['height']) if request.GET.get('height') else None,
|
||||
)
|
||||
except UnsupportedDataSet:
|
||||
return self.error(_('Unsupported dataset.'))
|
||||
except MissingVariable:
|
||||
return self.error(_('Page variable not found.'))
|
||||
except TemplateSyntaxError:
|
||||
return self.error(_('Syntax error in page variable.'))
|
||||
except VariableDoesNotExist:
|
||||
return self.error(_('Backoffice preview unavailable.'))
|
||||
except HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
return self.error(_('Visualization not found.'))
|
||||
else:
|
||||
return self.error(_('Unknown HTTP error: %s' % e))
|
||||
|
||||
if self.filters_cell_id and self.cell.statistic.service_slug != 'bijoe':
|
||||
self.update_subfilters_cache(form.instance)
|
||||
|
||||
export_format = request.GET.get('export-format')
|
||||
if export_format == 'svg':
|
||||
return self.export_to_svg(chart)
|
||||
elif export_format == 'ods':
|
||||
return self.export_to_ods(chart)
|
||||
|
||||
if self.cell.is_table_chart():
|
||||
if not chart.raw_series:
|
||||
return self.error(_('No data'))
|
||||
|
||||
# force x_value_formatter on x_labels (avoid bug https://github.com/Kozea/pygal/issues/372)
|
||||
chart.x_labels = [chart.config.x_value_formatter(x) for x in chart.x_labels]
|
||||
|
||||
rendered = chart.render_table(
|
||||
transpose=bool(self.cell.chart_type == 'table-inverted'),
|
||||
total=bool(self.cell.statistic.service_slug == 'bijoe' and chart.compute_sum),
|
||||
)
|
||||
rendered = rendered.replace('<table>', '<table class="main">')
|
||||
return HttpResponse(rendered)
|
||||
|
||||
return HttpResponse(chart.render(), content_type='image/svg+xml')
|
||||
|
||||
def error(self, error_text):
|
||||
if self.cell.is_table_chart():
|
||||
return HttpResponse('<p>%s</p>' % error_text)
|
||||
|
||||
context = {
|
||||
'width': self.request.GET.get('width', 200),
|
||||
'text': error_text,
|
||||
}
|
||||
return render(self.request, 'combo/dataviz-error.svg', context=context, content_type='image/svg+xml')
|
||||
|
||||
def update_subfilters_cache(self, cell):
|
||||
try:
|
||||
data = cell.get_statistic_data(raise_if_not_cached=True)
|
||||
except NothingInCacheException:
|
||||
pass # should not happen
|
||||
else:
|
||||
cache.set(
|
||||
cell.get_cache_key(self.filters_cell_id), data.json()['data'].get('subfilters', []), 300
|
||||
)
|
||||
|
||||
def export_to_svg(self, chart):
|
||||
response = HttpResponse(chart.render(), content_type='image/svg+xml')
|
||||
response['Content-Disposition'] = 'attachment; filename="%s.svg"' % self.cell.get_download_filename()
|
||||
return response
|
||||
|
||||
def export_to_ods(self, chart):
|
||||
data = [[''] + chart.x_labels] if any(chart.x_labels) else []
|
||||
for serie in chart.raw_series:
|
||||
line = [serie[1]['title']] + serie[0]
|
||||
line = [x or 0 for x in line]
|
||||
data.append(line)
|
||||
|
||||
data = [list(line) for line in zip(*data)]
|
||||
|
||||
output = io.BytesIO()
|
||||
pyexcel_ods.save_data(output, {self.cell.title or self.cell.statistic.label: data})
|
||||
output.seek(0)
|
||||
return FileResponse(
|
||||
output,
|
||||
as_attachment=True,
|
||||
content_type='application/vnd.oasis.opendocument.spreadsheet',
|
||||
filename='%s.ods' % self.cell.get_download_filename(),
|
||||
)
|
||||
|
||||
|
||||
dataviz_graph = xframe_options_sameorigin(DatavizGraphView.as_view())
|
||||
|
||||
|
||||
class DatavizGraphExportView(SingleObjectMixin, FormView):
|
||||
model = ChartNgCell
|
||||
pk_url_kwarg = 'cell'
|
||||
form_class = ChartNgExportForm
|
||||
template_name = 'combo/chartngcell_export_form.html'
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
self.querystring = self.request.GET.copy()
|
||||
self.querystring['export-format'] = form.cleaned_data['export_format']
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return '%s?%s' % (
|
||||
reverse('combo-dataviz-graph', kwargs={'cell': self.object.pk}),
|
||||
self.querystring.urlencode(),
|
||||
)
|
||||
|
||||
|
||||
dataviz_graph_export = DatavizGraphExportView.as_view()
|
||||
|
||||
|
||||
class DatavizChoicesView(DetailView):
|
||||
model = ChartNgCell
|
||||
pk_url_kwarg = 'cell_id'
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
self.cell = self.get_object()
|
||||
|
||||
filter_id = self.kwargs.get('filter_id')
|
||||
for filter_ in self.cell.available_filters:
|
||||
if filter_['id'] == filter_id:
|
||||
self.filter = filter_
|
||||
break
|
||||
else:
|
||||
raise Http404()
|
||||
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
search_term = request.GET.get('term', '')
|
||||
search_term = unicodedata.normalize('NFKC', search_term).casefold()
|
||||
|
||||
try:
|
||||
page_number = int(request.GET.get('page', 1))
|
||||
except ValueError:
|
||||
page_number = 1
|
||||
|
||||
initial = self.cell.filter_params.get(self.filter['id'], self.filter.get('default'))
|
||||
objects = ChartFiltersMixin.get_filter_options(self.cell, self.filter, initial)
|
||||
objects = [x for x in objects if search_term in unicodedata.normalize('NFKC', str(x)).casefold()]
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
'results': self.format_results(objects, (page_number - 1) * 10, page_number * 10),
|
||||
'pagination': {'more': bool(len(objects) >= page_number * 10)},
|
||||
}
|
||||
)
|
||||
|
||||
def format_results(self, objects, start_index, end_index):
|
||||
page_objects = objects[start_index:end_index]
|
||||
|
||||
if start_index > 0:
|
||||
last_displayed_group = objects[start_index - 1].group
|
||||
for option in page_objects:
|
||||
if option.group == last_displayed_group:
|
||||
option.group = None
|
||||
|
||||
return [
|
||||
{'text': group, 'children': [{'id': k, 'text': v} for k, v in choices]}
|
||||
for group, choices in Choice.get_field_choices(page_objects)
|
||||
]
|
||||
|
||||
|
||||
dataviz_choices = DatavizChoicesView.as_view()
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue