Compare commits
163 Commits
e5d3357951
...
ff1e2f9bab
Author | SHA1 | Date |
---|---|---|
Valentin Deniaud | ff1e2f9bab | |
Valentin Deniaud | 4bad6b488b | |
Valentin Deniaud | e8bd91b44e | |
Valentin Deniaud | af473d684e | |
Valentin Deniaud | 6be1d6c5fc | |
Valentin Deniaud | 5e8b58c6ca | |
Valentin Deniaud | b20464820a | |
Lauréline Guérin | 8a10f3eab2 | |
Yann Weber | d9b5247f44 | |
Frédéric Péters | 46e7c037f5 | |
Lauréline Guérin | 10055d8e54 | |
Frédéric Péters | 1a964cd76b | |
Frédéric Péters | a688c183e8 | |
Frédéric Péters | 7df1e3f997 | |
Frédéric Péters | 7b66dca2ba | |
Lauréline Guérin | d964be219e | |
Frédéric Péters | bfdefa73cc | |
Lauréline Guérin | 820bab39b7 | |
Lauréline Guérin | 50cd07545c | |
Frédéric Péters | 1a4be6ec3e | |
Lauréline Guérin | 21407807ad | |
Lauréline Guérin | 741efc0e35 | |
Lauréline Guérin | 370beb3a84 | |
Lauréline Guérin | b20230d34f | |
Lauréline Guérin | 150f6e954f | |
Lauréline Guérin | fd0d9c6fb7 | |
Lauréline Guérin | c1b431922f | |
Lauréline Guérin | 995e3773cf | |
Lauréline Guérin | ea3d41d222 | |
Lauréline Guérin | 8f6ab11272 | |
Lauréline Guérin | 7e43932262 | |
Lauréline Guérin | 81b27151a3 | |
Lauréline Guérin | f74e768a11 | |
Lauréline Guérin | 057c8f49a0 | |
Lauréline Guérin | 9f6ca2c862 | |
Lauréline Guérin | 9480ed89dc | |
Lauréline Guérin | d128ae63be | |
Yann Weber | 61fc9aab9b | |
Yann Weber | c9a4a74417 | |
Yann Weber | 7815d2d00e | |
Yann Weber | d9fd99f1dd | |
Lauréline Guérin | 59c14da940 | |
Lauréline Guérin | 7913efd0ab | |
Lauréline Guérin | f05be333cb | |
Lauréline Guérin | 1f8509f715 | |
Frédéric Péters | e76a113f74 | |
Corentin Sechet | 3a6ebed4db | |
Benjamin Dauvergne | bbb12f507f | |
Benjamin Dauvergne | cb33b47a19 | |
Benjamin Dauvergne | c092e19c77 | |
Benjamin Dauvergne | 78c036b7a2 | |
Frédéric Péters | 5d80833736 | |
Benjamin Dauvergne | 361f0a9bb1 | |
Benjamin Dauvergne | 35e6b79120 | |
Benjamin Dauvergne | b2d06e0234 | |
Benjamin Dauvergne | a5f8140d66 | |
Benjamin Dauvergne | 9f25287a66 | |
Benjamin Dauvergne | 05e615ddc9 | |
Lauréline Guérin | 03361bfbb9 | |
Yann Weber | 1e65e2ea49 | |
Thomas Jund | c02a001384 | |
Thomas Jund | 51d327d72e | |
Yann Weber | ec57e7c060 | |
Benjamin Dauvergne | 7aff4544fc | |
Benjamin Dauvergne | 253ae3863b | |
Benjamin Dauvergne | 8a15bacc72 | |
Frédéric Péters | 5fb21cd6ec | |
Paul Marillonnet | e06ea594d6 | |
Frédéric Péters | 17e7166841 | |
Lauréline Guérin | 59103a7db8 | |
Lauréline Guérin | 32641a04a1 | |
Lauréline Guérin | 97d0fd650e | |
Corentin Sechet | dd877aad7c | |
Frédéric Péters | 9b491b824f | |
Valentin Deniaud | ee51907385 | |
Yann Weber | 12a3d5b392 | |
Benjamin Dauvergne | d51abbf3ee | |
Benjamin Dauvergne | 6fcb6845ba | |
Yann Weber | 8e4d6aa904 | |
Yann Weber | 884cf93fae | |
Yann Weber | 3878028866 | |
Yann Weber | 43a6bb142d | |
Frédéric Péters | dab4a2ae1b | |
Valentin Deniaud | 34ed582d50 | |
Lauréline Guérin | 313660c702 | |
Frédéric Péters | a2ae3a5860 | |
Frédéric Péters | 1d22ba93b0 | |
Lauréline Guérin | 7a362a0193 | |
Frédéric Péters | 77f5ea9ac4 | |
Lauréline Guérin | 335670c2f1 | |
Lauréline Guérin | fe7771f0eb | |
Thomas Jund | b02801ebc8 | |
Thomas Jund | cd88c9e309 | |
Thomas Jund | abda013cc3 | |
Lauréline Guérin | e9bc442738 | |
Lauréline Guérin | 82698fcee9 | |
Lauréline Guérin | b4a12a1825 | |
Lauréline Guérin | d2c5c4fa17 | |
Frédéric Péters | 20a37687d6 | |
Valentin Deniaud | 3cb658b30e | |
Nicolas Roche | f44ab84e46 | |
Lauréline Guérin | 6897b15961 | |
Lauréline Guérin | 9c2dac3060 | |
Lauréline Guérin | dd9497e614 | |
Frédéric Péters | b535376efe | |
Frédéric Péters | e697ef2b6d | |
Valentin Deniaud | 3eb771ddbd | |
Valentin Deniaud | 78f08c8267 | |
Lauréline Guérin | 6ee25f340e | |
Thomas NOËL | 76e0a432a6 | |
Frédéric Péters | 18eeb74ca7 | |
Frédéric Péters | 62df2add65 | |
Frédéric Péters | d1321676f3 | |
Lauréline Guérin | 5b09a4b15a | |
Frédéric Péters | 719c810dff | |
Frédéric Péters | 1c631a36da | |
Lauréline Guérin | 8bb8e53eec | |
Lauréline Guérin | a5474de140 | |
Frédéric Péters | 5a88b7efdc | |
Thomas NOËL | bf65795e9e | |
Emmanuel Cazenave | e38e7eadfa | |
Frédéric Péters | 35fa2fef6a | |
Frédéric Péters | e92a1a72a2 | |
Lauréline Guérin | e6f3a2bdda | |
Frédéric Péters | 32b2bb43e9 | |
Lauréline Guérin | 93dff30566 | |
Lauréline Guérin | e320c819d3 | |
Frédéric Péters | 06ddfb6b7a | |
Frédéric Péters | 914124a66c | |
Lauréline Guérin | 0f02832df5 | |
Frédéric Péters | d8fcad3ed6 | |
Lauréline Guérin | 47fdcc2cd6 | |
Lauréline Guérin | a06c667356 | |
Lauréline Guérin | 5ce17606c4 | |
Lauréline Guérin | 96ca2ec851 | |
Lauréline Guérin | 77c4b473b1 | |
Lauréline Guérin | 5287443cbf | |
Lauréline Guérin | 105cd97ac3 | |
Lauréline Guérin | 3cbae76331 | |
Lauréline Guérin | d519781b74 | |
Lauréline Guérin | cd99705f8c | |
Lauréline Guérin | b916c483f8 | |
Frédéric Péters | bf4cbfe37a | |
Frédéric Péters | 65f8efc1f0 | |
Frédéric Péters | c5f1ffb36e | |
Frédéric Péters | 0198eb2a9a | |
Frédéric Péters | f5ff197858 | |
Frédéric Péters | 2d8bf3a1aa | |
Valentin Deniaud | b7017baf57 | |
Lauréline Guérin | 7f35da936a | |
Frédéric Péters | 22da07f739 | |
Frédéric Péters | 8c0d0dbf43 | |
Lauréline Guérin | 98e74b6da0 | |
Lauréline Guérin | 6dda05741f | |
Lauréline Guérin | c5b835d464 | |
Lauréline Guérin | 4f2c606cd1 | |
Lauréline Guérin | ef0dac26e1 | |
Lauréline Guérin | c0c93aa639 | |
Valentin Deniaud | 4f129777cf | |
Valentin Deniaud | e3832178ee | |
Valentin Deniaud | da721b70f2 | |
Lauréline Guérin | 49f81f55a1 | |
Nicolas Roche | b8f86ae74c |
|
@ -21,3 +21,7 @@ data/themes/gadjo/static/css/agent-portal.css.map
|
|||
.cache
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
node_modules/
|
||||
coverage/
|
||||
package.json
|
||||
package-lock.json
|
||||
|
|
|
@ -15,7 +15,7 @@ pipeline {
|
|||
always {
|
||||
script {
|
||||
utils = new Utils()
|
||||
utils.publish_coverage('coverage.xml')
|
||||
utils.publish_coverage('coverage.xml,coverage/cobertura-coverage.xml')
|
||||
utils.publish_coverage_native('index.html')
|
||||
utils.publish_pylint('pylint.out')
|
||||
}
|
||||
|
|
|
@ -14,12 +14,26 @@
|
|||
# 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
|
||||
|
||||
|
||||
class AssetUploadForm(forms.Form):
|
||||
upload = forms.FileField(label=_('File'))
|
||||
upload = forms.FileField(label=_('File'), validators=[validate_asset_file])
|
||||
|
||||
|
||||
class AssetsImportForm(forms.Form):
|
||||
|
|
|
@ -23,10 +23,28 @@ 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))
|
||||
|
||||
|
@ -59,6 +77,8 @@ def untar_assets_files(tar, overwrite=False):
|
|||
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()}
|
||||
|
|
|
@ -196,6 +196,9 @@ class AssetOverwrite(FormView):
|
|||
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']
|
||||
|
||||
|
@ -249,6 +252,9 @@ class AssetDelete(TemplateView):
|
|||
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)))
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# 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'),
|
||||
),
|
||||
]
|
|
@ -57,7 +57,7 @@ class ChartForm(forms.ModelForm):
|
|||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
available_charts = []
|
||||
for site_dict in settings.KNOWN_SERVICES.get('bijoe').values():
|
||||
for site_dict in (settings.KNOWN_SERVICES.get('bijoe') or {}).values():
|
||||
result = requests.get(
|
||||
'/visualization/json/',
|
||||
remote_service=site_dict,
|
||||
|
@ -214,6 +214,7 @@ class ChartNgForm(ChartFiltersMixin, forms.ModelForm):
|
|||
'time_range_start_template',
|
||||
'time_range_end_template',
|
||||
'chart_type',
|
||||
'display_total',
|
||||
'height',
|
||||
'sort_order',
|
||||
'hide_null_values',
|
||||
|
@ -245,6 +246,7 @@ class ChartNgForm(ChartFiltersMixin, forms.ModelForm):
|
|||
'time_range_end',
|
||||
'time_range_start_template',
|
||||
'time_range_end_template',
|
||||
'display_total',
|
||||
):
|
||||
del self.fields[field]
|
||||
else:
|
||||
|
@ -256,6 +258,9 @@ class ChartNgForm(ChartFiltersMixin, forms.ModelForm):
|
|||
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():
|
||||
|
@ -374,7 +379,12 @@ class ChartFiltersForm(ChartFiltersMixin, forms.ModelForm):
|
|||
filters_cell_id = kwargs.pop('filters_cell_id', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
chart_cells = list(ChartNgCell.objects.filter(page=page, statistic__isnull=False).order_by('order'))
|
||||
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
|
||||
|
@ -487,3 +497,13 @@ class ChartFiltersConfigForm(forms.ModelForm):
|
|||
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)')),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
# 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'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,27 @@
|
|||
# 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',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -17,6 +17,7 @@
|
|||
import copy
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
from collections import OrderedDict
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
|
@ -32,6 +33,7 @@ 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 _
|
||||
|
@ -244,6 +246,17 @@ class ChartNgCell(CellBase):
|
|||
('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'),
|
||||
|
@ -288,6 +301,7 @@ class ChartNgCell(CellBase):
|
|||
|
||||
class Media:
|
||||
js = ('js/chartngcell.js',)
|
||||
css = {'all': ('css/combo.chartngcell.css',)}
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
|
@ -301,9 +315,16 @@ class ChartNgCell(CellBase):
|
|||
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
|
||||
|
@ -323,9 +344,17 @@ class ChartNgCell(CellBase):
|
|||
)
|
||||
|
||||
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,
|
||||
|
@ -363,6 +392,8 @@ class ChartNgCell(CellBase):
|
|||
|
||||
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']
|
||||
|
@ -378,10 +409,10 @@ class ChartNgCell(CellBase):
|
|||
|
||||
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)
|
||||
chart.compute_sum = False
|
||||
|
||||
if chart.axis_count == 1:
|
||||
data['series'][0]['data'] = self.process_one_dimensional_data(
|
||||
|
@ -401,6 +432,10 @@ class ChartNgCell(CellBase):
|
|||
|
||||
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
|
||||
|
@ -596,8 +631,6 @@ class ChartNgCell(CellBase):
|
|||
data = self.hide_values(chart, data)
|
||||
if data and self.sort_order != 'none':
|
||||
data = self.sort_values(chart, data)
|
||||
if getattr(chart, 'compute_sum', True) and self.chart_type in ('table', 'table-inverted'):
|
||||
data = self.add_total_to_line_table(chart, data)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
|
@ -642,6 +675,28 @@ class ChartNgCell(CellBase):
|
|||
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 != 'pie':
|
||||
series_data = []
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
.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,13 +1,15 @@
|
|||
{% load i18n %}
|
||||
{% if cell.title %}<h2>{{cell.title}}</h2>{% endif %}
|
||||
{% if cell.chart_type == "table" or cell.chart_type == "table-inverted" %}
|
||||
<div id="chart-{{cell.id}}" class="dataviz-table"></div>
|
||||
{% 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 'combo-dataviz-graph' cell=cell.id %}" + get_graph_querystring(extra_context),
|
||||
url : url,
|
||||
type: 'GET',
|
||||
success: function(data) {
|
||||
$('#chart-{{cell.id}}').html(data);
|
||||
|
@ -29,13 +31,41 @@
|
|||
var new_width = Math.floor($(chart_cell).width());
|
||||
var ratio = new_width / last_width;
|
||||
if (ratio > 1.2 || ratio < 0.8) {
|
||||
$('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + get_graph_querystring(extra_context, new_width));
|
||||
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() {
|
||||
$('#chart-{{cell.id}}').attr('src', "{% url 'combo-dataviz-graph' cell=cell.id %}" + get_graph_querystring(extra_context, last_width));
|
||||
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>
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
{% 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 %}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<div style="position: relative">
|
||||
{{ form|with_template }}
|
||||
{% if cell.statistic and cell.chart_type != "table" and cell.chart_type != "table-inverted" %}
|
||||
{% 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>
|
||||
|
|
|
@ -19,11 +19,14 @@ from django.urls import path
|
|||
|
||||
from combo.urls_utils import manager_required
|
||||
|
||||
from .views import ajax_gauge_count, dataviz_choices, dataviz_graph
|
||||
from .views import ajax_gauge_count, dataviz_choices, dataviz_graph, dataviz_graph_export
|
||||
|
||||
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),
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
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']
|
||||
|
@ -22,15 +29,19 @@ def update_available_statistics():
|
|||
url = '/visualization/json/' if provider == 'bijoe' else '/api/statistics/'
|
||||
|
||||
for site_key, site_dict in sites.items():
|
||||
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'},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
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:
|
||||
|
@ -89,4 +100,23 @@ def update_available_statistics():
|
|||
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
|
||||
)
|
||||
|
|
|
@ -14,22 +14,25 @@
|
|||
# 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
|
||||
|
||||
import pyexcel_ods
|
||||
from django.core import signing
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
|
||||
from django.shortcuts import render
|
||||
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
|
||||
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, ChartNgPartialForm, Choice
|
||||
from .forms import ChartFiltersMixin, ChartNgExportForm, ChartNgPartialForm, Choice
|
||||
from .models import ChartNgCell, Gauge, MissingVariable, UnsupportedDataSet
|
||||
|
||||
|
||||
|
@ -94,7 +97,13 @@ class DatavizGraphView(DetailView):
|
|||
if self.filters_cell_id and self.cell.statistic.service_slug != 'bijoe':
|
||||
self.update_subfilters_cache(form.instance)
|
||||
|
||||
if self.cell.chart_type in ('table', 'table-inverted'):
|
||||
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'))
|
||||
|
||||
|
@ -103,7 +112,7 @@ class DatavizGraphView(DetailView):
|
|||
|
||||
rendered = chart.render_table(
|
||||
transpose=bool(self.cell.chart_type == 'table-inverted'),
|
||||
total=getattr(chart, 'compute_sum', True),
|
||||
total=bool(self.cell.statistic.service_slug == 'bijoe' and chart.compute_sum),
|
||||
)
|
||||
rendered = rendered.replace('<table>', '<table class="main">')
|
||||
return HttpResponse(rendered)
|
||||
|
@ -111,7 +120,7 @@ class DatavizGraphView(DetailView):
|
|||
return HttpResponse(chart.render(), content_type='image/svg+xml')
|
||||
|
||||
def error(self, error_text):
|
||||
if self.cell.chart_type in ('table', 'table-inverted'):
|
||||
if self.cell.is_table_chart():
|
||||
return HttpResponse('<p>%s</p>' % error_text)
|
||||
|
||||
context = {
|
||||
|
@ -130,10 +139,59 @@ class DatavizGraphView(DetailView):
|
|||
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'
|
||||
|
|
|
@ -0,0 +1,381 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2017-2023 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 tarfile
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import permissions
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from combo.apps.export_import.models import Application, ApplicationAsyncJob, ApplicationElement
|
||||
from combo.apps.wcs.utils import WCSError
|
||||
from combo.data.models import Page, PageSnapshot
|
||||
from combo.utils.api import APIErrorBadRequest
|
||||
from combo.utils.misc import is_portal_agent
|
||||
|
||||
klasses = {klass.application_component_type: klass for klass in [Page]}
|
||||
klasses['roles'] = Group
|
||||
|
||||
|
||||
class Index(GenericAPIView):
|
||||
permission_classes = (permissions.IsAdminUser,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if is_portal_agent():
|
||||
response = [
|
||||
{
|
||||
'id': 'portal-agent-pages',
|
||||
'text': _('Pages (agent portal)'),
|
||||
'singular': _('Page (agent portal)'),
|
||||
},
|
||||
]
|
||||
else:
|
||||
response = [
|
||||
{'id': 'pages', 'text': _('Pages'), 'singular': _('Page')},
|
||||
]
|
||||
|
||||
response[0]['urls'] = {
|
||||
'list': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-components-list',
|
||||
kwargs={'component_type': Page.application_component_type},
|
||||
)
|
||||
),
|
||||
}
|
||||
response.append(
|
||||
{
|
||||
'id': 'roles',
|
||||
'text': _('Roles'),
|
||||
'singular': _('Role'),
|
||||
'urls': {
|
||||
'list': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-components-list',
|
||||
kwargs={'component_type': 'roles'},
|
||||
)
|
||||
),
|
||||
},
|
||||
'minor': True,
|
||||
}
|
||||
)
|
||||
|
||||
return Response({'data': response})
|
||||
|
||||
|
||||
index = Index.as_view()
|
||||
|
||||
|
||||
def get_component_bundle_entry(request, component, order):
|
||||
if isinstance(component, Group):
|
||||
return {
|
||||
'id': component.role.slug if hasattr(component, 'role') else component.id,
|
||||
'text': component.name,
|
||||
'type': 'roles',
|
||||
'urls': {},
|
||||
# include uuid in object reference, this is not used for applification API but is useful
|
||||
# for authentic creating its role summary page.
|
||||
'uuid': component.role.uuid if hasattr(component, 'role') else None,
|
||||
}
|
||||
return {
|
||||
'id': str(component.uuid),
|
||||
'text': component.title,
|
||||
'indent': getattr(component, 'level', 0),
|
||||
'type': 'portal-agent-pages' if is_portal_agent() else 'pages',
|
||||
'order': order,
|
||||
'urls': {
|
||||
'export': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-component-export',
|
||||
kwargs={'uuid': str(component.uuid), 'component_type': Page.application_component_type},
|
||||
)
|
||||
),
|
||||
'dependencies': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-component-dependencies',
|
||||
kwargs={'uuid': str(component.uuid), 'component_type': Page.application_component_type},
|
||||
)
|
||||
),
|
||||
'redirect': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-component-redirect',
|
||||
kwargs={'uuid': str(component.uuid), 'component_type': Page.application_component_type},
|
||||
)
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ListComponents(GenericAPIView):
|
||||
permission_classes = (permissions.IsAdminUser,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
klass = klasses[kwargs['component_type']]
|
||||
if klass == Page:
|
||||
components = Page.get_as_reordered_flat_hierarchy(Page.objects.all())
|
||||
elif klass == Group:
|
||||
components = Group.objects.order_by('name')
|
||||
else:
|
||||
raise Http404
|
||||
response = [get_component_bundle_entry(request, x, i) for i, x in enumerate(components)]
|
||||
return Response({'data': response})
|
||||
|
||||
|
||||
list_components = ListComponents.as_view()
|
||||
|
||||
|
||||
class ExportComponent(GenericAPIView):
|
||||
permission_classes = (permissions.IsAdminUser,)
|
||||
|
||||
def get(self, request, uuid, *args, **kwargs):
|
||||
serialisation = get_object_or_404(Page, uuid=uuid).get_serialized_page()
|
||||
return Response({'data': serialisation})
|
||||
|
||||
|
||||
export_component = ExportComponent.as_view()
|
||||
|
||||
|
||||
class ComponentDependencies(GenericAPIView):
|
||||
permission_classes = (permissions.IsAdminUser,)
|
||||
|
||||
def get(self, request, uuid, *args, **kwargs):
|
||||
klass = klasses[kwargs['component_type']]
|
||||
component = get_object_or_404(klass, uuid=uuid)
|
||||
|
||||
def dependency_dict(component):
|
||||
if isinstance(component, dict):
|
||||
return component
|
||||
return get_component_bundle_entry(request, component, 0)
|
||||
|
||||
try:
|
||||
dependencies = [dependency_dict(x) for x in component.get_dependencies() if x]
|
||||
except WCSError as e:
|
||||
return Response({'err': 1, 'err_desc': str(e)}, status=400)
|
||||
return Response({'err': 0, 'data': dependencies})
|
||||
|
||||
|
||||
component_dependencies = ComponentDependencies.as_view()
|
||||
|
||||
|
||||
def component_redirect(request, component_type, uuid):
|
||||
klass = klasses[component_type]
|
||||
page = get_object_or_404(klass, uuid=uuid)
|
||||
if klass == Page:
|
||||
url = reverse('combo-manager-page-view', kwargs={'pk': page.pk})
|
||||
if (
|
||||
'compare' in request.GET
|
||||
and request.GET.get('application')
|
||||
and request.GET.get('version1')
|
||||
and request.GET.get('version2')
|
||||
):
|
||||
url = '%s?version1=%s&version2=%s&application=%s' % (
|
||||
reverse('combo-manager-page-history-compare', args=[page.pk]),
|
||||
request.GET['version1'],
|
||||
request.GET['version2'],
|
||||
request.GET['application'],
|
||||
)
|
||||
return redirect(url)
|
||||
raise Http404
|
||||
|
||||
|
||||
class BundleCheck(GenericAPIView):
|
||||
permission_classes = (permissions.IsAdminUser,)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
bundle = request.FILES['bundle']
|
||||
page_type = 'portal-agent-pages' if is_portal_agent() else 'pages'
|
||||
try:
|
||||
with tarfile.open(fileobj=bundle) as tar:
|
||||
try:
|
||||
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
|
||||
except KeyError:
|
||||
raise APIErrorBadRequest(_('Invalid tar file, missing manifest'))
|
||||
application_slug = manifest.get('slug')
|
||||
application_version = manifest.get('version_number')
|
||||
if not application_slug or not application_version:
|
||||
return Response({'data': {}})
|
||||
|
||||
differences = []
|
||||
unknown_elements = []
|
||||
no_history_elements = []
|
||||
legacy_elements = []
|
||||
content_type = ContentType.objects.get_for_model(Page)
|
||||
for element in manifest.get('elements'):
|
||||
if element.get('type') != page_type:
|
||||
continue
|
||||
try:
|
||||
page = Page.objects.get(uuid=element['slug'])
|
||||
except Page.DoesNotExist:
|
||||
unknown_elements.append(
|
||||
{
|
||||
'type': element['type'],
|
||||
'slug': element['slug'],
|
||||
}
|
||||
)
|
||||
continue
|
||||
elements_qs = ApplicationElement.objects.filter(
|
||||
application__slug=application_slug,
|
||||
content_type=content_type,
|
||||
object_id=page.pk,
|
||||
)
|
||||
if not elements_qs.exists():
|
||||
# object exists, but not linked to the application
|
||||
legacy_elements.append(
|
||||
{
|
||||
'type': element['type'],
|
||||
'slug': element['slug'],
|
||||
# information needed here, Relation objects may not exist yet in hobo
|
||||
'text': page.title,
|
||||
'url': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-export-import-component-redirect',
|
||||
kwargs={
|
||||
'uuid': str(page.uuid),
|
||||
'component_type': page.application_component_type,
|
||||
},
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
snapshot_for_app = (
|
||||
PageSnapshot.objects.filter(
|
||||
page=page,
|
||||
application_slug=application_slug,
|
||||
application_version=application_version,
|
||||
)
|
||||
.order_by('timestamp')
|
||||
.last()
|
||||
)
|
||||
if not snapshot_for_app:
|
||||
# no snapshot for this bundle
|
||||
no_history_elements.append(
|
||||
{
|
||||
'type': element['type'],
|
||||
'slug': element['slug'],
|
||||
}
|
||||
)
|
||||
continue
|
||||
last_snapshot = PageSnapshot.objects.filter(page=page).latest('timestamp')
|
||||
if snapshot_for_app.pk != last_snapshot.pk:
|
||||
differences.append(
|
||||
{
|
||||
'type': element['type'],
|
||||
'slug': element['slug'],
|
||||
'url': '%s%s?version1=%s&version2=%s'
|
||||
% (
|
||||
request.build_absolute_uri('/')[:-1],
|
||||
reverse('combo-manager-page-history-compare', args=[page.pk]),
|
||||
snapshot_for_app.pk,
|
||||
last_snapshot.pk,
|
||||
),
|
||||
}
|
||||
)
|
||||
except tarfile.TarError:
|
||||
raise APIErrorBadRequest(_('Invalid tar file'))
|
||||
|
||||
return Response(
|
||||
{
|
||||
'data': {
|
||||
'differences': differences,
|
||||
'unknown_elements': unknown_elements,
|
||||
'no_history_elements': no_history_elements,
|
||||
'legacy_elements': legacy_elements,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
bundle_check = BundleCheck.as_view()
|
||||
|
||||
|
||||
class BundleImport(GenericAPIView):
|
||||
permission_classes = (permissions.IsAdminUser,)
|
||||
action = 'import_bundle'
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
bundle = request.FILES['bundle']
|
||||
try:
|
||||
with tarfile.open(fileobj=bundle) as tar:
|
||||
try:
|
||||
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
|
||||
except KeyError:
|
||||
raise APIErrorBadRequest(_('Invalid tar file, missing manifest'))
|
||||
application_slug = manifest.get('slug')
|
||||
except tarfile.TarError:
|
||||
raise APIErrorBadRequest(_('Invalid tar file'))
|
||||
job = ApplicationAsyncJob(
|
||||
action=self.action,
|
||||
)
|
||||
job.bundle.save('%s.tar' % application_slug, content=bundle)
|
||||
job.save()
|
||||
job.run(spool=True)
|
||||
return Response({'err': 0, 'url': job.get_api_status_url(request)})
|
||||
|
||||
|
||||
bundle_import = BundleImport.as_view()
|
||||
|
||||
|
||||
class BundleDeclare(BundleImport):
|
||||
action = 'declare_bundle'
|
||||
|
||||
|
||||
bundle_declare = BundleDeclare.as_view()
|
||||
|
||||
|
||||
class BundleUnlink(GenericAPIView):
|
||||
permission_classes = (permissions.IsAdminUser,)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if request.POST.get('application'):
|
||||
try:
|
||||
application = Application.objects.get(slug=request.POST['application'])
|
||||
except Application.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
application.delete()
|
||||
|
||||
return Response({'err': 0})
|
||||
|
||||
|
||||
bundle_unlink = BundleUnlink.as_view()
|
||||
|
||||
|
||||
class JobStatus(GenericAPIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
job = get_object_or_404(ApplicationAsyncJob, uuid=kwargs['job_uuid'])
|
||||
return Response(
|
||||
{
|
||||
'err': 0,
|
||||
'data': {
|
||||
'status': job.status,
|
||||
'creation_time': job.creation_timestamp,
|
||||
'completion_time': job.completion_timestamp,
|
||||
'completion_status': job.get_completion_status(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
job_status = JobStatus.as_view()
|
|
@ -0,0 +1,33 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2017-2023 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.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class AppConfig(django.apps.AppConfig):
|
||||
name = 'combo.apps.export_import'
|
||||
verbose_name = _('Export/Import')
|
||||
|
||||
def get_before_urls(self):
|
||||
from . import urls
|
||||
|
||||
return urls.urlpatterns
|
||||
|
||||
def hourly(self):
|
||||
from combo.apps.export_import.models import ApplicationAsyncJob
|
||||
|
||||
ApplicationAsyncJob.clean_jobs()
|
|
@ -0,0 +1,7 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = []
|
||||
|
||||
operations = []
|
|
@ -0,0 +1,61 @@
|
|||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('export_import', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Application',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('slug', models.SlugField(max_length=100, unique=True)),
|
||||
('icon', models.FileField(blank=True, null=True, upload_to='applications/icons/')),
|
||||
('description', models.TextField(blank=True)),
|
||||
('documentation_url', models.URLField(blank=True)),
|
||||
('version_number', models.CharField(max_length=100)),
|
||||
('version_notes', models.TextField(blank=True)),
|
||||
('editable', models.BooleanField(default=True)),
|
||||
('visible', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ApplicationElement',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
'application',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to='export_import.application'
|
||||
),
|
||||
),
|
||||
(
|
||||
'content_type',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('application', 'content_type', 'object_id')},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,50 @@
|
|||
import uuid
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import combo.apps.export_import.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('export_import', '0002_application'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ApplicationAsyncJob',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
(
|
||||
'status',
|
||||
models.CharField(
|
||||
choices=[
|
||||
('registered', 'Registered'),
|
||||
('running', 'Running'),
|
||||
('failed', 'Failed'),
|
||||
('completed', 'Completed'),
|
||||
],
|
||||
default='registered',
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
('exception', models.TextField()),
|
||||
('action', models.CharField(max_length=100)),
|
||||
(
|
||||
'bundle',
|
||||
models.FileField(
|
||||
blank=True, null=True, upload_to=combo.apps.export_import.models.upload_to_job_uuid
|
||||
),
|
||||
),
|
||||
('total_count', models.PositiveIntegerField(default=0)),
|
||||
('current_count', models.PositiveIntegerField(default=0)),
|
||||
('creation_timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('last_update_timestamp', models.DateTimeField(auto_now=True)),
|
||||
('completion_timestamp', models.DateTimeField(default=None, null=True)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,414 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2017-2023 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 collections
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from combo.utils.misc import is_portal_agent
|
||||
|
||||
|
||||
class BundleKeyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Application(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(max_length=100, unique=True)
|
||||
icon = models.FileField(
|
||||
upload_to='applications/icons/',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
documentation_url = models.URLField(blank=True)
|
||||
version_number = models.CharField(max_length=100)
|
||||
version_notes = models.TextField(blank=True)
|
||||
editable = models.BooleanField(default=True)
|
||||
visible = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
|
||||
@classmethod
|
||||
def update_or_create_from_manifest(cls, manifest, tar, editable=False):
|
||||
application, dummy = cls.objects.get_or_create(
|
||||
slug=manifest.get('slug'), defaults={'editable': editable}
|
||||
)
|
||||
application.name = manifest.get('application')
|
||||
application.description = manifest.get('description') or ''
|
||||
application.documentation_url = manifest.get('documentation_url') or ''
|
||||
application.version_number = manifest.get('version_number') or 'unknown'
|
||||
application.version_notes = manifest.get('version_notes') or ''
|
||||
if not editable:
|
||||
application.editable = editable
|
||||
application.visible = manifest.get('visible', True)
|
||||
application.save()
|
||||
icon = manifest.get('icon')
|
||||
if icon:
|
||||
application.icon.save(icon, tar.extractfile(icon), save=True)
|
||||
else:
|
||||
application.icon.delete()
|
||||
return application
|
||||
|
||||
@classmethod
|
||||
def select_for_object_class(cls, object_class):
|
||||
content_type = ContentType.objects.get_for_model(object_class)
|
||||
elements = ApplicationElement.objects.filter(content_type=content_type)
|
||||
return cls.objects.filter(pk__in=elements.values('application'), visible=True).order_by('name')
|
||||
|
||||
@classmethod
|
||||
def populate_objects(cls, object_class, objects):
|
||||
content_type = ContentType.objects.get_for_model(object_class)
|
||||
elements = ApplicationElement.objects.filter(
|
||||
content_type=content_type, application__visible=True
|
||||
).prefetch_related('application')
|
||||
elements_by_objects = collections.defaultdict(list)
|
||||
for element in elements:
|
||||
elements_by_objects[element.object_id].append(element)
|
||||
for obj in objects:
|
||||
applications = [element.application for element in elements_by_objects.get(obj.pk) or []]
|
||||
obj._applications = sorted(applications, key=lambda a: a.name)
|
||||
|
||||
@classmethod
|
||||
def load_for_object(cls, obj):
|
||||
content_type = ContentType.objects.get_for_model(obj.__class__)
|
||||
elements = ApplicationElement.objects.filter(
|
||||
content_type=content_type, object_id=obj.pk, application__visible=True
|
||||
).prefetch_related('application')
|
||||
applications = [element.application for element in elements]
|
||||
obj._applications = sorted(applications, key=lambda a: a.name)
|
||||
|
||||
def get_objects_for_object_class(self, object_class):
|
||||
content_type = ContentType.objects.get_for_model(object_class)
|
||||
elements = ApplicationElement.objects.filter(content_type=content_type, application=self)
|
||||
return object_class.objects.filter(pk__in=elements.values('object_id'))
|
||||
|
||||
@classmethod
|
||||
def get_orphan_objects_for_object_class(cls, object_class):
|
||||
content_type = ContentType.objects.get_for_model(object_class)
|
||||
elements = ApplicationElement.objects.filter(content_type=content_type, application__visible=True)
|
||||
return object_class.objects.exclude(pk__in=elements.values('object_id'))
|
||||
|
||||
|
||||
class ApplicationElement(models.Model):
|
||||
application = models.ForeignKey(Application, on_delete=models.CASCADE)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['application', 'content_type', 'object_id']
|
||||
|
||||
@classmethod
|
||||
def update_or_create_for_object(cls, application, obj):
|
||||
content_type = ContentType.objects.get_for_model(obj.__class__)
|
||||
element, created = cls.objects.get_or_create(
|
||||
application=application,
|
||||
content_type=content_type,
|
||||
object_id=obj.pk,
|
||||
)
|
||||
if not created:
|
||||
element.save()
|
||||
return element
|
||||
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('registered', _('Registered')),
|
||||
('running', _('Running')),
|
||||
('failed', _('Failed')),
|
||||
('completed', _('Completed')),
|
||||
]
|
||||
|
||||
|
||||
def upload_to_job_uuid(instance, filename):
|
||||
return f'applications/bundles/{instance.uuid}/{filename}'
|
||||
|
||||
|
||||
class ApplicationAsyncJob(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
status = models.CharField(
|
||||
max_length=100,
|
||||
default='registered',
|
||||
choices=STATUS_CHOICES,
|
||||
)
|
||||
exception = models.TextField()
|
||||
action = models.CharField(max_length=100)
|
||||
bundle = models.FileField(
|
||||
upload_to=upload_to_job_uuid,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
total_count = models.PositiveIntegerField(default=0)
|
||||
current_count = models.PositiveIntegerField(default=0)
|
||||
|
||||
creation_timestamp = models.DateTimeField(auto_now_add=True)
|
||||
last_update_timestamp = models.DateTimeField(auto_now=True)
|
||||
completion_timestamp = models.DateTimeField(default=None, null=True)
|
||||
|
||||
def run(self, spool=False):
|
||||
if 'uwsgi' in sys.modules and spool:
|
||||
from combo.utils.spooler import run_async_job
|
||||
|
||||
run_async_job(job_id=str(self.pk))
|
||||
return
|
||||
self.status = 'running'
|
||||
self.save()
|
||||
try:
|
||||
getattr(self, self.action)()
|
||||
except BundleKeyError as e:
|
||||
self.status = 'failed'
|
||||
self.exception = str(e)
|
||||
except Exception:
|
||||
self.status = 'failed'
|
||||
self.exception = traceback.format_exc()
|
||||
finally:
|
||||
if self.status == 'running':
|
||||
self.status = 'completed'
|
||||
self.completion_timestamp = now()
|
||||
self.save()
|
||||
|
||||
def process_bundle(self, install=True):
|
||||
page_type = 'portal-agent-pages' if is_portal_agent() else 'pages'
|
||||
pages = []
|
||||
tar_io = io.BytesIO(self.bundle.read())
|
||||
with tarfile.open(fileobj=tar_io) as tar:
|
||||
manifest = json.loads(tar.extractfile('manifest.json').read().decode())
|
||||
self.application = Application.update_or_create_from_manifest(
|
||||
manifest,
|
||||
tar,
|
||||
editable=not install,
|
||||
)
|
||||
|
||||
# count number of actions
|
||||
self.total_count = len([x for x in manifest.get('elements') if x.get('type') == page_type])
|
||||
|
||||
for element in manifest.get('elements'):
|
||||
if element.get('type') != page_type:
|
||||
continue
|
||||
try:
|
||||
pages.append(
|
||||
json.loads(tar.extractfile(f'{page_type}/{element["slug"]}').read().decode()).get(
|
||||
'data'
|
||||
)
|
||||
)
|
||||
except KeyError:
|
||||
raise BundleKeyError(
|
||||
'Invalid tar file, missing component %s/%s.' % (page_type, element['slug'])
|
||||
)
|
||||
|
||||
# init cache of application elements, from manifest
|
||||
self.application_elements = set()
|
||||
# install pages
|
||||
if install and pages:
|
||||
self._import_site(pages)
|
||||
# create application elements
|
||||
self.link_objects(pages, increment=not install)
|
||||
# remove obsolete application elements
|
||||
self.unlink_obsolete_objects()
|
||||
|
||||
def _import_site(self, pages):
|
||||
from combo.data.models import Page
|
||||
from combo.data.utils import import_site
|
||||
|
||||
# keep pages positions (order and parent) before import
|
||||
initial_positions = {
|
||||
str(p.uuid): (str(p.parent.uuid) if p.parent else None, p.order) for p in Page.objects.all()
|
||||
}
|
||||
|
||||
# keep positions (order and parent) of imported pages
|
||||
imported_positions = {
|
||||
p['fields']['uuid']: (
|
||||
p['fields']['parent'][0] if p['fields']['parent'] else None,
|
||||
p['fields']['order'],
|
||||
)
|
||||
for p in pages
|
||||
}
|
||||
|
||||
# import pages
|
||||
import_site({'pages': pages}, job=self)
|
||||
|
||||
# rebuild page positions: first set parents, and then set orders
|
||||
objects_by_uuid = {str(p.uuid): p for p in Page.objects.all()}
|
||||
objects_by_uuid[None] = None
|
||||
|
||||
# set parents of imported pages
|
||||
for page_uuid, (parent_uuid, order) in imported_positions.items():
|
||||
if page_uuid in initial_positions:
|
||||
# page was already deployed, keep parent initially set on this instance
|
||||
objects_by_uuid[page_uuid].parent = objects_by_uuid[initial_positions[page_uuid][0]]
|
||||
objects_by_uuid[page_uuid].save()
|
||||
continue
|
||||
|
||||
# page is newly deployed, set parent
|
||||
# search siblings in the application
|
||||
siblings = [k for k, v in imported_positions.items() if k != page_uuid and v[0] == parent_uuid]
|
||||
# look at siblings parents before the import, but only parents outside in the application
|
||||
parents = {
|
||||
v[0] for k, v in initial_positions.items() if k in siblings and v[0] not in imported_positions
|
||||
}
|
||||
if not parents or len(parents) > 1:
|
||||
# no parents outside the application: no change, parent is already correctly set by the import
|
||||
# more than one parent outside the application: can not decide which one to take; no change, keep parents set by the import
|
||||
continue
|
||||
# all siblings at the same place, set page under siblings parent
|
||||
parent = list(parents)[0]
|
||||
objects_by_uuid[page_uuid].parent = objects_by_uuid[parent]
|
||||
objects_by_uuid[page_uuid].save()
|
||||
|
||||
# and set orders
|
||||
objects_by_uuid.pop(None)
|
||||
|
||||
# find imported pages and orders from initials
|
||||
existing_positions = {k: v[1] for k, v in initial_positions.items() if k in imported_positions}
|
||||
# find not imported pages and orders from initials
|
||||
not_imported_positions = {
|
||||
k: v[1] for k, v in initial_positions.items() if k not in imported_positions
|
||||
}
|
||||
|
||||
def order_children(parent):
|
||||
# find children of the parent
|
||||
children = [k for k, v in objects_by_uuid.items() if v.parent == parent]
|
||||
# find children and positions in the application
|
||||
application_children = {k: imported_positions[k][1] for k in children if k in imported_positions}
|
||||
# find imported children and initial positions
|
||||
children_existing_positions = {
|
||||
k: v for k, v in existing_positions.items() if k in application_children
|
||||
}
|
||||
# find not imported children and initial positions
|
||||
children_not_imported_positions = {
|
||||
k: v
|
||||
for k, v in not_imported_positions.items()
|
||||
if k not in application_children and k in children
|
||||
}
|
||||
# determine position of application pages
|
||||
application_position = None
|
||||
if children_existing_positions:
|
||||
application_position = min(children_existing_positions.values())
|
||||
# all children placed before application pages
|
||||
before_positions = {
|
||||
k: v
|
||||
for k, v in children_not_imported_positions.items()
|
||||
if application_position is None or v < application_position
|
||||
}
|
||||
# all children placed after application pages
|
||||
after_positions = {
|
||||
k: v
|
||||
for k, v in children_not_imported_positions.items()
|
||||
if application_position is not None and v >= application_position
|
||||
}
|
||||
# sort children
|
||||
ordered_children = [
|
||||
objects_by_uuid[u] for u in sorted(before_positions, key=lambda a: before_positions[a])
|
||||
]
|
||||
ordered_children += [
|
||||
objects_by_uuid[u]
|
||||
for u in sorted(application_children, key=lambda a: application_children[a])
|
||||
]
|
||||
ordered_children += [
|
||||
objects_by_uuid[u] for u in sorted(after_positions, key=lambda a: after_positions[a])
|
||||
]
|
||||
for child in ordered_children:
|
||||
# yield child
|
||||
yield child
|
||||
# and children of this child
|
||||
yield from order_children(child)
|
||||
|
||||
ordered_pages = list(order_children(None))
|
||||
order = 1
|
||||
for page in ordered_pages:
|
||||
page.order = order
|
||||
page.save()
|
||||
order += 1
|
||||
|
||||
def import_bundle(self):
|
||||
self.process_bundle()
|
||||
|
||||
def declare_bundle(self):
|
||||
self.process_bundle(install=False)
|
||||
|
||||
def link_objects(self, pages, increment=False):
|
||||
from combo.data.models import Page, PageSnapshot
|
||||
|
||||
for page in pages:
|
||||
page_uuid = page['fields']['uuid']
|
||||
try:
|
||||
existing_page = Page.objects.get(uuid=page_uuid)
|
||||
except Page.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
element = ApplicationElement.update_or_create_for_object(self.application, existing_page)
|
||||
self.application_elements.add(element.content_object)
|
||||
if self.action == 'import_bundle':
|
||||
PageSnapshot.take(
|
||||
existing_page,
|
||||
comment=_('Application (%s)') % self.application,
|
||||
application=self.application,
|
||||
)
|
||||
if increment:
|
||||
self.increment_count()
|
||||
|
||||
def unlink_obsolete_objects(self):
|
||||
known_elements = ApplicationElement.objects.filter(application=self.application)
|
||||
for element in known_elements:
|
||||
if element.content_object not in self.application_elements:
|
||||
element.delete()
|
||||
|
||||
def increment_count(self, amount=1):
|
||||
self.current_count = (self.current_count or 0) + amount
|
||||
if (now() - self.last_update_timestamp).total_seconds() > 1:
|
||||
self.save()
|
||||
|
||||
def get_api_status_url(self, request):
|
||||
return request.build_absolute_uri(reverse('api-export-import-job-status', args=[self.uuid]))
|
||||
|
||||
def get_completion_status(self):
|
||||
current_count = self.current_count or 0
|
||||
|
||||
if not current_count:
|
||||
return ''
|
||||
|
||||
if not self.total_count:
|
||||
return _('%(current_count)s (unknown total)') % {'current_count': current_count}
|
||||
|
||||
return _('%(current_count)s/%(total_count)s (%(percent)s%%)') % {
|
||||
'current_count': int(current_count),
|
||||
'total_count': self.total_count,
|
||||
'percent': int(current_count * 100 / self.total_count),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def clean_jobs(cls):
|
||||
# remove jobs after 7 days
|
||||
for job in cls.objects.filter(last_update_timestamp__lte=now() - datetime.timedelta(days=7)):
|
||||
job.bundle.delete(save=False)
|
||||
job.delete()
|
|
@ -0,0 +1,52 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2017-2023 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.urls import path
|
||||
|
||||
from . import api_views
|
||||
|
||||
urlpatterns = [
|
||||
path('api/export-import/', api_views.index, name='api-export-import'),
|
||||
path('api/export-import/bundle-check/', api_views.bundle_check),
|
||||
path('api/export-import/bundle-declare/', api_views.bundle_declare),
|
||||
path('api/export-import/bundle-import/', api_views.bundle_import),
|
||||
path('api/export-import/unlink/', api_views.bundle_unlink),
|
||||
path(
|
||||
'api/export-import/<slug:component_type>/',
|
||||
api_views.list_components,
|
||||
name='api-export-import-components-list',
|
||||
),
|
||||
path(
|
||||
'api/export-import/<slug:component_type>/<uuid:uuid>/',
|
||||
api_views.export_component,
|
||||
name='api-export-import-component-export',
|
||||
),
|
||||
path(
|
||||
'api/export-import/<slug:component_type>/<uuid:uuid>/dependencies/',
|
||||
api_views.component_dependencies,
|
||||
name='api-export-import-component-dependencies',
|
||||
),
|
||||
path(
|
||||
'api/export-import/<slug:component_type>/<uuid:uuid>/redirect/',
|
||||
api_views.component_redirect,
|
||||
name='api-export-import-component-redirect',
|
||||
),
|
||||
path(
|
||||
'api/export-import/job/<uuid:job_uuid>/status/',
|
||||
api_views.job_status,
|
||||
name='api-export-import-job-status',
|
||||
),
|
||||
]
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('family', '0013_display_condition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='weeklyagendacell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -112,3 +112,14 @@ class WeeklyAgendaCell(JsonCellBase):
|
|||
from .forms import WeeklyAgendaCellForm
|
||||
|
||||
return WeeklyAgendaCellForm
|
||||
|
||||
def get_computed_strings(self):
|
||||
yield from super().get_computed_strings()
|
||||
fields = [
|
||||
'agenda_references_template',
|
||||
'agenda_categories',
|
||||
'start_date_filter',
|
||||
'end_date_filter',
|
||||
'user_external_template',
|
||||
]
|
||||
yield from [getattr(self, f) for f in fields]
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('fargo', '0006_display_condition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recentdocumentscell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -109,7 +109,7 @@ class RecentDocumentsCell(CellBase):
|
|||
|
||||
|
||||
def get_fargo_services():
|
||||
return settings.KNOWN_SERVICES.get('fargo') or []
|
||||
return settings.KNOWN_SERVICES.get('fargo') or {}
|
||||
|
||||
|
||||
def get_fargo_site(fargo_site):
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('gallery', '0006_enlarge_title'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='gallerycell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('kb', '0004_display_condition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='latestpageupdatescell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -54,7 +54,7 @@ class Command(BaseCommand):
|
|||
):
|
||||
qs = PaymentBackend.objects.all()
|
||||
if backend and all_backends:
|
||||
raise CommandError('--backend and --all-baskends cannot be used together')
|
||||
raise CommandError('--backend and --all-backends cannot be used together')
|
||||
if backend:
|
||||
try:
|
||||
backend = qs.get(slug=backend)
|
|
@ -0,0 +1,83 @@
|
|||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import combo.apps.lingo.models
|
||||
import combo.data.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('data', '0067_application'),
|
||||
('lingo', '0054_payment_cell'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='invoicescell',
|
||||
name='groups',
|
||||
field=models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Roles'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CreditsCell',
|
||||
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'),
|
||||
),
|
||||
(
|
||||
'condition',
|
||||
models.CharField(
|
||||
blank=True, max_length=1000, null=True, verbose_name='Display condition'
|
||||
),
|
||||
),
|
||||
('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)),
|
||||
('regie', models.CharField(blank=True, max_length=50, verbose_name='Regie')),
|
||||
('title', models.CharField(blank=True, max_length=200, verbose_name='Title')),
|
||||
('text', combo.data.fields.RichTextField(blank=True, null=True, verbose_name='Text')),
|
||||
('hide_if_empty', models.BooleanField(default=False, verbose_name='Hide if no credits')),
|
||||
(
|
||||
'display_mode',
|
||||
models.CharField(
|
||||
choices=[('active', 'Active'), ('historical', 'Historical')],
|
||||
default='active',
|
||||
max_length=10,
|
||||
verbose_name='Credits to display',
|
||||
),
|
||||
),
|
||||
(
|
||||
'payer_external_id_template',
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text='The computed value will be transmitted to the billing system. It can also be left blank.',
|
||||
max_length=1000,
|
||||
verbose_name='Payer external id (template)',
|
||||
),
|
||||
),
|
||||
('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': 'Credits cell',
|
||||
},
|
||||
bases=(combo.apps.lingo.models.RegieElementsMixin, models.Model),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,52 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('lingo', '0055_credits'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='creditscell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoicescell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='lingobasketcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='lingobasketlinkcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='lingorecenttransactionscell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentscell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='selfdeclaredinvoicepayment',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tipipaymentformcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.2.18 on 2023-04-20 23:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('lingo', '0056_increase_extra_css_class'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='regie',
|
||||
name='has_invoice_for_payment',
|
||||
field=models.BooleanField(
|
||||
default=False, verbose_name='The invoice endpoint handle the for-payment parameter'
|
||||
),
|
||||
),
|
||||
]
|
|
@ -141,13 +141,15 @@ def build_remote_item(data, regie, payer_external_id=None):
|
|||
)
|
||||
|
||||
|
||||
def build_remote_payment(data, regie, payer_external_id=None):
|
||||
return RemotePayment(
|
||||
def build_remote_element(element_type, data, regie, payer_external_id=None):
|
||||
return RemoteElement(
|
||||
id=data.get('id'),
|
||||
regie=regie,
|
||||
creation_date=data['created'],
|
||||
display_id=data.get('display_id'),
|
||||
amount=data.get('amount'),
|
||||
remaining_amount=data.get('remaining_amount'),
|
||||
total_amount=data.get('total_amount'),
|
||||
payment_type=data.get('payment_type'),
|
||||
has_pdf=data.get('has_pdf'),
|
||||
payer_external_id=payer_external_id,
|
||||
|
@ -317,6 +319,9 @@ class Regie(models.Model):
|
|||
can_pay_only_one_basket_item = models.BooleanField(
|
||||
default=True, verbose_name=_('Basket items must be paid individually')
|
||||
)
|
||||
has_invoice_for_payment = models.BooleanField(
|
||||
default=False, verbose_name=_('The invoice endpoint handle the for-payment parameter')
|
||||
)
|
||||
|
||||
def is_remote(self):
|
||||
return self.webservice_url != ''
|
||||
|
@ -382,6 +387,11 @@ class Regie(models.Model):
|
|||
raise RegieException(regie_exc_msg) from e
|
||||
if items.get('err'):
|
||||
raise RegieException(regie_exc_msg)
|
||||
if not history:
|
||||
has_invoice_for_payment = items.get('has_invoice_for_payment', False)
|
||||
if self.has_invoice_for_payment != has_invoice_for_payment:
|
||||
self.has_invoice_for_payment = has_invoice_for_payment
|
||||
self.save(update_fields=['has_invoice_for_payment'])
|
||||
if items.get('data'):
|
||||
if not isinstance(items['data'], list):
|
||||
raise RegieException(regie_exc_msg)
|
||||
|
@ -408,12 +418,15 @@ class Regie(models.Model):
|
|||
log_errors=True,
|
||||
raise_4xx=False,
|
||||
update_paid=False,
|
||||
for_payment=False,
|
||||
):
|
||||
if not self.is_remote():
|
||||
return self.basketitem_set.get(pk=invoice_id)
|
||||
url = self.webservice_url + '/invoice/%s/' % invoice_id
|
||||
if payer_external_id:
|
||||
url += '?payer_external_id=%s' % payer_external_id
|
||||
if self.has_invoice_for_payment and for_payment:
|
||||
url += ('?' if '?' not in url else '&') + 'payment'
|
||||
response = requests.get(
|
||||
url,
|
||||
user=user if not payer_external_id else None,
|
||||
|
@ -428,7 +441,7 @@ class Regie(models.Model):
|
|||
raise ObjectDoesNotExist()
|
||||
response.raise_for_status()
|
||||
if response.json().get('err'):
|
||||
raise RemoteInvoiceException()
|
||||
raise RemoteInvoiceException('err != 0', response.json())
|
||||
if response.json().get('data') is None:
|
||||
raise ObjectDoesNotExist()
|
||||
remote_item = build_remote_item(response.json().get('data'), self)
|
||||
|
@ -482,11 +495,13 @@ class Regie(models.Model):
|
|||
raise RemoteInvoiceException
|
||||
return resp
|
||||
|
||||
def get_payments(self, user, payer_external_id=None):
|
||||
def get_lingo_elements(self, element_type, user, payer_external_id=None, history=False):
|
||||
if not self.is_remote():
|
||||
return []
|
||||
if user:
|
||||
url = self.webservice_url + '/payments/'
|
||||
url = self.webservice_url + '/%s/' % element_type
|
||||
if history:
|
||||
url += 'history/'
|
||||
if payer_external_id:
|
||||
url += '?payer_external_id=%s' % payer_external_id
|
||||
|
||||
|
@ -506,31 +521,35 @@ class Regie(models.Model):
|
|||
except RequestException as e:
|
||||
raise RegieException(regie_exc_msg) from e
|
||||
try:
|
||||
payments = response.json()
|
||||
elements = response.json()
|
||||
except ValueError as e:
|
||||
raise RegieException(regie_exc_msg) from e
|
||||
if payments.get('err'):
|
||||
if elements.get('err'):
|
||||
raise RegieException(regie_exc_msg)
|
||||
if payments.get('data'):
|
||||
if not isinstance(payments['data'], list):
|
||||
if elements.get('data'):
|
||||
if not isinstance(elements['data'], list):
|
||||
raise RegieException(regie_exc_msg)
|
||||
return [
|
||||
build_remote_payment(
|
||||
payment,
|
||||
build_remote_element(
|
||||
element_type,
|
||||
element,
|
||||
self,
|
||||
payer_external_id=payer_external_id,
|
||||
)
|
||||
for payment in payments['data']
|
||||
for element in elements['data']
|
||||
]
|
||||
return []
|
||||
return []
|
||||
|
||||
def get_payment_pdf(self, user, payment_id, payer_external_id=None):
|
||||
"""
|
||||
downloads payment's file
|
||||
"""
|
||||
def get_payments(self, user, payer_external_id=None, **kwargs):
|
||||
return self.get_lingo_elements('payments', user, payer_external_id)
|
||||
|
||||
def get_credits(self, user, payer_external_id=None, history=False):
|
||||
return self.get_lingo_elements('credits', user, payer_external_id, history)
|
||||
|
||||
def get_lingo_element_pdf(self, element_type, user, payment_id, payer_external_id=None):
|
||||
if self.is_remote() and user:
|
||||
url = self.webservice_url + '/payment/%s/pdf/' % payment_id
|
||||
url = self.webservice_url + '/%s/%s/pdf/' % (element_type, payment_id)
|
||||
if payer_external_id:
|
||||
url += '?payer_external_id=%s' % payer_external_id
|
||||
return requests.get(
|
||||
|
@ -542,6 +561,18 @@ class Regie(models.Model):
|
|||
)
|
||||
raise PermissionDenied
|
||||
|
||||
def get_payment_pdf(self, user, payment_id, payer_external_id=None):
|
||||
"""
|
||||
downloads payment's file
|
||||
"""
|
||||
return self.get_lingo_element_pdf('payment', user, payment_id, payer_external_id)
|
||||
|
||||
def get_credit_pdf(self, user, credit_id, payer_external_id=None):
|
||||
"""
|
||||
downloads credit's file
|
||||
"""
|
||||
return self.get_lingo_element_pdf('credit', user, credit_id, payer_external_id)
|
||||
|
||||
def as_api_dict(self):
|
||||
return {'id': self.slug, 'text': self.label, 'description': self.description}
|
||||
|
||||
|
@ -942,7 +973,7 @@ class RemoteItem:
|
|||
remote_item.waiting_date = waiting_items[remote_item.id]
|
||||
|
||||
|
||||
class RemotePayment:
|
||||
class RemoteElement:
|
||||
def __init__(
|
||||
self,
|
||||
id,
|
||||
|
@ -950,6 +981,8 @@ class RemotePayment:
|
|||
creation_date,
|
||||
display_id,
|
||||
amount,
|
||||
remaining_amount,
|
||||
total_amount,
|
||||
payment_type,
|
||||
has_pdf,
|
||||
payer_external_id=None,
|
||||
|
@ -958,7 +991,9 @@ class RemotePayment:
|
|||
self.regie = regie
|
||||
self.creation_date = dateparse.parse_date(creation_date or '')
|
||||
self.display_id = display_id or self.id
|
||||
self.amount = Decimal(amount)
|
||||
self.amount = Decimal(amount) if amount else None
|
||||
self.remaining_amount = Decimal(remaining_amount) if remaining_amount else None
|
||||
self.total_amount = Decimal(total_amount) if total_amount else None
|
||||
self.payment_type = payment_type
|
||||
self.has_pdf = has_pdf
|
||||
self.payer_external_id = payer_external_id
|
||||
|
@ -1041,22 +1076,25 @@ class Transaction(models.Model):
|
|||
to_be_paid_remote_items = []
|
||||
for item_id in items:
|
||||
try:
|
||||
remote_item = regie.get_invoice(user=self.user, invoice_id=item_id, raise_4xx=True)
|
||||
remote_item = regie.get_invoice(
|
||||
user=self.user, invoice_id=item_id, raise_4xx=True, for_payment=True
|
||||
)
|
||||
with atomic(savepoint=False):
|
||||
self.items.add(self.create_paid_invoice_basket_item(item_id, remote_item))
|
||||
regie.pay_invoice(item_id, self.order_id, self.bank_transaction_date or self.end_date)
|
||||
except ObjectDoesNotExist:
|
||||
# 4xx error
|
||||
# 4xx error or data field is empty
|
||||
logger.error(
|
||||
'unable to retrieve or pay remote item %s from transaction %s, ignore it', item_id, self
|
||||
)
|
||||
except (RequestException, RemoteInvoiceException):
|
||||
except (RequestException, RemoteInvoiceException) as e:
|
||||
# 5xx, err or requests error
|
||||
to_be_paid_remote_items.append(item_id)
|
||||
logger.warning(
|
||||
'unable to notify payment for remote item %s from transaction %s, retry later',
|
||||
'unable to notify payment for remote item %s from transaction %s, retry later (%s)',
|
||||
item_id,
|
||||
self,
|
||||
e,
|
||||
)
|
||||
except Exception:
|
||||
# unknown error
|
||||
|
@ -1538,9 +1576,57 @@ class InvoicesCell(RegieElementsMixin, CellBase):
|
|||
raise NothingInCacheException()
|
||||
return super().render(context)
|
||||
|
||||
def get_computed_strings(self):
|
||||
yield from super().get_computed_strings()
|
||||
yield self.payer_external_id_template
|
||||
|
||||
|
||||
class LingoElementsMixin:
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
lingo_enabled = hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('lingo')
|
||||
return Regie.objects.exclude(webservice_url='').exists() and lingo_enabled
|
||||
|
||||
def get_elements(self, user, payer_external_id):
|
||||
elements = []
|
||||
errors = []
|
||||
for r in self.get_regies():
|
||||
try:
|
||||
for remote_element in getattr(r, 'get_%s' % self.element_type)(
|
||||
user,
|
||||
history=bool(getattr(self, 'display_mode', None) == 'historical'),
|
||||
payer_external_id=payer_external_id,
|
||||
):
|
||||
elements.append(remote_element)
|
||||
except RegieException as e:
|
||||
errors.append(e)
|
||||
return elements, errors
|
||||
|
||||
def get_cell_extra_context(self, context):
|
||||
ctx = super().get_cell_extra_context(context)
|
||||
if context.get('placeholder_search_mode'):
|
||||
# don't call webservices when we're just looking for placeholders
|
||||
return ctx
|
||||
ctx.update({'title': self.title, 'text': self.text})
|
||||
payer_external_id = self.get_payer_external_id(original_context=context)
|
||||
elements, errors = self.get_elements(user=context['user'], payer_external_id=payer_external_id)
|
||||
none_date = datetime.datetime(1900, 1, 1) # to avoid None-None comparison errors
|
||||
elements.sort(key=lambda i: i.creation_date or none_date, reverse=True)
|
||||
ctx.update(
|
||||
{
|
||||
self.element_type: elements,
|
||||
'errors': errors,
|
||||
}
|
||||
)
|
||||
return ctx
|
||||
|
||||
def get_computed_strings(self):
|
||||
yield from super().get_computed_strings()
|
||||
yield self.payer_external_id_template
|
||||
|
||||
|
||||
@register_cell_class
|
||||
class PaymentsCell(RegieElementsMixin, CellBase):
|
||||
class PaymentsCell(RegieElementsMixin, LingoElementsMixin, CellBase):
|
||||
regie = models.CharField(_('Regie'), max_length=50, blank=True)
|
||||
title = models.CharField(_('Title'), max_length=200, blank=True)
|
||||
text = RichTextField(_('Text'), blank=True, null=True)
|
||||
|
@ -1557,6 +1643,7 @@ class PaymentsCell(RegieElementsMixin, CellBase):
|
|||
user_dependant = True
|
||||
default_template_name = 'lingo/combo/payments.html'
|
||||
loading_message = _('Loading payments...')
|
||||
element_type = 'payments'
|
||||
|
||||
default_form_fields = ['text', 'hide_if_empty', 'payer_external_id_template']
|
||||
default_form_widgets = {
|
||||
|
@ -1572,42 +1659,57 @@ class PaymentsCell(RegieElementsMixin, CellBase):
|
|||
'js/gadjo.js',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls):
|
||||
lingo_enabled = hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('lingo')
|
||||
return Regie.objects.exclude(webservice_url='').exists() and lingo_enabled
|
||||
|
||||
def get_payments(self, user, payer_external_id):
|
||||
payments = []
|
||||
errors = []
|
||||
for r in self.get_regies():
|
||||
try:
|
||||
for remote_payment in r.get_payments(
|
||||
user,
|
||||
payer_external_id=payer_external_id,
|
||||
):
|
||||
payments.append(remote_payment)
|
||||
except RegieException as e:
|
||||
errors.append(e)
|
||||
return payments, errors
|
||||
@register_cell_class
|
||||
class CreditsCell(RegieElementsMixin, LingoElementsMixin, CellBase):
|
||||
regie = models.CharField(_('Regie'), max_length=50, blank=True)
|
||||
title = models.CharField(_('Title'), max_length=200, blank=True)
|
||||
text = RichTextField(_('Text'), blank=True, null=True)
|
||||
hide_if_empty = models.BooleanField(_('Hide if no credits'), default=False)
|
||||
display_mode = models.CharField(
|
||||
_('Credits to display'),
|
||||
choices=[
|
||||
('active', pgettext_lazy('credits', 'Active')),
|
||||
('historical', pgettext_lazy('credits', 'Historical')),
|
||||
],
|
||||
default='active',
|
||||
max_length=10,
|
||||
)
|
||||
payer_external_id_template = models.CharField(
|
||||
_('Payer external id (template)'),
|
||||
max_length=1000,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
'The computed value will be transmitted to the billing system. It can also be left blank.'
|
||||
),
|
||||
)
|
||||
|
||||
def get_cell_extra_context(self, context):
|
||||
ctx = super().get_cell_extra_context(context)
|
||||
if context.get('placeholder_search_mode'):
|
||||
# don't call webservices when we're just looking for placeholders
|
||||
return ctx
|
||||
ctx.update({'title': self.title, 'text': self.text})
|
||||
payer_external_id = self.get_payer_external_id(original_context=context)
|
||||
payments, errors = self.get_payments(user=context['user'], payer_external_id=payer_external_id)
|
||||
none_date = datetime.datetime(1900, 1, 1) # to avoid None-None comparison errors
|
||||
payments.sort(key=lambda i: i.creation_date or none_date, reverse=True)
|
||||
ctx.update(
|
||||
{
|
||||
'payments': payments,
|
||||
'errors': errors,
|
||||
}
|
||||
user_dependant = True
|
||||
default_template_name = 'lingo/combo/credits.html'
|
||||
loading_message = _('Loading credits...')
|
||||
element_type = 'credits'
|
||||
|
||||
default_form_fields = [
|
||||
'text',
|
||||
'display_mode',
|
||||
'hide_if_empty',
|
||||
'payer_external_id_template',
|
||||
]
|
||||
default_form_widgets = {
|
||||
'payer_external_id_template': TextInput(attrs={'class': 'text-wide'}),
|
||||
}
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Credits cell')
|
||||
|
||||
class Media:
|
||||
js = (
|
||||
'xstatic/jquery-ui.min.js',
|
||||
'js/gadjo.js',
|
||||
)
|
||||
return ctx
|
||||
|
||||
def get_additional_label(self):
|
||||
return self.get_display_mode_display()
|
||||
|
||||
|
||||
TIPI_CONTROL_PROCOTOLS = (
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
{% load i18n %}
|
||||
{% block cell-content %}
|
||||
{% if errors or credits or not cell.hide_if_empty %}
|
||||
{% if title %}<h2>{{ title|safe }}</h2>{% endif %}
|
||||
<div>
|
||||
{% if text %}{{ text|safe }}{% endif %}
|
||||
{% if errors %}
|
||||
<ul class="errorlist">
|
||||
{% for error in errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if credits %}
|
||||
<div class="pk-table-wrapper">
|
||||
<table class="invoices pk-data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="credit-id">{% trans "Number" %}</th>
|
||||
<th class="credit-creation-date">{% trans "Credit date" %}</th>
|
||||
<th class="invoice-amount amount">{% trans "Amount" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for credit in credits %}
|
||||
<tr>
|
||||
<td class="credit-id">{{ credit.display_id }}</td>
|
||||
<td class="credit-creation-date">{{ credit.creation_date|date:"SHORT_DATE_FORMAT" }}</td>
|
||||
<td class="invoice-amount amount">
|
||||
{% blocktrans with amount=credit.total_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %}
|
||||
{% if cell.display_mode == 'active' and credit.remaining_amount %}
|
||||
<br />
|
||||
<small>
|
||||
({% trans "credit left:" %} {% blocktrans with amount=credit.remaining_amount|floatformat:"2" %}{{ amount }}€{% endblocktrans %})
|
||||
</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if credit.regie.is_remote %}
|
||||
{% with cell_crypto_reference=cell.crypto_reference credit_crypto_id=credit.crypto_id credit_crypto_payer_external_id=credit.crypto_payer_external_id %}
|
||||
{% if credit.has_pdf %}
|
||||
<a href="{% url 'download-credit-pdf' regie_id=credit.regie.pk credit_crypto_id=credit_crypto_id cell_crypto_reference=cell_crypto_reference %}{% if credit_crypto_payer_external_id %}?payer_external_id={{ credit_crypto_payer_external_id }}{% endif %}" class="icon-pdf"
|
||||
>{% trans "Download" %}</a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{% trans "No credits yet" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
|
@ -39,6 +39,7 @@ from .views import (
|
|||
CallbackView,
|
||||
CancelItemView,
|
||||
CancelTransactionApiView,
|
||||
CreditDownloadView,
|
||||
ItemDownloadView,
|
||||
ItemPaymentsDownloadView,
|
||||
ItemView,
|
||||
|
@ -138,6 +139,11 @@ urlpatterns = [
|
|||
PaymentDownloadView.as_view(),
|
||||
name='download-payment-pdf',
|
||||
),
|
||||
re_path(
|
||||
r'^lingo/credit/(?P<regie_id>[\w,-]+)/(?P<credit_crypto_id>[\w,-]+)/(?P<cell_crypto_reference>[\w,-]+)/pdf$',
|
||||
CreditDownloadView.as_view(),
|
||||
name='download-credit-pdf',
|
||||
),
|
||||
re_path(
|
||||
r'^lingo/item/(?P<item_signature>.+)/pay$', BasketItemPayView.as_view(), name='basket-item-pay-view'
|
||||
),
|
||||
|
|
|
@ -512,7 +512,7 @@ class PayView(PayMixin, View):
|
|||
regie = Regie.objects.get(pk=regie_id)
|
||||
# get all items data from regie webservice
|
||||
for item_id in request.POST.getlist('item'):
|
||||
remote_items.append(regie.get_invoice(user, item_id, update_paid=True))
|
||||
remote_items.append(regie.get_invoice(user, item_id, update_paid=True, for_payment=True))
|
||||
except (requests.exceptions.RequestException, RemoteInvoiceException):
|
||||
messages.error(request, _('Technical error: impossible to retrieve invoices.'))
|
||||
return HttpResponseRedirect(next_url)
|
||||
|
@ -924,13 +924,11 @@ class CancelItemView(DetailView):
|
|||
return HttpResponseRedirect(get_basket_url())
|
||||
|
||||
|
||||
class PaymentDownloadView(View):
|
||||
http_method_names = ['get']
|
||||
|
||||
class LingoElementDownloadMixin:
|
||||
def get(self, request, *args, **kwargs):
|
||||
regie = get_object_or_404(Regie, pk=kwargs['regie_id'])
|
||||
try:
|
||||
payment_id = aes_hex_decrypt(settings.SECRET_KEY, kwargs['payment_crypto_id'])
|
||||
element_id = aes_hex_decrypt(settings.SECRET_KEY, kwargs['%s_crypto_id' % self.element_type])
|
||||
except DecryptionError:
|
||||
raise Http404()
|
||||
|
||||
|
@ -944,24 +942,38 @@ class PaymentDownloadView(View):
|
|||
raise Http404()
|
||||
|
||||
try:
|
||||
data = regie.get_payment_pdf(request.user, payment_id, payer_external_id=payer_external_id)
|
||||
data = getattr(regie, 'get_%s_pdf' % self.element_type)(
|
||||
request.user, element_id, payer_external_id=payer_external_id
|
||||
)
|
||||
except PermissionDenied:
|
||||
return HttpResponseForbidden()
|
||||
except DecryptionError as e:
|
||||
return Http404(str(e))
|
||||
|
||||
if data.status_code != 200:
|
||||
logging.error('failed to retrieve payment (%r)', data.status_code)
|
||||
messages.error(request, _('We are sorry but an error occured when retrieving the payment.'))
|
||||
logging.error('failed to retrieve %s (%r)', self.element_type, data.status_code)
|
||||
messages.error(request, self.error_message)
|
||||
if self.request.headers.get('Referer'):
|
||||
return HttpResponseRedirect(self.request.headers.get('Referer'))
|
||||
return HttpResponseRedirect('/')
|
||||
|
||||
r = HttpResponse(data, content_type='application/pdf')
|
||||
r['Content-Disposition'] = 'attachment; filename="%s.pdf"' % payment_id
|
||||
r['Content-Disposition'] = 'attachment; filename="%s.pdf"' % element_id
|
||||
return r
|
||||
|
||||
|
||||
class PaymentDownloadView(LingoElementDownloadMixin, View):
|
||||
http_method_names = ['get']
|
||||
element_type = 'payment'
|
||||
error_message = _('We are sorry but an error occured when retrieving the payment.')
|
||||
|
||||
|
||||
class CreditDownloadView(LingoElementDownloadMixin, View):
|
||||
http_method_names = ['get']
|
||||
element_type = 'credit'
|
||||
error_message = _('We are sorry but an error occured when retrieving the credit.')
|
||||
|
||||
|
||||
class SelfInvoiceView(View):
|
||||
http_method_names = ['get', 'options']
|
||||
|
||||
|
|
|
@ -15,13 +15,14 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from combo.data.fields import TemplatableURLField
|
||||
|
||||
from .models import MapLayer, MapLayerOptions
|
||||
from .models import Map, MapLayer, MapLayerOptions
|
||||
|
||||
|
||||
class IconRadioSelect(forms.RadioSelect):
|
||||
|
@ -122,3 +123,24 @@ class MapLayerOptionsForm(forms.ModelForm):
|
|||
self.fields['opacity'].required = True
|
||||
self.fields['opacity'].initial = 1
|
||||
del self.fields['properties']
|
||||
|
||||
|
||||
class MapCellEditForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Map
|
||||
fields = ('initial_zoom', 'min_zoom', 'max_zoom')
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
initial_zoom = int(cleaned_data['initial_zoom'])
|
||||
max_zoom = int(cleaned_data['max_zoom'])
|
||||
min_zoom = int(cleaned_data['min_zoom'])
|
||||
if min_zoom > max_zoom:
|
||||
raise ValidationError(
|
||||
_('Invalid zoom configuration: minimal zoom must be lower than maximal zoom')
|
||||
)
|
||||
if not (max_zoom >= initial_zoom >= min_zoom):
|
||||
raise ValidationError(
|
||||
_('Invalid zoom configuration: initial zoom is not between minimal & maximal zoom'),
|
||||
)
|
||||
return cleaned_data
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('maps', '0021_maplayer_marker_size'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='map',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.16 on 2024-03-13 19:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('maps', '0022_increase_extra_css_class'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='map',
|
||||
name='include_search_button',
|
||||
field=models.BooleanField(default=False, verbose_name='Include address search button'),
|
||||
),
|
||||
]
|
|
@ -31,9 +31,9 @@ from django.utils.translation import gettext_lazy as _
|
|||
from requests.exceptions import RequestException
|
||||
from requests.models import PreparedRequest
|
||||
|
||||
from combo.data.exceptions import ImportSiteError
|
||||
from combo.data.library import register_cell_class
|
||||
from combo.data.models import CellBase
|
||||
from combo.data.utils import ImportSiteError
|
||||
from combo.utils import get_templated_url, requests
|
||||
|
||||
KIND = [
|
||||
|
@ -433,6 +433,7 @@ class Map(CellBase):
|
|||
min_zoom = models.CharField(_('Minimal zoom level'), max_length=2, choices=ZOOM_LEVELS, default='0')
|
||||
max_zoom = models.CharField(_('Maximal zoom level'), max_length=2, choices=ZOOM_LEVELS, default=19)
|
||||
group_markers = models.BooleanField(_('Group markers in clusters'), default=False)
|
||||
include_search_button = models.BooleanField(_('Include address search button'), default=False)
|
||||
marker_behaviour_onclick = models.CharField(
|
||||
_('Marker behaviour on click'), max_length=32, default='none', choices=MARKER_BEHAVIOUR_ONCLICK
|
||||
)
|
||||
|
@ -449,6 +450,7 @@ class Map(CellBase):
|
|||
'/jsi18n',
|
||||
'xstatic/leaflet.js',
|
||||
'js/leaflet-gps.js',
|
||||
'js/leaflet-search.js',
|
||||
'js/combo.map.js',
|
||||
'xstatic/leaflet.markercluster.js',
|
||||
'xstatic/leaflet-gesture-handling.min.js',
|
||||
|
@ -464,18 +466,21 @@ class Map(CellBase):
|
|||
fields = (
|
||||
'initial_state',
|
||||
'group_markers',
|
||||
'include_search_button',
|
||||
'marker_behaviour_onclick',
|
||||
)
|
||||
return forms.models.modelform_factory(self.__class__, fields=fields)
|
||||
|
||||
def get_manager_tabs(self):
|
||||
from .forms import MapCellEditForm
|
||||
|
||||
tabs = super().get_manager_tabs()
|
||||
tabs.insert(
|
||||
1,
|
||||
{
|
||||
'slug': 'zoom',
|
||||
'name': _('Zoom'),
|
||||
'fields': ['initial_zoom', 'min_zoom', 'max_zoom'],
|
||||
'form': MapCellEditForm,
|
||||
},
|
||||
)
|
||||
return tabs
|
||||
|
@ -558,6 +563,7 @@ class Map(CellBase):
|
|||
ctx['tiles_layers'] = self.get_tiles_layers()
|
||||
ctx['max_bounds'] = settings.COMBO_MAP_MAX_BOUNDS
|
||||
ctx['group_markers'] = self.group_markers
|
||||
ctx['include_search_button'] = self.include_search_button
|
||||
ctx['marker_behaviour_onclick'] = self.marker_behaviour_onclick
|
||||
return ctx
|
||||
|
||||
|
|
|
@ -76,77 +76,80 @@ $marker_icons: (
|
|||
);
|
||||
|
||||
div.combo-cell-map.leaflet-container {
|
||||
height: 60vh;
|
||||
font: inherit;
|
||||
height: 60vh;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* leaflet styles */
|
||||
|
||||
div.leaflet-marker-icon.leaflet-div-icon {
|
||||
border: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
div.leaflet-div-icon span {
|
||||
width: #{$marker_width};
|
||||
height: #{$marker_width};
|
||||
display: block;
|
||||
left: #{0 - $marker_width / 2};
|
||||
top: #{0 - $marker_width * 1.2};
|
||||
position: relative;
|
||||
border-radius: #{$marker_width * 10} #{$marker_width * 6} #{$marker_width * 0.5};
|
||||
transform: scale(1, 1.3) rotate(45deg);
|
||||
box-sizing: content-box;
|
||||
&.leaflet-icon-marker-medium {
|
||||
width: #{$medium_marker_width};
|
||||
height: #{$medium_marker_width};
|
||||
left: #{0 - $medium_marker_width / 2};
|
||||
top: #{0 - $medium_marker_width * 1.2};
|
||||
border-radius: #{$medium_marker_width * 10} #{$medium_marker_width * 6} #{$medium_marker_width * 0.5};
|
||||
}
|
||||
&.leaflet-icon-marker-small {
|
||||
width: #{$small_marker_width};
|
||||
height: #{$small_marker_width};
|
||||
left: #{0 - $small_marker_width / 2};
|
||||
top: #{0 - $small_marker_width * 1.2};
|
||||
border-radius: #{$small_marker_width * 10} #{$small_marker_width * 6} #{$small_marker_width * 0.5};
|
||||
}
|
||||
width: #{$marker_width};
|
||||
height: #{$marker_width};
|
||||
display: block;
|
||||
left: #{0 - $marker_width / 2};
|
||||
top: #{0 - $marker_width * 1.2};
|
||||
position: relative;
|
||||
border-radius: #{$marker_width * 10} #{$marker_width * 6} #{$marker_width * 0.5};
|
||||
transform: scale(1, 1.3) rotate(45deg);
|
||||
box-sizing: content-box;
|
||||
&.leaflet-icon-marker-medium {
|
||||
width: #{$medium_marker_width};
|
||||
height: #{$medium_marker_width};
|
||||
left: #{0 - $medium_marker_width / 2};
|
||||
top: #{0 - $medium_marker_width * 1.2};
|
||||
border-radius: #{$medium_marker_width * 10} #{$medium_marker_width * 6} #{$medium_marker_width * 0.5};
|
||||
}
|
||||
&.leaflet-icon-marker-small {
|
||||
width: #{$small_marker_width};
|
||||
height: #{$small_marker_width};
|
||||
left: #{0 - $small_marker_width / 2};
|
||||
top: #{0 - $small_marker_width * 1.2};
|
||||
border-radius: #{$small_marker_width * 10} #{$small_marker_width * 6} #{$small_marker_width * 0.5};
|
||||
}
|
||||
}
|
||||
|
||||
div.leaflet-div-icon span {
|
||||
border: 1px solid white;
|
||||
box-shadow: 0 0 0 1px #aaa;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0 0 0 1px #aaa;
|
||||
}
|
||||
|
||||
div.leaflet-div-icon span i {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
transform: translateY(50%) rotate(-45deg);
|
||||
height: 50%;
|
||||
box-sizing: content-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
transform: translateY(50%) rotate(-45deg);
|
||||
height: 50%;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
div.leaflet-popup-content {
|
||||
div.popup-field {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
span.field-label,
|
||||
span.field-value {
|
||||
display: block;
|
||||
}
|
||||
span.field-label + span.field-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
div.file-field {
|
||||
font-weight: normal;
|
||||
font-size: 90%;
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
max-height: 10vh;
|
||||
}
|
||||
}
|
||||
div.popup-field {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
span.field-label,
|
||||
span.field-value {
|
||||
display: block;
|
||||
}
|
||||
span.field-label + span.field-value {
|
||||
font-weight: bold;
|
||||
a {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
div.file-field {
|
||||
font-weight: normal;
|
||||
font-size: 90%;
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
max-height: 10vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.leaflet-div-icon span {
|
||||
|
@ -187,15 +190,15 @@ div.leaflet-div-icon span {
|
|||
select#id_icon option::before,
|
||||
ul#id_icon span label::before,
|
||||
i.leaflet-marker-icon {
|
||||
font: normal normal normal 1em/1 FontAwesome;
|
||||
font: normal normal normal 1em/1 FontAwesome;
|
||||
}
|
||||
|
||||
.layers a::before,
|
||||
select#id_icon option::before {
|
||||
padding-right: 1ex;
|
||||
display: inline-block;
|
||||
width: 3ex;
|
||||
text-align: center;
|
||||
padding-right: 1ex;
|
||||
display: inline-block;
|
||||
width: 3ex;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@each $marker_icon_name, $marker_icon_symbol in $marker_icons {
|
||||
|
@ -278,3 +281,61 @@ ul#id_icon {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-top.leaflet-right {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.leaflet-search {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
align-items: start;
|
||||
|
||||
&.leaflet-control {
|
||||
pointer-events: none;
|
||||
&.open {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-bar {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&--control {
|
||||
width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&.open &--control {
|
||||
width: 100%
|
||||
}
|
||||
|
||||
&--input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--result-list {
|
||||
padding-right: 0.7em;
|
||||
background: white;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
&--result-item {
|
||||
text-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
font-size: 1em;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover, &.selected {
|
||||
color: white;
|
||||
background-color: #5897fb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -293,6 +293,17 @@ $(function() {
|
|||
tooltipTitle: gettext('Display my position')});
|
||||
map.addControl(gps_control);
|
||||
}
|
||||
if (L.Control.Search && $map_widget.data('search-url')) {
|
||||
var search_control = new L.Control.Search({
|
||||
labels: {
|
||||
hint: gettext('Search address'),
|
||||
error: gettext('An error occured while fetching results'),
|
||||
searching: gettext('Searching...'),
|
||||
},
|
||||
searchUrl: $map_widget.data('search-url')
|
||||
});
|
||||
map.addControl(search_control);
|
||||
}
|
||||
|
||||
$map_widget[0].leaflet_map = map;
|
||||
$(cell).removeClass('empty-cell');
|
||||
|
|
|
@ -0,0 +1,269 @@
|
|||
/* global L, $ */
|
||||
class SearchControl extends L.Control {
|
||||
options = {
|
||||
labels: {
|
||||
hint: 'Search adresses',
|
||||
error: 'An error occured while fetching results',
|
||||
searching: 'Searching...'
|
||||
},
|
||||
position: 'topright',
|
||||
searchUrl: '/api/geocoding',
|
||||
maxResults: 5
|
||||
}
|
||||
|
||||
constructor (options) {
|
||||
super()
|
||||
L.Util.setOptions(this, options)
|
||||
this._refreshTimeout = 0
|
||||
}
|
||||
|
||||
onAdd (map) {
|
||||
this._map = map
|
||||
this._container = L.DomUtil.create('div', 'leaflet-search')
|
||||
this._resultLocations = []
|
||||
this._selectedIndex = -1
|
||||
|
||||
this._buttonBar = L.DomUtil.create('div', 'leaflet-bar', this._container)
|
||||
|
||||
this._toggleButton = L.DomUtil.create('a', '', this._buttonBar)
|
||||
this._toggleButton.href = '#'
|
||||
this._toggleButton.role = 'button'
|
||||
this._toggleButton.style.fontFamily = 'FontAwesome'
|
||||
this._toggleButton.text = '\uf002'
|
||||
this._toggleButton.title = this.options.labels.hint
|
||||
this._toggleButton.setAttribute('aria-label', this.options.labels.hint)
|
||||
|
||||
this._control = L.DomUtil.create('div', 'leaflet-search--control', this._container)
|
||||
this._control.style.visibility = 'collapse'
|
||||
|
||||
this._searchInput = L.DomUtil.create('input', 'leaflet-search--input', this._control)
|
||||
this._searchInput.placeholder = this.options.labels.hint
|
||||
|
||||
this._feedback = L.DomUtil.create('div', '', this._control)
|
||||
|
||||
this._resultList = L.DomUtil.create('div', 'leaflet-search--result-list', this._control)
|
||||
this._resultList.style.visibility = 'collapse'
|
||||
this._resultList.tabIndex = 0
|
||||
this._resultList.setAttribute('aria-role', 'list')
|
||||
|
||||
L.DomEvent
|
||||
.on(this._container, 'click', L.DomEvent.stop, this)
|
||||
.on(this._control, 'focusin', this._onControlFocusIn, this)
|
||||
.on(this._control, 'focusout', this._onControlFocusOut, this)
|
||||
.on(this._control, 'keydown', this._onControlKeyDown, this)
|
||||
.on(this._toggleButton, 'click', this._onToggleButtonClick, this)
|
||||
.on(this._searchInput, 'keydown', this._onSearchInputKeyDown, this)
|
||||
.on(this._searchInput, 'input', this._onSearchInput, this)
|
||||
.on(this._searchInput, 'mousemove', this._onSearchInputMove, this)
|
||||
.on(this._searchInput, 'touchmove', this._onSearchInputMove, this)
|
||||
.on(this._resultList, 'keydown', this._onResultListKeyDown, this)
|
||||
|
||||
return this._container
|
||||
}
|
||||
|
||||
onRemove (map) {
|
||||
}
|
||||
|
||||
_showControl () {
|
||||
this._container.classList.add('open')
|
||||
this._buttonBar.style.visibility = 'collapse'
|
||||
this._control.style.removeProperty('visibility')
|
||||
this._initialBounds = this._map.getBounds()
|
||||
setTimeout(() => this._searchInput.focus(), 50)
|
||||
}
|
||||
|
||||
_hideControl (resetBounds) {
|
||||
this._container.classList.remove('open')
|
||||
if (resetBounds) {
|
||||
this._map.fitBounds(this._initialBounds)
|
||||
}
|
||||
|
||||
this._buttonBar.style.removeProperty('visibility')
|
||||
this._control.style.visibility = 'collapse'
|
||||
this._toggleButton.focus()
|
||||
}
|
||||
|
||||
_onControlFocusIn (event) {
|
||||
clearTimeout(this._hideTimeout)
|
||||
}
|
||||
|
||||
_onControlFocusOut (event) {
|
||||
// need to debounce here because leaflet raises focusout then focusin when
|
||||
// clicking on an already focused child element.
|
||||
this._hideTimeout = setTimeout(() => this._hideControl(), 50)
|
||||
}
|
||||
|
||||
_getSelectedLocation () {
|
||||
if (this._selectedIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this._resultLocations[this._selectedIndex]
|
||||
}
|
||||
|
||||
_focusLocation (location) {
|
||||
if (location.bounds !== undefined) {
|
||||
this._map.fitBounds(location.bounds)
|
||||
} else {
|
||||
this._map.panTo(location.latlng)
|
||||
}
|
||||
}
|
||||
|
||||
_validateLocation (location) {
|
||||
this._focusLocation(location)
|
||||
this._hideControl()
|
||||
}
|
||||
|
||||
_onSearchInputMove (event) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
_onControlKeyDown (event) {
|
||||
if (event.keyCode === 27) { // escape
|
||||
this._hideControl(true)
|
||||
event.preventDefault()
|
||||
} else if (event.keyCode === 13) { // enter
|
||||
const selectedLocation = this._getSelectedLocation()
|
||||
if (selectedLocation) {
|
||||
this._validateLocation(selectedLocation)
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
_onToggleButtonClick () {
|
||||
this._showControl()
|
||||
}
|
||||
|
||||
_selectIndex (index) {
|
||||
for (const resultItem of this._resultList.children) {
|
||||
resultItem.classList.remove('selected')
|
||||
}
|
||||
|
||||
this._selectedIndex = index
|
||||
|
||||
if (index === -1) {
|
||||
this._map.fitBounds(this._initialBounds)
|
||||
this._searchInput.focus()
|
||||
} else {
|
||||
this._focusLocation(this._resultLocations[index])
|
||||
const selectedElement = this._resultList.children[index]
|
||||
selectedElement.classList.add('selected')
|
||||
this._resultList.focus()
|
||||
}
|
||||
}
|
||||
|
||||
_onSearchInputKeyDown (event) {
|
||||
const results = this._resultLocations
|
||||
if (results.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.keyCode === 38) {
|
||||
this._selectIndex(results.length - 1)
|
||||
event.preventDefault()
|
||||
} else if (event.keyCode === 40) {
|
||||
this._selectIndex(0)
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
_clearResults () {
|
||||
while (this._resultList.lastElementChild) {
|
||||
this._resultList.removeChild(this._resultList.lastElementChild)
|
||||
}
|
||||
this._resultList.style.visibility = 'collapse'
|
||||
this._resultLocations = []
|
||||
}
|
||||
|
||||
_fetchResults () {
|
||||
const searchString = this._searchInput.value
|
||||
|
||||
if (!searchString) {
|
||||
return
|
||||
}
|
||||
|
||||
this._clearResults()
|
||||
|
||||
this._feedback.innerHTML = this.options.labels.searching
|
||||
this._feedback.classList.remove('error')
|
||||
|
||||
$.ajax({
|
||||
url: this.options.searchUrl,
|
||||
data: { q: searchString },
|
||||
success: (data) => {
|
||||
this._feedback.innerHTML = ''
|
||||
this._resultLocations = []
|
||||
const firstResults = data.slice(0, this.options.maxResults)
|
||||
|
||||
if (firstResults.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this._resultList.style.removeProperty('visibility')
|
||||
|
||||
for (const result of firstResults) {
|
||||
const resultItem = L.DomUtil.create('div', 'leaflet-search--result-item', this._resultList)
|
||||
resultItem.innerHTML = result.display_name
|
||||
resultItem.title = result.display_name
|
||||
resultItem.setAttribute('aria-role', 'list-item')
|
||||
L.DomEvent.on(resultItem, 'click', this._onResultItemClick, this)
|
||||
|
||||
const itemLocation = {
|
||||
latlng: L.latLng(result.lat, result.lon)
|
||||
}
|
||||
|
||||
const bbox = result.boundingbox
|
||||
|
||||
if (bbox !== undefined) {
|
||||
itemLocation.bounds = L.latLngBounds(
|
||||
L.latLng(bbox[0], bbox[2]),
|
||||
L.latLng(bbox[1], bbox[3])
|
||||
)
|
||||
}
|
||||
|
||||
this._resultLocations.push(itemLocation)
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this._feedback.innerHTML = this.options.labels.error
|
||||
this._feedback.classList.add('error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_onSearchInput () {
|
||||
clearTimeout(this._refreshTimeout)
|
||||
if (this._searchInput.value === '') {
|
||||
this._clearResults()
|
||||
} else {
|
||||
this._refreshTimeout = setTimeout(() => this._fetchResults(), 250)
|
||||
}
|
||||
}
|
||||
|
||||
_onResultItemClick (event) {
|
||||
const elementIndex = Array.prototype.indexOf.call(this._resultList.children, event.target)
|
||||
this._selectIndex(elementIndex)
|
||||
const selectedLocation = this._getSelectedLocation()
|
||||
this._validateLocation(selectedLocation)
|
||||
}
|
||||
|
||||
_onResultListKeyDown (event) {
|
||||
const results = this._resultLocations
|
||||
if (event.keyCode === 38) {
|
||||
this._selectIndex(this._selectedIndex - 1)
|
||||
event.preventDefault()
|
||||
} else if (event.keyCode === 40) {
|
||||
if (this._selectedIndex === results.length - 1) {
|
||||
this._selectIndex(-1)
|
||||
} else {
|
||||
this._selectIndex(this._selectedIndex + 1)
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(SearchControl.prototype, L.Mixin.Events)
|
||||
|
||||
L.Control.Search = SearchControl
|
|
@ -11,6 +11,11 @@
|
|||
{% block map-include-geoloc-button %}
|
||||
data-include-geoloc-button="true"
|
||||
{% endblock %}
|
||||
{% block map-include-search-button %}
|
||||
{% if include_search_button %}
|
||||
data-search-url="{% url 'mapcell-geocoding' %}"
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% if group_markers %}data-group-markers="1"{% endif %}
|
||||
data-marker-behaviour-onclick="{{ cell.marker_behaviour_onclick }}"
|
||||
{% if max_bounds.corner1.lat %}
|
||||
|
|
|
@ -14,16 +14,15 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.conf.urls import re_path
|
||||
from django.urls import include
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from combo.urls_utils import decorated_includes, staff_required
|
||||
|
||||
from . import manager_views
|
||||
from .views import GeojsonView
|
||||
from .views import GeojsonView, geocoding_view
|
||||
|
||||
maps_manager_urls = [
|
||||
re_path('^$', manager_views.ManagerHomeView.as_view(), name='maps-manager-homepage'),
|
||||
path('', manager_views.ManagerHomeView.as_view(), name='maps-manager-homepage'),
|
||||
re_path(
|
||||
'^layers/add/(?P<kind>geojson|tiles)/$',
|
||||
manager_views.LayerAddView.as_view(),
|
||||
|
@ -68,4 +67,5 @@ urlpatterns = [
|
|||
GeojsonView.as_view(),
|
||||
name='mapcell-geojson',
|
||||
),
|
||||
path('api/geocoding', geocoding_view, name='mapcell-geocoding'),
|
||||
]
|
||||
|
|
|
@ -15,11 +15,16 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
from django.http import HttpResponse, HttpResponseForbidden
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.generic.base import View
|
||||
|
||||
from combo.utils import requests
|
||||
|
||||
from .models import Map
|
||||
|
||||
|
||||
|
@ -33,3 +38,21 @@ class GeojsonView(View):
|
|||
geojson = layer.get_geojson(request, options.properties)
|
||||
content_type = 'application/json'
|
||||
return HttpResponse(json.dumps(geojson), content_type=content_type)
|
||||
|
||||
|
||||
def geocoding_view(request, *args, **kwargs):
|
||||
if 'q' not in request.GET:
|
||||
return HttpResponseBadRequest()
|
||||
if not Map.objects.filter(include_search_button=True).exists():
|
||||
raise PermissionDenied()
|
||||
q = request.GET['q']
|
||||
url = settings.COMBO_MAP_GEOCODING_URL
|
||||
if '?' in url:
|
||||
url += '&'
|
||||
else:
|
||||
url += '?'
|
||||
url += 'format=json&q=%s' % urllib.parse.quote(q)
|
||||
url += '&accept-language=%s' % settings.LANGUAGE_CODE.split('-')[0]
|
||||
return HttpResponse(
|
||||
requests.get(url, without_user=True, remote_service=False).text, content_type='application/json'
|
||||
)
|
||||
|
|
|
@ -29,7 +29,7 @@ from .models import Notification
|
|||
class NotificationSerializer(serializers.Serializer):
|
||||
summary = serializers.CharField(required=True, allow_blank=False, max_length=140)
|
||||
id = serializers.CharField(required=False, allow_null=True)
|
||||
body = serializers.CharField(allow_blank=False, default='')
|
||||
body = serializers.CharField(allow_blank=True, allow_null=True, default='')
|
||||
url = serializers.URLField(allow_blank=True, default='')
|
||||
origin = serializers.CharField(allow_blank=True, default='')
|
||||
start_timestamp = serializers.DateTimeField(required=False, allow_null=True)
|
||||
|
@ -87,7 +87,7 @@ class Add(GenericAPIView):
|
|||
user=user,
|
||||
summary=payload['summary'],
|
||||
id=payload.get('id'),
|
||||
body=payload.get('body'),
|
||||
body=payload.get('body') or '',
|
||||
url=payload.get('url'),
|
||||
origin=payload.get('origin'),
|
||||
start_timestamp=payload.get('start_timestamp'),
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('notifications', '0007_display_condition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='notificationscell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -15,11 +15,15 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import pywebpush
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from py_vapid import Vapid
|
||||
|
@ -28,46 +32,89 @@ from combo.apps.notifications.models import Notification
|
|||
|
||||
from .models import PushSubscription, PwaSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_sub():
|
||||
webpush_mailto = getattr(settings, 'WEBPUSH_MAILTO', None)
|
||||
if webpush_mailto:
|
||||
return webpush_mailto
|
||||
|
||||
tenant_domain_url = getattr(getattr(connection, 'tenant', None), 'domain_url', None)
|
||||
if tenant_domain_url:
|
||||
return f'mailto:webpush@{tenant_domain_url}'
|
||||
|
||||
return 'mailto:webpush@combo.example.net'
|
||||
|
||||
|
||||
def get_vapid_headers(private_key, subscription_info):
|
||||
url = urllib.parse.urlparse(subscription_info['endpoint'])
|
||||
aud = f'{url.scheme}://{url.netloc}'
|
||||
|
||||
key_bytes = private_key.encode('ascii')
|
||||
|
||||
cache_key = 'v2-vapid-headers-' + hashlib.sha256(aud.encode() + key_bytes).hexdigest()
|
||||
headers = cache.get(cache_key)
|
||||
if headers:
|
||||
return headers
|
||||
|
||||
pwa_vapid_private_key = Vapid.from_pem(key_bytes)
|
||||
|
||||
headers = pwa_vapid_private_key.sign(
|
||||
{
|
||||
'aud': aud,
|
||||
'sub': get_sub(),
|
||||
'exp': int(datetime.datetime.now().timestamp() + 3600 * 24), # expire after 24 hours
|
||||
}
|
||||
)
|
||||
|
||||
cache.set(cache_key, headers, 23 * 3600) # but keep it 23 hours
|
||||
return headers
|
||||
|
||||
|
||||
class DeadSubscription(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def send_webpush(private_key, subscription_info, **kwargs):
|
||||
message = json.dumps(kwargs)
|
||||
|
||||
headers = get_vapid_headers(private_key, subscription_info)
|
||||
headers['Urgency'] = 'low'
|
||||
webpusher = pywebpush.WebPusher(subscription_info)
|
||||
response = webpusher.send(
|
||||
data=message,
|
||||
headers=headers,
|
||||
ttl=86400 * 30,
|
||||
)
|
||||
if response.status_code in (404, 410):
|
||||
raise DeadSubscription
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Notification)
|
||||
def notification(sender, instance=None, created=False, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
|
||||
pwa_settings = PwaSettings.singleton()
|
||||
if not pwa_settings.push_notifications:
|
||||
return
|
||||
if settings.PWA_VAPID_PRIVATE_KEY: # legacy
|
||||
pwa_vapid_private_key = settings.PWA_VAPID_PRIVATE_KEY
|
||||
else:
|
||||
pwa_vapid_private_key = Vapid.from_pem(
|
||||
pwa_settings.push_notifications_infos['private_key'].encode('ascii')
|
||||
)
|
||||
if settings.PWA_VAPID_CLAIMS: # legacy
|
||||
claims = settings.PWA_VAPID_CLAIMS
|
||||
else:
|
||||
claims = {
|
||||
'sub': 'mailto:%s' % settings.DEFAULT_FROM_EMAIL,
|
||||
'exp': int(datetime.datetime.now().timestamp() + 3600 * 3),
|
||||
}
|
||||
message = json.dumps(
|
||||
{
|
||||
'summary': instance.summary,
|
||||
'body': instance.body,
|
||||
'url': instance.url,
|
||||
}
|
||||
)
|
||||
|
||||
for subscription in PushSubscription.objects.filter(user_id=instance.user_id):
|
||||
private_key = pwa_settings.push_notifications_infos['private_key']
|
||||
|
||||
for subscription in PushSubscription.objects.filter(user=instance.user):
|
||||
try:
|
||||
pywebpush.webpush(
|
||||
send_webpush(
|
||||
private_key=private_key,
|
||||
subscription_info=subscription.subscription_info,
|
||||
data=message,
|
||||
vapid_private_key=pwa_vapid_private_key,
|
||||
vapid_claims=claims,
|
||||
summary=instance.summary,
|
||||
body=instance.body,
|
||||
url=instance.url,
|
||||
)
|
||||
except pywebpush.WebPushException as e:
|
||||
if 'Push failed: 410 Gone' in str(e):
|
||||
subscription.delete()
|
||||
continue
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.exception('webpush error (%r)', e)
|
||||
logger.info('webpush: notification sent')
|
||||
except DeadSubscription:
|
||||
subscription.delete()
|
||||
logger.info('webpush: deleting dead subscription')
|
||||
except Exception:
|
||||
logger.exception('webpush: request failed')
|
||||
|
|
|
@ -21,7 +21,11 @@ if ('serviceWorker' in navigator) {
|
|||
// Registration was successful
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
swRegistration = registration;
|
||||
combo_pwa_initialize();
|
||||
/* run pwa initialize after page loading, so that pwa-user-info event can
|
||||
be handled by the notification cell handler. */
|
||||
$(function () {
|
||||
combo_pwa_initialize();
|
||||
})
|
||||
}).catch(function(err) {
|
||||
// registration failed :(
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
|
@ -33,8 +37,12 @@ function combo_pwa_initialize() {
|
|||
swRegistration.pushManager.getSubscription()
|
||||
.then(function(subscription) {
|
||||
if (subscription !== null) {
|
||||
if (sessionStorage.getItem('push-subscription')) {
|
||||
combo_pwa_update_subscription_on_server(subscription);
|
||||
}
|
||||
COMBO_PWA_USER_SUBSCRIPTION = true;
|
||||
} else {
|
||||
sessionStorage.removeItem('push-subscription');
|
||||
COMBO_PWA_USER_SUBSCRIPTION = false;
|
||||
}
|
||||
$(document).trigger('combo:pwa-user-info');
|
||||
|
@ -70,7 +78,6 @@ function combo_pwa_unsubscribe_user() {
|
|||
console.log('Error unsubscribing', error);
|
||||
})
|
||||
.then(function() {
|
||||
combo_pwa_update_subscription_on_server(null);
|
||||
console.log('User is unsubscribed.');
|
||||
COMBO_PWA_USER_SUBSCRIPTION = false;
|
||||
$(document).trigger('combo:pwa-user-info');
|
||||
|
@ -85,6 +92,7 @@ function combo_pwa_update_subscription_on_server(subscription) {
|
|||
type: 'POST',
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
sessionStorage.setItem('push-subscription', 'registered')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -96,13 +96,12 @@ def subscribe_push(request, *args, **kwargs):
|
|||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest('bad json request: "%s"' % request.body)
|
||||
|
||||
if subscription_data is None:
|
||||
PushSubscription.objects.filter(user=request.user).delete()
|
||||
else:
|
||||
subscription, dummy = PushSubscription.objects.get_or_create(
|
||||
user=request.user, subscription_info=subscription_data
|
||||
)
|
||||
subscription.save()
|
||||
if not isinstance(subscription_data, dict) or not (set(subscription_data) >= {'keys', 'endpoint'}):
|
||||
return HttpResponseBadRequest('bad json request: "%s"' % subscription_data)
|
||||
subscription, dummy = PushSubscription.objects.get_or_create(
|
||||
user=request.user, subscription_info=subscription_data
|
||||
)
|
||||
subscription.save()
|
||||
return JsonResponse({'err': 0})
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('search', '0013_display_condition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='searchcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -14,20 +14,25 @@
|
|||
# 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 hashlib
|
||||
|
||||
from django import template
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.contenttypes import fields
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core import signing
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
from django.db.models import JSONField
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.template import RequestContext, Template
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.http import quote
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from combo.apps.wcs.utils import get_wcs_dependency_from_carddef_reference, get_wcs_json, get_wcs_services
|
||||
from combo.data.library import register_cell_class
|
||||
from combo.data.models import CellBase, Page
|
||||
from combo.utils import get_templated_url, requests
|
||||
|
@ -208,6 +213,13 @@ class SearchCell(CellBase):
|
|||
if '\x00' in query: # nul byte
|
||||
return HttpResponseBadRequest('invalid query string')
|
||||
|
||||
cell_context = {}
|
||||
if request.GET.get('ctx'):
|
||||
try:
|
||||
cell_context = signing.loads(request.GET['ctx'])
|
||||
except signing.BadSignature:
|
||||
return HttpResponseBadRequest('bad signature')
|
||||
|
||||
def render_response(service=None, results=None, pages=None):
|
||||
service = service or {}
|
||||
results = results or {'err': 0, 'data': []}
|
||||
|
@ -309,6 +321,7 @@ class SearchCell(CellBase):
|
|||
if hit_templates:
|
||||
for hit in results.get('data') or []:
|
||||
for k, v in hit_templates.items():
|
||||
hit['cell_context'] = cell_context
|
||||
hit[k] = v.render(RequestContext(request, hit))
|
||||
|
||||
return render_response(service, results, pages=pages)
|
||||
|
@ -319,6 +332,37 @@ class SearchCell(CellBase):
|
|||
def missing_index(self):
|
||||
return IndexedCell.objects.all().count() == 0
|
||||
|
||||
def get_dependencies(self):
|
||||
yield from super().get_dependencies()
|
||||
page_slugs = [
|
||||
e['slug'].replace('_text_page_', '')
|
||||
for e in self.search_services
|
||||
if e['slug'].startswith('_text_page_')
|
||||
]
|
||||
yield from Page.objects.filter(sub_slug='', slug__in=page_slugs)
|
||||
page_ids = [
|
||||
e['options']['target_page']
|
||||
for e in self.search_services
|
||||
if (e.get('options') or {}).get('target_page')
|
||||
]
|
||||
yield from Page.objects.filter(pk__in=page_ids)
|
||||
card_services = [
|
||||
e['slug'].replace('__without-user__', '')
|
||||
for e in self.search_services
|
||||
if e['slug'].startswith('cards:')
|
||||
]
|
||||
for key, service in get_wcs_services().items():
|
||||
card_models = get_wcs_json(service, 'api/cards/@list')
|
||||
for card_model in card_models.get('data') or []:
|
||||
service_key = 'cards:%s:%s' % (
|
||||
hashlib.md5(force_bytes(key)).hexdigest()[:8],
|
||||
card_model['id'],
|
||||
)
|
||||
if service_key in card_services:
|
||||
yield get_wcs_dependency_from_carddef_reference(
|
||||
'%s:%s' % (key, card_model['id']), card_model['text']
|
||||
)
|
||||
|
||||
|
||||
class IndexedCell(models.Model):
|
||||
cell_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
|
|
|
@ -22,10 +22,12 @@
|
|||
{% for engine in engines %}
|
||||
<li data-link-item-id="{{ engine.0 }}"><span class="handle">⣿</span>
|
||||
<span>{{ engine.1 }}{% if engine.2.title %} ({% trans "Custom title:"%} {{ engine.2.title }}){% endif %}</span>
|
||||
{% if engine.0 == '_text' or engine.0 == 'users' or engine.0|startswith:'cards:' %}
|
||||
<a rel="popup" title="{% trans "Edit" %}" class="link-action-icon edit" href="{% url 'combo-manager-page-search-cell-update-engine' page_pk=page.pk cell_reference=cell.get_reference engine_slug=engine.0 %}">{% trans "Edit" %}</a>
|
||||
{% if not is_readonly %}
|
||||
{% if engine.0 == '_text' or engine.0 == 'users' or engine.0|startswith:'cards:' %}
|
||||
<a rel="popup" title="{% trans "Edit" %}" class="link-action-icon edit" href="{% url 'combo-manager-page-search-cell-update-engine' page_pk=page.pk cell_reference=cell.get_reference engine_slug=engine.0 %}">{% trans "Edit" %}</a>
|
||||
{% endif %}
|
||||
<a title="{% trans "Delete" %}" class="link-action-icon delete" href="{% url 'combo-manager-page-search-cell-delete-engine' page_pk=page.pk cell_reference=cell.get_reference engine_slug=engine.0 %}">{% trans "Delete" %}</a>
|
||||
{% endif %}
|
||||
<a title="{% trans "Delete" %}" class="link-action-icon delete" href="{% url 'combo-manager-page-search-cell-delete-engine' page_pk=page.pk cell_reference=cell.get_reference engine_slug=engine.0 %}">{% trans "Delete" %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -50,7 +52,7 @@
|
|||
</script>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% if cell.available_engines %}
|
||||
{% if cell.available_engines and not is_readonly %}
|
||||
<div class="search-engine-add">
|
||||
{% trans "Add an engine:" %}
|
||||
{% for key, engine in cell.available_engines.items %}
|
||||
|
|
|
@ -18,7 +18,8 @@
|
|||
|
||||
{% block search-results %}
|
||||
{% for search_service in cell.search_services %}
|
||||
<div id="combo-search-results-{{ cell.pk }}-{{ forloop.counter }}" class="combo-search-results combo-search-results-{{ search_service.slug }}"></div>
|
||||
<div id="combo-search-results-{{ cell.pk }}-{{ forloop.counter }}"
|
||||
class="combo-search-results combo-search-results-{{ search_service.slug|split:":"|first }}"></div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
|
@ -29,6 +30,7 @@
|
|||
var last_search = null;
|
||||
var $form = $('#combo-search-form-{{ cell.pk }}');
|
||||
var $input = $('#combo-search-input-{{ cell.pk }}');
|
||||
var extra_context = $form.parents('[data-extra-context]').data('extra-context');
|
||||
{% for search_service in cell.search_services %}
|
||||
var $results_{{ forloop.counter }} = $('#combo-search-results-{{ cell.pk }}-{{ forloop.counter }}');
|
||||
var xhr_{{ forloop.counter }} = null;
|
||||
|
@ -44,7 +46,7 @@
|
|||
{% for search_service in cell.search_services %}
|
||||
if (xhr_{{ forloop.counter }}) xhr_{{ forloop.counter }}.abort();
|
||||
xhr_{{ forloop.counter }} = $.get(url_{{ forloop.counter }},
|
||||
{'q': new_search},
|
||||
{'q': new_search, 'ctx': decodeURIComponent(extra_context)},
|
||||
function (response) {
|
||||
xhr_{{ forloop.counter }} = null;
|
||||
$results_{{ forloop.counter }}.html(response);
|
||||
|
|
|
@ -45,6 +45,27 @@ class AppConfig(django.apps.AppConfig):
|
|||
|
||||
return engines
|
||||
|
||||
def get_backoffice_submission_engines(self, wcs_services):
|
||||
engines = {}
|
||||
for key, service in wcs_services.items():
|
||||
if len(wcs_services.keys()) == 1:
|
||||
label = _('Backoffice submission')
|
||||
else:
|
||||
label = _('Backoffice submission (%s)') % service['title']
|
||||
engines['backoffice-submission:%s' % (hashlib.md5(force_bytes(key)).hexdigest()[:8])] = {
|
||||
'url': (
|
||||
service['url'] + 'api/formdefs/?backoffice-submission=true'
|
||||
'&NameID={{ user_nameid }}&q=%(q)s'
|
||||
),
|
||||
'label': label,
|
||||
'signature': True,
|
||||
'hit_url_template': '{{ backoffice_submission_url }}?'
|
||||
'{% if cell_context.name_id %}NameID={{ cell_context.name_id }}&{% endif %}'
|
||||
'{% if cell_context.absolute_uri %}ReturnURL={{ cell_context.absolute_uri|iriencode }}{% endif %}',
|
||||
'hit_label_template': '{{ title }}',
|
||||
}
|
||||
return engines
|
||||
|
||||
def get_card_search_engines(self, wcs_services):
|
||||
from combo.data.models import Page
|
||||
|
||||
|
@ -91,6 +112,8 @@ class AppConfig(django.apps.AppConfig):
|
|||
'label': _('Tracking Code'),
|
||||
}
|
||||
}
|
||||
engines.update(self.get_backoffice_submission_engines(wcs_services))
|
||||
|
||||
for key, service in wcs_services.items():
|
||||
label = pgettext_lazy('user-forms', 'Forms')
|
||||
if len(wcs_services.keys()) > 1:
|
||||
|
|
|
@ -198,28 +198,36 @@ class WcsCardCellFiltersForm(forms.Form):
|
|||
continue
|
||||
field_schema = field_schemas[0]
|
||||
|
||||
options = {}
|
||||
for card in card_objects:
|
||||
card_fields = card.get('fields', {})
|
||||
card_fields.update(card.get('workflow', {}).get('fields', {}))
|
||||
value = card_fields.get(filter_id + '_raw')
|
||||
if not value:
|
||||
continue
|
||||
|
||||
display_value = card_fields[filter_id]
|
||||
if field_schema['type'] == 'item':
|
||||
options[value] = display_value
|
||||
else:
|
||||
for option_key, option_label in zip(value, display_value.split(', ')):
|
||||
options[option_key] = option_label
|
||||
if 'items' in field_schema:
|
||||
choices = [(x, x) for x in field_schema['items']]
|
||||
else:
|
||||
options = self.get_options_from_cards(card_objects, filter_id, field_schema)
|
||||
choices = sorted(options.items(), key=lambda x: x[1])
|
||||
|
||||
self.fields[filter_id] = forms.MultipleChoiceField(
|
||||
label=field_schema['label'],
|
||||
choices=sorted(options.items(), key=lambda x: x[1]),
|
||||
choices=choices,
|
||||
widget=MultipleSelect2Widget,
|
||||
)
|
||||
|
||||
self.prefix = 'c%s' % cell.get_reference()
|
||||
self.prefix = 'c%s' % cell.get_reference()
|
||||
|
||||
def get_options_from_cards(self, card_objects, filter_id, field_schema):
|
||||
options = {}
|
||||
for card in card_objects:
|
||||
card_fields = card.get('fields', {})
|
||||
card_fields.update(card.get('workflow', {}).get('fields', {}))
|
||||
value = card_fields.get(filter_id + '_raw')
|
||||
if not value:
|
||||
continue
|
||||
|
||||
display_value = card_fields[filter_id]
|
||||
if field_schema['type'] == 'item':
|
||||
options[value] = display_value
|
||||
else:
|
||||
for option_key, option_label in zip(value, display_value.split(', ')):
|
||||
options[option_key] = option_label
|
||||
return options
|
||||
|
||||
|
||||
class WcsCategoryCellForm(forms.ModelForm):
|
||||
|
|
|
@ -75,7 +75,7 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
'display_mode',
|
||||
models.CharField(
|
||||
choices=[('card', 'Card'), ('table', 'Table')],
|
||||
choices=[('card', 'Card'), ('table', 'Table'), ('list', 'List')],
|
||||
default='card',
|
||||
max_length=10,
|
||||
verbose_name='Display mode',
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
WcsCardCell = apps.get_model('wcs', 'WcsCardCell')
|
||||
|
||||
for cell in WcsCardCell.objects.order_by('pk'):
|
||||
if not cell.custom_schema:
|
||||
continue
|
||||
if cell.display_mode != 'table':
|
||||
continue
|
||||
if len(cell.custom_schema.get('cells')) != 1:
|
||||
continue
|
||||
cell.display_mode = 'list'
|
||||
cell.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('wcs', '0058_care_forms_by_card'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop),
|
||||
]
|
|
@ -0,0 +1,23 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
WcsCardCell = apps.get_model('wcs', 'WcsCardCell')
|
||||
|
||||
for cell in WcsCardCell.objects.order_by('pk'):
|
||||
if cell.display_mode != 'table':
|
||||
continue
|
||||
if cell.custom_schema:
|
||||
continue
|
||||
cell.display_mode = 'list'
|
||||
cell.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('wcs', '0059_cards_list_mode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop),
|
||||
]
|
|
@ -0,0 +1,62 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('wcs', '0060_cards_list_mode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='backofficesubmissioncell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='categoriescell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trackingcodeinputcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wcscardcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wcscareformscell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wcscategorycell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wcscurrentdraftscell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wcscurrentformscell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wcsformcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wcsformsofcategorycell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -45,6 +45,7 @@ from combo.utils.requests_wrapper import WaitForCacheException
|
|||
|
||||
from .utils import (
|
||||
get_matching_pages_from_card_slug,
|
||||
get_wcs_dependency_from_carddef_reference,
|
||||
get_wcs_json,
|
||||
get_wcs_matching_card_model,
|
||||
get_wcs_services,
|
||||
|
@ -121,7 +122,14 @@ class WcsFormCell(CellBase):
|
|||
wcs_site = get_wcs_services().get(wcs_key)
|
||||
forms_response_json = get_wcs_json(wcs_site, 'api/formdefs/')
|
||||
|
||||
if not forms_response_json or forms_response_json.get('err') == 1:
|
||||
if not forms_response_json:
|
||||
# can not retrieve data, don't report cell as invalid
|
||||
self.mark_as_valid()
|
||||
return
|
||||
|
||||
if forms_response_json.get('err') == 1:
|
||||
if forms_response_json.get('err_desc') == 'no-wcs-site':
|
||||
return self.mark_as_invalid('wcs_site_not_found')
|
||||
# can not retrieve data, don't report cell as invalid
|
||||
self.mark_as_valid()
|
||||
return
|
||||
|
@ -171,6 +179,18 @@ class WcsFormCell(CellBase):
|
|||
def render_for_search(self):
|
||||
return ''
|
||||
|
||||
def get_dependencies(self):
|
||||
yield from super().get_dependencies()
|
||||
if self.formdef_reference:
|
||||
wcs_key, form_slug = self.formdef_reference.split(':')
|
||||
wcs_site_url = get_wcs_services().get(wcs_key)['url']
|
||||
urls = {
|
||||
'export': f'{wcs_site_url}api/export-import/forms/{form_slug}/',
|
||||
'dependencies': f'{wcs_site_url}api/export-import/forms/{form_slug}/dependencies/',
|
||||
'redirect': f'{wcs_site_url}api/export-import/forms/{form_slug}/redirect/',
|
||||
}
|
||||
yield {'type': 'forms', 'id': form_slug, 'text': self.cached_title, 'urls': urls}
|
||||
|
||||
def get_external_links_data(self):
|
||||
if not (self.cached_url and self.cached_title):
|
||||
return []
|
||||
|
@ -228,7 +248,14 @@ class WcsCommonCategoryCell(CellBase):
|
|||
wcs_site = get_wcs_services().get(wcs_key)
|
||||
categories_response_json = get_wcs_json(wcs_site, 'api/categories/')
|
||||
|
||||
if not categories_response_json or categories_response_json.get('err') == 1:
|
||||
if not categories_response_json:
|
||||
# can not retrieve data, don't report cell as invalid
|
||||
self.mark_as_valid()
|
||||
return
|
||||
|
||||
if categories_response_json.get('err') == 1:
|
||||
if categories_response_json.get('err_desc') == 'no-wcs-site':
|
||||
return self.mark_as_invalid('wcs_site_not_found')
|
||||
# can not retrieve data, don't report cell as invalid
|
||||
self.mark_as_valid()
|
||||
return
|
||||
|
@ -278,6 +305,18 @@ class WcsCommonCategoryCell(CellBase):
|
|||
def get_inspect_keys(self):
|
||||
return [k for k in super().get_inspect_keys() if not k.startswith('cached_')]
|
||||
|
||||
def get_dependencies(self):
|
||||
yield from super().get_dependencies()
|
||||
if self.category_reference:
|
||||
wcs_key, category_slug = self.category_reference.split(':')
|
||||
wcs_site_url = get_wcs_services().get(wcs_key)['url']
|
||||
urls = {
|
||||
'export': f'{wcs_site_url}api/export-import/forms-categories/{category_slug}/',
|
||||
'dependencies': f'{wcs_site_url}api/export-import/forms-categories/{category_slug}/dependencies/',
|
||||
'redirect': f'{wcs_site_url}api/export-import/forms-xategories/{category_slug}/redirect/',
|
||||
}
|
||||
yield {'type': 'forms-categories', 'id': category_slug, 'text': self.cached_title, 'urls': urls}
|
||||
|
||||
|
||||
@register_cell_class
|
||||
class WcsCategoryCell(WcsCommonCategoryCell):
|
||||
|
@ -358,6 +397,7 @@ class WcsBlurpMixin:
|
|||
cache_duration=self.cache_duration,
|
||||
raise_if_not_cached=not (context.get('synchronous')),
|
||||
log_errors=False,
|
||||
django_request=context.get('request'),
|
||||
)
|
||||
returns.add(response.status_code)
|
||||
response.raise_for_status()
|
||||
|
@ -445,6 +485,8 @@ class WcsUserDataBaseCell(WcsDataBaseCell):
|
|||
|
||||
|
||||
class CategoriesAndWcsSiteValidityMixin:
|
||||
invalid_reason_codes = invalid_reason_codes
|
||||
|
||||
def check_validity(self):
|
||||
if self.wcs_site and self.wcs_site not in get_wcs_services():
|
||||
self.mark_as_invalid('wcs_site_not_found')
|
||||
|
@ -461,7 +503,14 @@ class CategoriesAndWcsSiteValidityMixin:
|
|||
wcs_site = get_wcs_services().get(wcs_key)
|
||||
categories_response_json = get_wcs_json(wcs_site, 'api/categories/')
|
||||
|
||||
if not categories_response_json or categories_response_json.get('err') == 1:
|
||||
if not categories_response_json:
|
||||
# can not retrieve data, don't report cell as invalid
|
||||
continue
|
||||
|
||||
if categories_response_json.get('err') == 1:
|
||||
if categories_response_json.get('err_desc') == 'no-wcs-site':
|
||||
self.mark_as_invalid('wcs_site_not_found')
|
||||
return
|
||||
# can not retrieve data, don't report cell as invalid
|
||||
continue
|
||||
|
||||
|
@ -612,6 +661,7 @@ class WcsCurrentDraftsCell(CategoriesAndWcsSiteValidityMixin, CategoriesFilterin
|
|||
variable_name = 'current_drafts'
|
||||
default_template_name = 'combo/wcs/current_drafts.html'
|
||||
loading_message = _('Loading drafts...')
|
||||
invalid_reason_codes = invalid_reason_codes
|
||||
|
||||
categories = JSONField(_('Categories'), blank=True, default=dict)
|
||||
|
||||
|
@ -700,8 +750,16 @@ class WcsFormsOfCategoryCell(WcsCommonCategoryCell, WcsBlurpMixin, CardIdMixin):
|
|||
wcs_site = get_wcs_services().get(wcs_key)
|
||||
categories_response_json = get_wcs_json(wcs_site, 'api/categories/')
|
||||
|
||||
if not categories_response_json or categories_response_json.get('err') == 1:
|
||||
if not categories_response_json:
|
||||
# can not retrieve data, don't report cell as invalid
|
||||
self.mark_as_valid()
|
||||
return
|
||||
|
||||
if categories_response_json.get('err') == 1:
|
||||
if categories_response_json.get('err_desc') == 'no-wcs-site':
|
||||
return self.mark_as_invalid('wcs_site_not_found')
|
||||
# can not retrieve data, don't report cell as invalid
|
||||
self.mark_as_valid()
|
||||
return
|
||||
|
||||
category_found = any(
|
||||
|
@ -848,6 +906,7 @@ class CategoriesCell(WcsDataBaseCell):
|
|||
variable_name = 'form_categories'
|
||||
default_template_name = 'combo/wcs/form_categories.html'
|
||||
cache_duration = 120
|
||||
invalid_reason_codes = invalid_reason_codes
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Form Categories')
|
||||
|
@ -922,6 +981,7 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
choices=[
|
||||
('card', pgettext_lazy('card-display-mode', 'Card')),
|
||||
('table', pgettext_lazy('card-display-mode', 'Table')),
|
||||
('list', pgettext_lazy('card-display-mode', 'List')),
|
||||
],
|
||||
)
|
||||
filters = ArrayField(models.CharField(max_length=128), default=list, blank=True)
|
||||
|
@ -997,6 +1057,8 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
if card_schema.get('err') == 1:
|
||||
if card_schema.get('err_class') == 'Page not found':
|
||||
self.mark_as_invalid('wcs_card_not_found')
|
||||
elif card_schema.get('err_desc') == 'no-wcs-site':
|
||||
self.mark_as_invalid('wcs_site_not_found')
|
||||
else:
|
||||
self.mark_as_valid()
|
||||
return
|
||||
|
@ -1016,6 +1078,23 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
return False
|
||||
return super().is_visible(request, **kwargs)
|
||||
|
||||
def get_computed_strings(self):
|
||||
yield from super().get_computed_strings()
|
||||
yield self.card_ids
|
||||
for cell in self.get_custom_schema().get('cells') or []:
|
||||
yield from [str(v) for v in cell.values() if v]
|
||||
|
||||
def get_dependencies(self):
|
||||
yield from super().get_dependencies()
|
||||
if self.carddef_reference:
|
||||
yield get_wcs_dependency_from_carddef_reference(self.carddef_reference, self.cached_title)
|
||||
for cell in self.get_custom_schema().get('cells') or []:
|
||||
if cell.get('page') not in ['', None]:
|
||||
try:
|
||||
yield Page.objects.get(pk=cell['page'])
|
||||
except Page.DoesNotExist:
|
||||
pass
|
||||
|
||||
def check_validity(self):
|
||||
if self.get_related_card_path():
|
||||
relations = [r[0] for r in self.get_related_card_paths()]
|
||||
|
@ -1028,7 +1107,9 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
@property
|
||||
def default_template_name(self):
|
||||
if self.display_mode == 'table':
|
||||
return 'combo/wcs/cards.html'
|
||||
return 'combo/wcs/cards-as-table.html'
|
||||
if self.display_mode == 'list':
|
||||
return 'combo/wcs/cards-as-list.html'
|
||||
return 'combo/wcs/card.html'
|
||||
|
||||
def get_serialized_cell(self):
|
||||
|
@ -1059,15 +1140,15 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
return '%s-card-ids' % self.get_reference()
|
||||
|
||||
def modify_global_context(self, context, request):
|
||||
if self.display_mode == 'table' and not context.get('synchronous'):
|
||||
if self.display_mode in ['table', 'list'] and not context.get('synchronous'):
|
||||
# don't call wcs on page loading
|
||||
return
|
||||
if self.carddef_reference and self.global_context_key not in context:
|
||||
context[self.global_context_key] = LazyValue(lambda: self.resolve_card_ids(context, request))
|
||||
|
||||
def get_repeat_template(self, context):
|
||||
if self.display_mode == 'table':
|
||||
# don't repeat cell if table display mode
|
||||
if self.display_mode in ['table', 'list']:
|
||||
# don't repeat cell if table/list display mode
|
||||
return []
|
||||
return len(self.get_card_ids(context))
|
||||
|
||||
|
@ -1100,7 +1181,7 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
synchronous = bool(context.get('synchronous'))
|
||||
wcs_site = get_wcs_services().get(self.wcs_site)
|
||||
|
||||
if wait_for_cache:
|
||||
if not synchronous and wait_for_cache:
|
||||
cache_key = requests.get_cache_key(
|
||||
url=requests._build_url(
|
||||
api_url,
|
||||
|
@ -1122,6 +1203,7 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
cache_duration=5,
|
||||
raise_if_not_cached=not synchronous,
|
||||
log_errors=False,
|
||||
django_request=context.get('request'),
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException:
|
||||
|
@ -1198,6 +1280,7 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
cache_duration=5,
|
||||
raise_if_not_cached=not synchronous,
|
||||
log_errors=False,
|
||||
django_request=context.get('request'),
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException:
|
||||
|
@ -1467,8 +1550,8 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
return self.filter_card_ids(card_ids, original_context)
|
||||
|
||||
if self.must_get_all():
|
||||
if self.display_mode == 'table':
|
||||
# don't call wcs if table mode with all cards
|
||||
if self.display_mode in ['table', 'list']:
|
||||
# don't call wcs if table/list mode with all cards
|
||||
return []
|
||||
# get all cards
|
||||
return [c['id'] for c in self.get_cards_from_ids([], original_context, synchronous=True)]
|
||||
|
@ -1521,6 +1604,10 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
return get_matching_pages_from_card_slug(self.card_slug, order=order)
|
||||
|
||||
def get_cell_extra_context(self, context):
|
||||
if context.get('placeholder_search_mode'):
|
||||
# don't call webservices when we're just looking for placeholders
|
||||
return {}
|
||||
|
||||
if not context.get('synchronous'):
|
||||
raise NothingInCacheException()
|
||||
|
||||
|
@ -1528,7 +1615,6 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
|
||||
extra_context = super().get_cell_extra_context(context)
|
||||
if self.title_type in ['auto', 'manual']:
|
||||
# card mode: default value used if card is not found
|
||||
extra_context['title'] = self.cached_title
|
||||
extra_context['fields_by_varnames'] = {
|
||||
i['varname']: i for i in (cached_json.get('fields') or []) if i.get('varname')
|
||||
|
@ -1608,6 +1694,23 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
}
|
||||
)
|
||||
|
||||
if not (self.related_card_path or self.card_ids) and (
|
||||
card_data.get('digest') or card_data.get('digests')
|
||||
):
|
||||
# card linked to page, set attribute to have card title in page <title>
|
||||
page_title_from_cell = card_data.get('digest')
|
||||
if card_data.get('digests', {}).get('default'):
|
||||
page_title_from_cell = card_data['digests']['default']
|
||||
parts = self.carddef_reference.split(':')
|
||||
if len(parts) == 3:
|
||||
digest_name = f'custom-view:{parts[-1]}'
|
||||
if card_data.get('digests', {}).get(digest_name):
|
||||
page_title_from_cell = card_data['digests'][digest_name]
|
||||
if page_title_from_cell:
|
||||
# header values can't contain newlines
|
||||
page_title_from_cell = page_title_from_cell.replace('\n', ' ')
|
||||
custom_context['request'].page_title_from_cell = page_title_from_cell
|
||||
|
||||
def set_data_from_repeated_cell(self, cell, context):
|
||||
if not hasattr(cell, '_cards_data'):
|
||||
cell._cards_data = None
|
||||
|
@ -1628,9 +1731,19 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
def get_cell_extra_context_table_mode(self, context, extra_context):
|
||||
from .forms import WcsCardCellFiltersForm
|
||||
|
||||
extra_context['schema'] = self.cached_json
|
||||
|
||||
extra_context['paginate_by'] = self.limit or 10
|
||||
custom_context = Context(extra_context, autoescape=False)
|
||||
custom_context.update(context)
|
||||
custom_context.update(self.page.get_extra_variables(context.get('request'), context))
|
||||
if self.title_type == 'manual':
|
||||
extra_context['title'] = self.custom_title or extra_context['title']
|
||||
try:
|
||||
extra_context['title'] = Template(self.custom_title).render(custom_context)
|
||||
except (VariableDoesNotExist, TemplateSyntaxError):
|
||||
extra_context['title'] = ''
|
||||
if self.title_type == 'auto' or self.title_type == 'manual' and not extra_context['title']:
|
||||
extra_context['title'] = self.cached_title
|
||||
if not self.carddef_reference:
|
||||
# not configured
|
||||
return extra_context
|
||||
|
@ -1657,6 +1770,9 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
|
||||
return extra_context
|
||||
|
||||
def get_cell_extra_context_list_mode(self, context, extra_context):
|
||||
return self.get_cell_extra_context_table_mode(context, extra_context)
|
||||
|
||||
def get_cell_extra_context_card_mode(self, context, extra_context):
|
||||
extra_context['schema'] = self.cached_json
|
||||
|
||||
|
@ -1674,6 +1790,7 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
extra_context['card'] = card_data
|
||||
custom_context = Context(extra_context, autoescape=False)
|
||||
custom_context.update(context)
|
||||
custom_context.update(self.page.get_extra_variables(context.get('request'), context))
|
||||
repeat_index = getattr(self, 'repeat_index', context.get('repeat_index')) or 0
|
||||
custom_context['repeat_index'] = repeat_index
|
||||
if self.title_type == 'manual':
|
||||
|
@ -1722,7 +1839,7 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
def get_custom_schema(self):
|
||||
custom_schema = copy.deepcopy(self.custom_schema or {})
|
||||
|
||||
if self.display_mode == 'table':
|
||||
if self.display_mode in ['table', 'list']:
|
||||
# missing default values
|
||||
if custom_schema.get('cells') and not custom_schema.get('grid_headers'):
|
||||
custom_schema['grid_headers'] = False
|
||||
|
@ -1757,13 +1874,13 @@ class WcsCardCell(CardMixin, CellBase):
|
|||
|
||||
def get_asset_slot_key(self, key):
|
||||
# for legacy
|
||||
if self.display_mode == 'table':
|
||||
if self.display_mode in ['table', 'list']:
|
||||
return 'cell:wcs_wcscardscell:%s:%s' % (key, self.get_slug_for_asset())
|
||||
return 'cell:wcs_wcscardinfoscell:%s:%s' % (key, self.get_slug_for_asset())
|
||||
|
||||
def get_asset_slot_templates(self):
|
||||
# for legacy
|
||||
if self.display_mode == 'table':
|
||||
if self.display_mode in ['table', 'list']:
|
||||
return settings.COMBO_CELL_ASSET_SLOTS.get('wcs_wcscardscell')
|
||||
return settings.COMBO_CELL_ASSET_SLOTS.get('wcs_wcscardinfoscell')
|
||||
|
||||
|
@ -1773,6 +1890,7 @@ class TrackingCodeInputCell(CellBase):
|
|||
is_enabled = classmethod(is_wcs_enabled)
|
||||
wcs_site = models.CharField(_('Site'), max_length=50, blank=True)
|
||||
default_template_name = 'combo/wcs/tracking_code_input.html'
|
||||
invalid_reason_codes = invalid_reason_codes
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Tracking Code Input')
|
||||
|
@ -1794,13 +1912,6 @@ class TrackingCodeInputCell(CellBase):
|
|||
self.__class__, fields=['wcs_site'], widgets={'wcs_site': Select(choices=combo_wcs_sites)}
|
||||
)
|
||||
|
||||
def get_cell_extra_context(self, context):
|
||||
extra_context = super().get_cell_extra_context(context)
|
||||
if not self.wcs_site:
|
||||
self.wcs_site = list(get_wcs_services().keys())[0]
|
||||
extra_context['url'] = (get_wcs_services().get(self.wcs_site) or {}).get('url')
|
||||
return extra_context
|
||||
|
||||
|
||||
@register_cell_class
|
||||
class BackofficeSubmissionCell(CategoriesAndWcsSiteValidityMixin, CategoriesFilteringMixin, WcsDataBaseCell):
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% load combo %}{% spaceless %}
|
||||
{% if field.type == "text" and field.display_mode == 'rich' and value %}
|
||||
{% if field.type == "text" and field.display_mode == 'rich' and value or field.type == "text" and field.display_mode == 'basic-rich' and value %}
|
||||
{% if cell.display_mode == 'table' or cell.display_mode == 'card' and item.display_mode == 'text' %}
|
||||
<div class="value">{{ value|safe }}</div>
|
||||
{% else %}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
{% load i18n gadjo %}
|
||||
|
||||
{% block cell-content %}
|
||||
|
||||
{% block cell-header %}
|
||||
{% if title %}<h2>{{ title|force_escape }}</h2>{% endif %}
|
||||
{% include "combo/asset_picture_fragment.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% if card_objects %}
|
||||
{% if cell.filters %}
|
||||
<form class="wcs-card-filters pk-mark-optional-fields {% if cell.inline_filters %}inline-display{% endif %}">{{ filters_form|with_template }}</form>
|
||||
{% endif %}
|
||||
{% with cell.get_custom_schema as custom_schema %}
|
||||
<div class="links-list cards-{{ cell.card_slug }} list-of-cards">
|
||||
<ul>
|
||||
{% for card in card_objects %}
|
||||
<li {{ cell|get_filter_attrs:card }}>
|
||||
{% spaceless %}
|
||||
{% if custom_schema %}
|
||||
{% include "combo/wcs/cards-field.html" with item=custom_schema.cells.0 ul_display=True %}
|
||||
{% else %}
|
||||
<a href="{% if card_page_base_url %}{{ card_page_base_url }}{{ card.id }}/{% else %}{{ card.url }}{% endif %}"><span class="card-title">{{ card.text }}</span></a>
|
||||
{% endif %}
|
||||
{% endspaceless %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% include "combo/pagination.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div class="cell--body"><p class="empty-message">{% trans "There are no cards." %}</p></div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
|
@ -12,11 +12,11 @@
|
|||
<form class="wcs-card-filters pk-mark-optional-fields {% if cell.inline_filters %}inline-display{% endif %}">{{ filters_form|with_template }}</form>
|
||||
{% endif %}
|
||||
{% with cell.get_custom_schema as custom_schema %}
|
||||
{% if cell.custom_schema and cell.custom_schema.cells|length > 1 %}
|
||||
<div class="pk-table-wrapper">
|
||||
<table class="pk-data-table pk-table-headers">
|
||||
{% if custom_schema.grid_headers %}
|
||||
<thead>
|
||||
<div class="pk-table-wrapper">
|
||||
<table class="pk-data-table pk-table-headers">
|
||||
{% if custom_schema.grid_headers or not custom_schema %}
|
||||
<thead>
|
||||
{% if custom_schema %}
|
||||
{% for item in custom_schema.cells %}
|
||||
{% if item.varname == "@custom@" %}
|
||||
{% if item.template %}
|
||||
|
@ -37,36 +37,36 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</thead>
|
||||
{% endif %}
|
||||
<tbody>
|
||||
{% for card in card_objects %}
|
||||
<tr {{ cell|get_filter_attrs:card }}>
|
||||
{% else %}
|
||||
{% for field in schema.fields %}
|
||||
{% if 'varname' in field and field.varname and field.type != 'page' %}
|
||||
<th>{{ field.label }}</th>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</thead>
|
||||
{% endif %}
|
||||
<tbody>
|
||||
{% for card in card_objects %}
|
||||
<tr {{ cell|get_filter_attrs:card }}>
|
||||
{% if custom_schema %}
|
||||
{% for item in custom_schema.cells %}
|
||||
{% include "combo/wcs/cards-field.html" %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="links-list cards-{{ cell.card_slug }} list-of-cards">
|
||||
<ul>
|
||||
{% for card in card_objects %}
|
||||
<li {{ cell|get_filter_attrs:card }}>
|
||||
{% spaceless %}
|
||||
{% if custom_schema %}
|
||||
{% include "combo/wcs/cards-field.html" with item=custom_schema.cells.0 ul_display=True %}
|
||||
{% else %}
|
||||
<a href="{% if card_page_base_url %}{{ card_page_base_url }}{{ card.id }}/{% else %}{{ card.url }}{% endif %}"><span class="card-title">{{ card.text }}</span></a>
|
||||
{% endif %}
|
||||
{% endspaceless %}
|
||||
</li>
|
||||
{% else %}
|
||||
{% for field in schema.fields %}
|
||||
{% if 'varname' in field and field.varname and field.type != 'page' %}
|
||||
{% with card.fields|get:field.varname as value %}
|
||||
<td>{% include "combo/wcs/card-field-value.html" with mode='inline' %}</td>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "combo/pagination.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
|
@ -0,0 +1,211 @@
|
|||
{% load i18n %}
|
||||
|
||||
{# UI to customize content layout #}
|
||||
<div class="as-card wcs-cards-cell--grid">
|
||||
<div class="as-card wcs-cards-cell--grid-options">
|
||||
<span class="as-card wcs-cards-cell--grid-layout-label">{% trans "Grid Layout:" %}</span>
|
||||
<span class="as-card wcs-cards-cell--grid-layout-mode"></span>
|
||||
<a role="button" class="as-card wcs-cards-cell--grid-layout-btn">
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="as-card wcs-cards-cell--grid-cells">
|
||||
</div>
|
||||
<div class="as-card wcs-cards-cell--grid-buttons">
|
||||
<button type="button" class="as-card wcs-cards-cell--add-grid-cell-btn">{% trans "Add" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# templates for JS #}
|
||||
<template class="as-card wcs-cards-cell--grid-form-tpl">
|
||||
<form>
|
||||
<p>
|
||||
{% trans "Layout" %}
|
||||
<select name="grid-layout">
|
||||
<option value="fx-grid--auto">{% trans "Automatic" %}</option>
|
||||
<option value="fx-grid">{% trans "1 column" %}</option>
|
||||
<option value="fx-grid--t2">{% trans "2 columns" %}</option>
|
||||
<option value="fx-grid--t3">{% trans "3 columns" %}</option>
|
||||
</select>
|
||||
</p>
|
||||
</form>
|
||||
</template>
|
||||
<template class="as-card wcs-cards-cell--grid-cell-form-tpl">
|
||||
<form>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Content type" %}
|
||||
<select name="entry_type" data-dynamic-display-parent="true">
|
||||
<option value="@field@">{% trans "Card field" %}</option>
|
||||
<option value="@user-field@">{% trans "User field" %}</option>
|
||||
<option value="@info-field@">{% trans "Card information field" %}</option>
|
||||
<option value="@custom@">{% trans "Custom" %}</option>
|
||||
<option value="@link@">{% trans "Link" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
|
||||
{# fields group for "content type == @field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Card Fields" %}
|
||||
<select name="field_varname" data-dynamic-display-parent="true"></select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @user-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@user-field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "User Fields" %}
|
||||
<select name="user_field_varname" data-dynamic-display-parent="true"></select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @info-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@info-field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Card Information Fields" %}
|
||||
<select name="info_field_varname" data-dynamic-display-parent="true">
|
||||
<option value="info:id">{% trans "Identifier" %}</option>
|
||||
<option value="info:receipt_time">{% trans "Receipt date" %}</option>
|
||||
<option value="info:last_update_time">{% trans "Last modified" %}</option>
|
||||
<option value="info:status">{% trans "Status" %}</option>
|
||||
<option value="info:text">{% trans "Text" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @field@" and "content type == @user-field@" and "content type == @info-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value-in=" @field@ @user-field@ @info-field@ ">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Field content" %}
|
||||
<select name="field_content" data-dynamic-display-parent="true">
|
||||
<option value="label-and-value">{% trans "Label & Value" %}</option>
|
||||
<option value="label">{% trans "Label only" %}</option>
|
||||
<option value="value">{% trans "Value only" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="field_content" data-dynamic-display-value-in=" label value ">
|
||||
<label>
|
||||
{% trans "Display mode" %}
|
||||
<select name="field_display_mode">
|
||||
<option value="text">{% trans "Text" %}</option>
|
||||
<option value="title">{% trans "Title" %}</option>
|
||||
<option value="subtitle">{% trans "Subtitle" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="field_varname" data-dynamic-display-value-in=" {% for field in card_schema.fields %}{% if field.type == 'file' %}{{ field.varname }} {% endif %}{% endfor %} ">
|
||||
<label>
|
||||
{% trans "File display mode" %}
|
||||
<select name="file_field_display_mode">
|
||||
<option value="link">{% trans "Link" %}</option>
|
||||
<option value="thumbnail">{% trans "Thumbnail" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="entry_type" data-dynamic-display-value-in=" @field@ @user-field@ ">
|
||||
<label>
|
||||
{% trans "Empty value display mode" %}
|
||||
<select name="field_empty_display_mode" data-dynamic-display-parent="true">
|
||||
<option value="@empty@">{% trans "Display as empty" %}</option>
|
||||
<option value="@skip@">{% trans "Hide" %}</option>
|
||||
<option value="@custom@">{% trans "Display a custom text" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="field_empty_display_mode" data-dynamic-display-value="@custom@">
|
||||
<label>
|
||||
{% trans "Empty value custom text" %}
|
||||
<input name="field_empty_text" />
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @custom@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@custom@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Value template" %}
|
||||
<textarea name="custom_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Display mode" %}
|
||||
<select name="custom_display_mode">
|
||||
<option value="label">{% trans "Label" %}</option>
|
||||
<option value="text">{% trans "Text" %}</option>
|
||||
<option value="title">{% trans "Title" %}</option>
|
||||
<option value="subtitle">{% trans "Subtitle" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @link@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@link@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Label template" %}
|
||||
<textarea name="link_label_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Link destination" %}
|
||||
<select name="link_page" data-dynamic-display-parent="true">
|
||||
{% for page in cell.get_matching_pages %}
|
||||
<option value="{{ page.pk }}">{{ page.get_full_path_titles }}</option>
|
||||
{% endfor %}
|
||||
<option value="">{% trans "URL (Template)" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="link_page" data-dynamic-display-value="">
|
||||
<label>
|
||||
<textarea name="link_url_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Display mode" %}
|
||||
<select name="link_display_mode">
|
||||
<option value="link">{% trans "Link" %}</option>
|
||||
<option value="button">{% trans "Button" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Size" %}
|
||||
<select name="cell_size">
|
||||
<option value="">{% trans "Automatic" %}</option>
|
||||
<option value="size--1-1">1/1</option>
|
||||
<option value="size--t1-2">1/2</option>
|
||||
<option value="size--t1-3">1/3</option>
|
||||
<option value="size--t2-3">2/3</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</form>
|
||||
</template>
|
||||
<template class="as-card wcs-cards-cell--grid-cell-tpl">
|
||||
<div class="as-card wcs-cards-cell--grid-cell">
|
||||
<div class="as-card wcs-cards-cell--grid-cell-content"></div>
|
||||
<div class="as-card wcs-cards-cell--grid-cell-buttons">
|
||||
<a role="button" class="as-card wcs-cards-cell--grid-cell-edit">{% trans "Edit" %}</a>
|
||||
<a role="button" class="as-card wcs-cards-cell--grid-cell-delete">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,42 @@
|
|||
{% load i18n %}
|
||||
|
||||
{# UI to customize content layout #}
|
||||
<div class="as-list wcs-cards-cell--grid">
|
||||
<div class="as-list wcs-cards-cell--grid-cells">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# templates for JS #}
|
||||
<template class="as-list wcs-cards-cell--grid-cell-form-tpl">
|
||||
<form>
|
||||
{# fields group for "content type == @link@" #}
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Content type" %}
|
||||
<select name="entry_type">
|
||||
<option value="@link@">{% trans "Link" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Label template" %}
|
||||
<textarea name="link_label_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "URL (Template)" %}
|
||||
<textarea name="link_url_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
</form>
|
||||
</template>
|
||||
<template class="as-list wcs-cards-cell--grid-cell-tpl">
|
||||
<div class="as-list wcs-cards-cell--grid-cell">
|
||||
<div class="as-list wcs-cards-cell--grid-cell-content"></div>
|
||||
<div class="as-list wcs-cards-cell--grid-cell-buttons">
|
||||
<a role="button" class="as-list wcs-cards-cell--grid-cell-edit">{% trans "Edit" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,164 @@
|
|||
{% load i18n %}
|
||||
|
||||
{# UI to customize content layout #}
|
||||
<div class="as-table wcs-cards-cell--grid">
|
||||
<div class="as-table wcs-cards-cell--grid-options">
|
||||
<span class="as-table wcs-cards-cell--grid-headers-label">{% trans "Display headers:" %}</span>
|
||||
<span class="as-table wcs-cards-cell--grid-headers-mode"></span>
|
||||
<a role="button" class="as-table wcs-cards-cell--grid-headers-btn">
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="as-table wcs-cards-cell--grid-cells">
|
||||
</div>
|
||||
<div class="as-table wcs-cards-cell--grid-buttons">
|
||||
<button type="button" class="as-table wcs-cards-cell--add-grid-cell-btn">{% trans "Add" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# templates for JS #}
|
||||
<template class="as-table wcs-cards-cell--grid-form-tpl">
|
||||
<form>
|
||||
<p>
|
||||
{% trans "Display headers" %}
|
||||
<input name="grid-headers" type="checkbox" />
|
||||
</p>
|
||||
</form>
|
||||
</template>
|
||||
<template class="as-table wcs-cards-cell--grid-cell-form-tpl">
|
||||
<form>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Content type" %}
|
||||
<select name="entry_type" data-dynamic-display-parent="true">
|
||||
<option value="@field@">{% trans "Card field" %}</option>
|
||||
<option value="@user-field@">{% trans "User field" %}</option>
|
||||
<option value="@info-field@">{% trans "Card information field" %}</option>
|
||||
<option value="@custom@">{% trans "Custom" %}</option>
|
||||
<option value="@link@">{% trans "Link" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
|
||||
{# fields group for "content type == @field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Card Fields" %}
|
||||
<select name="field_varname" data-dynamic-display-parent="true"></select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @user-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@user-field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "User Fields" %}
|
||||
<select name="user_field_varname" data-dynamic-display-parent="true"></select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @info-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@info-field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Card Information Fields" %}
|
||||
<select name="info_field_varname" data-dynamic-display-parent="true">
|
||||
<option value="info:id">{% trans "Identifier" %}</option>
|
||||
<option value="info:receipt_time">{% trans "Receipt date" %}</option>
|
||||
<option value="info:last_update_time">{% trans "Last modified" %}</option>
|
||||
<option value="info:status">{% trans "Status" %}</option>
|
||||
<option value="info:text">{% trans "Text" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @field@" and "content type == @user-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value-in=" @field@ @user-field@ ">
|
||||
<p data-dynamic-display-child-of="field_varname" data-dynamic-display-value-in=" {% for field in card_schema.fields %}{% if field.type == 'file' %}{{ field.varname }} {% endif %}{% endfor %} ">
|
||||
<label>
|
||||
{% trans "File display mode" %}
|
||||
<select name="file_field_display_mode">
|
||||
<option value="link">{% trans "Link" %}</option>
|
||||
<option value="thumbnail">{% trans "Thumbnail" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Custom text to replace empty value" %}
|
||||
<input name="field_empty_text" />
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @custom@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@custom@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Header" %}
|
||||
<input name="custom_header" />
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Value template" %}
|
||||
<textarea name="custom_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @link@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@link@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Header" %}
|
||||
<input name="link_header" />
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Label template" %}
|
||||
<textarea name="link_label_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Link destination" %}
|
||||
<select name="link_page" data-dynamic-display-parent="true">
|
||||
{% for page in cell.get_matching_pages %}
|
||||
<option value="{{ page.pk }}">{{ page.get_full_path_titles }}</option>
|
||||
{% endfor %}
|
||||
<option value="">{% trans "URL (Template)" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="link_page" data-dynamic-display-value="">
|
||||
<label>
|
||||
<textarea name="link_url_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Display mode" %}
|
||||
<select name="link_display_mode">
|
||||
<option value="link">{% trans "Link" %}</option>
|
||||
<option value="button">{% trans "Button" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template class="as-table wcs-cards-cell--grid-cell-tpl">
|
||||
<div class="as-table wcs-cards-cell--grid-cell">
|
||||
<div class="as-table wcs-cards-cell--grid-cell-content"></div>
|
||||
<div class="as-table wcs-cards-cell--grid-cell-buttons">
|
||||
<a role="button" class="as-table wcs-cards-cell--grid-cell-edit">{% trans "Edit" %}</a>
|
||||
<a role="button" class="as-table wcs-cards-cell--grid-cell-delete">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -6,378 +6,12 @@
|
|||
{{ card_schema|json_script:card_schema_id }}
|
||||
|
||||
{# display mode as card #}
|
||||
|
||||
{# UI to customize content layout #}
|
||||
<div class="as-card wcs-cards-cell--grid">
|
||||
<div class="as-card wcs-cards-cell--grid-options">
|
||||
<span class="as-card wcs-cards-cell--grid-layout-label">{% trans "Grid Layout:" %}</span>
|
||||
<span class="as-card wcs-cards-cell--grid-layout-mode"></span>
|
||||
<a role="button" class="as-card wcs-cards-cell--grid-layout-btn">
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="as-card wcs-cards-cell--grid-cells">
|
||||
</div>
|
||||
<div class="as-card wcs-cards-cell--grid-buttons">
|
||||
<button type="button" class="as-card wcs-cards-cell--add-grid-cell-btn">{% trans "Add" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# templates for JS #}
|
||||
<template class="as-card wcs-cards-cell--grid-form-tpl">
|
||||
<form>
|
||||
<p>
|
||||
{% trans "Layout" %}
|
||||
<select name="grid-layout">
|
||||
<option value="fx-grid--auto">{% trans "Automatic" %}</option>
|
||||
<option value="fx-grid">{% trans "1 column" %}</option>
|
||||
<option value="fx-grid--t2">{% trans "2 columns" %}</option>
|
||||
<option value="fx-grid--t3">{% trans "3 columns" %}</option>
|
||||
</select>
|
||||
</p>
|
||||
</form>
|
||||
</template>
|
||||
<template class="as-card wcs-cards-cell--grid-cell-form-tpl">
|
||||
<form>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Content type" %}
|
||||
<select name="entry_type" data-dynamic-display-parent="true">
|
||||
<option value="@field@">{% trans "Card field" %}</option>
|
||||
<option value="@user-field@">{% trans "User field" %}</option>
|
||||
<option value="@info-field@">{% trans "Card information field" %}</option>
|
||||
<option value="@custom@">{% trans "Custom" %}</option>
|
||||
<option value="@link@">{% trans "Link" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
|
||||
{# fields group for "content type == @field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Card Fields" %}
|
||||
<select name="field_varname" data-dynamic-display-parent="true"></select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @user-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@user-field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "User Fields" %}
|
||||
<select name="user_field_varname" data-dynamic-display-parent="true"></select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @info-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@info-field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Card Information Fields" %}
|
||||
<select name="info_field_varname" data-dynamic-display-parent="true">
|
||||
<option value="info:id">{% trans "Identifier" %}</option>
|
||||
<option value="info:receipt_time">{% trans "Receipt date" %}</option>
|
||||
<option value="info:last_update_time">{% trans "Last modified" %}</option>
|
||||
<option value="info:status">{% trans "Status" %}</option>
|
||||
<option value="info:text">{% trans "Text" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
{# fields group for "content type == @field@" and "content type == @user-field@" and "content type == @info-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value-in=" @field@ @user-field@ @info-field@ ">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Field content" %}
|
||||
<select name="field_content" data-dynamic-display-parent="true">
|
||||
<option value="label-and-value">{% trans "Label & Value" %}</option>
|
||||
<option value="label">{% trans "Label only" %}</option>
|
||||
<option value="value">{% trans "Value only" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="field_content" data-dynamic-display-value-in=" label value ">
|
||||
<label>
|
||||
{% trans "Display mode" %}
|
||||
<select name="field_display_mode">
|
||||
<option value="text">{% trans "Text" %}</option>
|
||||
<option value="title">{% trans "Title" %}</option>
|
||||
<option value="subtitle">{% trans "Subtitle" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="field_varname" data-dynamic-display-value-in=" {% for field in card_schema.fields %}{% if field.type == 'file' %}{{ field.varname }} {% endif %}{% endfor %} ">
|
||||
<label>
|
||||
{% trans "File display mode" %}
|
||||
<select name="file_field_display_mode">
|
||||
<option value="link">{% trans "Link" %}</option>
|
||||
<option value="thumbnail">{% trans "Thumbnail" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="entry_type" data-dynamic-display-value-in=" @field@ @user-field@ ">
|
||||
<label>
|
||||
{% trans "Empty value display mode" %}
|
||||
<select name="field_empty_display_mode" data-dynamic-display-parent="true">
|
||||
<option value="@empty@">{% trans "Display as empty" %}</option>
|
||||
<option value="@skip@">{% trans "Hide" %}</option>
|
||||
<option value="@custom@">{% trans "Display a custom text" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="field_empty_display_mode" data-dynamic-display-value="@custom@">
|
||||
<label>
|
||||
{% trans "Empty value custom text" %}
|
||||
<input name="field_empty_text" />
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @custom@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@custom@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Value template" %}
|
||||
<textarea name="custom_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Display mode" %}
|
||||
<select name="custom_display_mode">
|
||||
<option value="label">{% trans "Label" %}</option>
|
||||
<option value="text">{% trans "Text" %}</option>
|
||||
<option value="title">{% trans "Title" %}</option>
|
||||
<option value="subtitle">{% trans "Subtitle" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @link@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@link@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Label template" %}
|
||||
<textarea name="link_label_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Link destination" %}
|
||||
<select name="link_page" data-dynamic-display-parent="true">
|
||||
{% for page in cell.get_matching_pages %}
|
||||
<option value="{{ page.pk }}">{{ page.get_full_path_titles }}</option>
|
||||
{% endfor %}
|
||||
<option value="">{% trans "URL (Template)" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="link_page" data-dynamic-display-value="">
|
||||
<label>
|
||||
<textarea name="link_url_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Display mode" %}
|
||||
<select name="link_display_mode">
|
||||
<option value="link">{% trans "Link" %}</option>
|
||||
<option value="button">{% trans "Button" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Size" %}
|
||||
<select name="cell_size">
|
||||
<option value="">{% trans "Automatic" %}</option>
|
||||
<option value="size--1-1">1/1</option>
|
||||
<option value="size--t1-2">1/2</option>
|
||||
<option value="size--t1-3">1/3</option>
|
||||
<option value="size--t2-3">2/3</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</form>
|
||||
</template>
|
||||
<template class="as-card wcs-cards-cell--grid-cell-tpl">
|
||||
<div class="as-card wcs-cards-cell--grid-cell">
|
||||
<div class="as-card wcs-cards-cell--grid-cell-content"></div>
|
||||
<div class="as-card wcs-cards-cell--grid-cell-buttons">
|
||||
<a role="button" class="as-card wcs-cards-cell--grid-cell-edit">{% trans "Edit" %}</a>
|
||||
<a role="button" class="as-card wcs-cards-cell--grid-cell-delete">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
{% include "combo/wcs/manager/card-cell-form-display-as-card.html" %}
|
||||
|
||||
{# display mode as table #}
|
||||
{% include "combo/wcs/manager/card-cell-form-display-as-table.html" %}
|
||||
|
||||
{# UI to customize content layout #}
|
||||
<div class="as-table wcs-cards-cell--grid">
|
||||
<div class="as-table wcs-cards-cell--grid-options">
|
||||
<span class="as-table wcs-cards-cell--grid-headers-label">{% trans "Display headers:" %}</span>
|
||||
<span class="as-table wcs-cards-cell--grid-headers-mode"></span>
|
||||
<a role="button" class="as-table wcs-cards-cell--grid-headers-btn">
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="as-table wcs-cards-cell--grid-cells">
|
||||
</div>
|
||||
<div class="as-table wcs-cards-cell--grid-buttons">
|
||||
<button type="button" class="as-table wcs-cards-cell--add-grid-cell-btn">{% trans "Add" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
{# display mode as list #}
|
||||
{% include "combo/wcs/manager/card-cell-form-display-as-list.html" %}
|
||||
|
||||
{# templates for JS #}
|
||||
<template class="as-table wcs-cards-cell--grid-form-tpl">
|
||||
<form>
|
||||
<p>
|
||||
{% trans "Display headers" %}
|
||||
<input name="grid-headers" type="checkbox" />
|
||||
</p>
|
||||
</form>
|
||||
</template>
|
||||
<template class="as-table wcs-cards-cell--grid-cell-form-tpl">
|
||||
<form>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Content type" %}
|
||||
<select name="entry_type" data-dynamic-display-parent="true">
|
||||
<option value="@field@">{% trans "Card field" %}</option>
|
||||
<option value="@user-field@">{% trans "User field" %}</option>
|
||||
<option value="@info-field@">{% trans "Card information field" %}</option>
|
||||
<option value="@custom@">{% trans "Custom" %}</option>
|
||||
<option value="@link@">{% trans "Link" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
|
||||
{# fields group for "content type == @field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Card Fields" %}
|
||||
<select name="field_varname" data-dynamic-display-parent="true"></select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @user-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@user-field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "User Fields" %}
|
||||
<select name="user_field_varname" data-dynamic-display-parent="true"></select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @info-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@info-field@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Card Information Fields" %}
|
||||
<select name="info_field_varname" data-dynamic-display-parent="true">
|
||||
<option value="info:id">{% trans "Identifier" %}</option>
|
||||
<option value="info:receipt_time">{% trans "Receipt date" %}</option>
|
||||
<option value="info:last_update_time">{% trans "Last modified" %}</option>
|
||||
<option value="info:status">{% trans "Status" %}</option>
|
||||
<option value="info:text">{% trans "Text" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @field@" and "content type == @user-field@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value-in=" @field@ @user-field@ ">
|
||||
<p data-dynamic-display-child-of="field_varname" data-dynamic-display-value-in=" {% for field in card_schema.fields %}{% if field.type == 'file' %}{{ field.varname }} {% endif %}{% endfor %} ">
|
||||
<label>
|
||||
{% trans "File display mode" %}
|
||||
<select name="file_field_display_mode">
|
||||
<option value="link">{% trans "Link" %}</option>
|
||||
<option value="thumbnail">{% trans "Thumbnail" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Custom text to replace empty value" %}
|
||||
<input name="field_empty_text" />
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @custom@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@custom@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Header" %}
|
||||
<input name="custom_header" />
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Value template" %}
|
||||
<textarea name="custom_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# fields group for "content type == @link@" #}
|
||||
<div data-dynamic-display-child-of="entry_type" data-dynamic-display-value="@link@">
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Header" %}
|
||||
<input name="link_header" />
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Label template" %}
|
||||
<textarea name="link_label_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Link destination" %}
|
||||
<select name="link_page" data-dynamic-display-parent="true">
|
||||
{% for page in cell.get_matching_pages %}
|
||||
<option value="{{ page.pk }}">{{ page.get_full_path_titles }}</option>
|
||||
{% endfor %}
|
||||
<option value="">{% trans "URL (Template)" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p data-dynamic-display-child-of="link_page" data-dynamic-display-value="">
|
||||
<label>
|
||||
<textarea name="link_url_template" style="resize: vertical;"></textarea>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
{% trans "Display mode" %}
|
||||
<select name="link_display_mode">
|
||||
<option value="link">{% trans "Link" %}</option>
|
||||
<option value="button">{% trans "Button" %}</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template class="as-table wcs-cards-cell--grid-cell-tpl">
|
||||
<div class="as-table wcs-cards-cell--grid-cell">
|
||||
<div class="as-table wcs-cards-cell--grid-cell-content"></div>
|
||||
<div class="as-table wcs-cards-cell--grid-cell-buttons">
|
||||
<a role="button" class="as-table wcs-cards-cell--grid-cell-edit">{% trans "Edit" %}</a>
|
||||
<a role="button" class="as-table wcs-cards-cell--grid-cell-delete">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
{% endif %}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
{% endblock %}
|
||||
<div>
|
||||
{% block form-pre %}{% endblock %}
|
||||
<form data-wcs-url="{{ url }}" method="post" action="{{ site_base }}{% url 'wcs-tracking-code' %}">
|
||||
<form method="post" action="{{ site_base }}{% url 'wcs-tracking-code' %}">
|
||||
{% block form-top %}
|
||||
{% block intro-text %}
|
||||
<p>
|
||||
|
@ -30,7 +30,7 @@
|
|||
</div>
|
||||
<label class="sr-only" for="tracking-code-{{cell.id}}">{% trans "Tracking Code" %}</label>
|
||||
<input required class="tracking-code--input" id="tracking-code-{{cell.id}}" name="code" placeholder="{% block input-placeholder-content %}{% trans 'ex: CNPHNTFB' %}{% endblock %}"/>
|
||||
<button aria-label="{% trans 'Submit' %}">{% block submit-content %}{% trans 'Submit' %}{% endblock %}</button>
|
||||
<button class="submit-button" aria-label="{% trans 'Submit' %}">{% block submit-content %}{% trans 'Submit' %}{% endblock %}</button>
|
||||
<script>
|
||||
$(function() {
|
||||
$('#_cell_url_{{ cell.id }}').val(window.location);
|
||||
|
|
|
@ -68,7 +68,10 @@ def get_filter_attrs(cell, card):
|
|||
prefix = 'c%s-' % cell.get_reference()
|
||||
for filter_id in cell.filters:
|
||||
if filter_id == 'status' and 'workflow' in card:
|
||||
value = card['workflow']['real_status']['id']
|
||||
try:
|
||||
value = card['workflow']['real_status']['id']
|
||||
except KeyError:
|
||||
value = None
|
||||
else:
|
||||
value = card_fields.get(filter_id + '_raw')
|
||||
|
||||
|
|
|
@ -15,13 +15,19 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from combo.utils import requests
|
||||
|
||||
|
||||
class WCSError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def is_wcs_enabled(cls):
|
||||
return hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('wcs')
|
||||
|
||||
|
@ -32,6 +38,17 @@ def get_wcs_services():
|
|||
return settings.KNOWN_SERVICES.get('wcs')
|
||||
|
||||
|
||||
def get_default_wcs_service_key():
|
||||
services = get_wcs_services()
|
||||
|
||||
for key, service in services.items():
|
||||
if not service.get('secondary', False):
|
||||
# if secondary is not set or not set to True, return this one
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_wcs_json(wcs_site, path, log_errors=True):
|
||||
if wcs_site is None:
|
||||
# no site specified (probably an import referencing a not yet deployed
|
||||
|
@ -52,12 +69,16 @@ def get_wcs_json(wcs_site, path, log_errors=True):
|
|||
# return json if available (on 404 responses by example)
|
||||
return e.response.json()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {'err': 1, 'data': None}
|
||||
return {
|
||||
'err': 1,
|
||||
'err_desc': 'request-error-status-%s' % e.response.status_code,
|
||||
'data': None,
|
||||
}
|
||||
return {'err': 1, 'err_desc': 'request-error', 'data': None}
|
||||
return response.json()
|
||||
|
||||
|
||||
def get_wcs_options(url, include_category_slug=False, include_custom_views=False):
|
||||
def get_wcs_options(url, include_category_slug=False, include_custom_views=False, with_site_title=True):
|
||||
references = []
|
||||
for wcs_key, wcs_site in sorted(get_wcs_services().items(), key=lambda x: x[1]['title']):
|
||||
site_title = wcs_site.get('title')
|
||||
|
@ -70,7 +91,7 @@ def get_wcs_options(url, include_category_slug=False, include_custom_views=False
|
|||
for element in response_json:
|
||||
slug = element.get('slug')
|
||||
title = element.get('title')
|
||||
if len(get_wcs_services()) == 1:
|
||||
if len(get_wcs_services()) == 1 or not with_site_title:
|
||||
label = title
|
||||
else:
|
||||
label = '%s : %s' % (site_title, title)
|
||||
|
@ -104,9 +125,48 @@ def get_matching_pages_from_card_slug(card_slug, order=True):
|
|||
return Page.get_as_reordered_flat_hierarchy(matching_pages)
|
||||
|
||||
|
||||
def get_wcs_matching_card_model(sub_slug):
|
||||
card_models = get_wcs_options('/api/cards/@list')
|
||||
def get_wcs_matching_card_model(sub_slug, with_site_title=True):
|
||||
card_models = get_wcs_options('/api/cards/@list', with_site_title=with_site_title)
|
||||
for carddef_reference, card_label in card_models:
|
||||
card_id = '%s_id' % carddef_reference.split(':')[1]
|
||||
if '<%s>' % card_id in sub_slug or sub_slug == card_id:
|
||||
return carddef_reference, card_label
|
||||
|
||||
|
||||
def get_card_dependency(carddef_slug, carddef_title, wcs_url):
|
||||
urls = {
|
||||
'export': f'{wcs_url}api/export-import/cards/{carddef_slug}/',
|
||||
'dependencies': f'{wcs_url}api/export-import/cards/{carddef_slug}/dependencies/',
|
||||
'redirect': f'{wcs_url}api/export-import/cards/{carddef_slug}/redirect/',
|
||||
}
|
||||
return {'type': 'cards', 'id': carddef_slug, 'text': carddef_title, 'urls': urls}
|
||||
|
||||
|
||||
def get_wcs_dependencies_from_template(string):
|
||||
if not is_wcs_enabled(None):
|
||||
return []
|
||||
if not string:
|
||||
return []
|
||||
service_key = get_default_wcs_service_key()
|
||||
wcs = get_wcs_services().get(service_key)
|
||||
wcs_url = wcs.get('url') or ''
|
||||
response_json = get_wcs_json(wcs, '/api/cards/@list')
|
||||
if response_json.get('err') == 1:
|
||||
raise WCSError(_('Unable to get WCS service (%s)') % response_json.get('err_desc'))
|
||||
if not response_json.get('data'):
|
||||
raise WCSError(_('Unable to get WCS data'))
|
||||
carddef_labels_by_slug = {e['slug']: e['title'] for e in response_json['data']}
|
||||
for carddef_slug in re.findall(r'cards\|objects:"([\w_-]+:?[\w_-]*)"', string):
|
||||
if ':' in carddef_slug:
|
||||
carddef_slug = carddef_slug.split(':')[0]
|
||||
if carddef_slug not in carddef_labels_by_slug:
|
||||
# ignore unknown card model
|
||||
continue
|
||||
yield get_card_dependency(carddef_slug, carddef_labels_by_slug[carddef_slug], wcs_url)
|
||||
|
||||
|
||||
def get_wcs_dependency_from_carddef_reference(carddef_reference, carddef_title):
|
||||
parts = carddef_reference.split(':')
|
||||
wcs_key, carddef_slug = parts[:2]
|
||||
wcs_site_url = get_wcs_services().get(wcs_key)['url']
|
||||
return get_card_dependency(carddef_slug, carddef_title, wcs_site_url)
|
||||
|
|
|
@ -44,8 +44,10 @@ class TrackingCodeView(View):
|
|||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def search(cls, code, request, wcs_site=None):
|
||||
def search(cls, code, request, wcs_site=None, backoffice=False):
|
||||
code = code.strip().upper()
|
||||
if not re.match(r'^[BCDFGHJKLMNPQRSTVWXZ]{8}$', code):
|
||||
return None
|
||||
if wcs_site:
|
||||
wcs_sites = [get_wcs_services().get(wcs_site)]
|
||||
else:
|
||||
|
@ -63,7 +65,10 @@ class TrackingCodeView(View):
|
|||
for wcs_site in wcs_sites:
|
||||
if not wcs_site:
|
||||
continue
|
||||
response = requests.get('/api/code/' + quote(code), remote_service=wcs_site, log_errors=False)
|
||||
url = '/api/code/' + quote(code)
|
||||
if backoffice:
|
||||
url += '?backoffice=true'
|
||||
response = requests.get(url, remote_service=wcs_site, log_errors=False)
|
||||
if response.status_code == 200 and response.json().get('err') == 0:
|
||||
return response.json().get('load_url')
|
||||
|
||||
|
@ -115,7 +120,7 @@ def tracking_code_search(request):
|
|||
query = query.strip().upper()
|
||||
if re.match(r'^[BCDFGHJKLMNPQRSTVWXZ]{8}$', query):
|
||||
try:
|
||||
url = TrackingCodeView.search(query, request)
|
||||
url = TrackingCodeView.search(query, request, backoffice=True)
|
||||
except PermissionDenied:
|
||||
response['err'] = 1
|
||||
hits.append(
|
||||
|
|
|
@ -21,9 +21,13 @@ from combo.utils.cache import cache_during_request
|
|||
|
||||
|
||||
def template_vars(request):
|
||||
context_extras = {}
|
||||
context_extras['debug'] = settings.DEBUG
|
||||
context_extras['livereload_enabled'] = settings.LIVERELOAD_ENABLED
|
||||
context_extras['pwa_settings'] = cache_during_request(PwaSettings.singleton)
|
||||
context_extras = {
|
||||
'debug': settings.DEBUG,
|
||||
'livereload_enabled': settings.LIVERELOAD_ENABLED,
|
||||
'pwa_settings': cache_during_request(PwaSettings.singleton),
|
||||
'true': True,
|
||||
'false': False,
|
||||
'null': None,
|
||||
}
|
||||
context_extras.update(settings.TEMPLATE_VARS)
|
||||
return context_extras
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
# combo - content management system
|
||||
# Copyright (C) 2024 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.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class MissingSubSlug(Exception):
|
||||
def __init__(self, page):
|
||||
self.page = page
|
||||
|
||||
|
||||
class ImportSiteError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingGroups(ImportSiteError):
|
||||
def __init__(self, names):
|
||||
self.names = names
|
||||
|
||||
def __str__(self):
|
||||
return _('Missing groups: %s') % ', '.join(self.names)
|
||||
|
||||
|
||||
class PostException(Exception):
|
||||
pass
|
|
@ -33,20 +33,32 @@ class Command(BaseCommand):
|
|||
parser.add_argument(
|
||||
'--format-json', action='store_true', default=False, help='use JSON format with no asset files'
|
||||
)
|
||||
parser.add_argument('--only-assets', action='store_true', default=False, help='only export assets')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
export_kwargs = {}
|
||||
if options.get('only_assets'):
|
||||
export_kwargs = {
|
||||
'pages': False,
|
||||
'cartography': False,
|
||||
'pwa': False,
|
||||
'assets': True,
|
||||
'payment': False,
|
||||
'site_settings': False,
|
||||
}
|
||||
|
||||
if options['format_json']:
|
||||
if options['output'] and options['output'] != '-':
|
||||
with open(options['output'], 'w') as output:
|
||||
json.dump(export_site(), output, indent=2)
|
||||
json.dump(export_site(**export_kwargs), output, indent=2)
|
||||
else:
|
||||
json.dump(export_site(), sys.stdout, indent=2)
|
||||
json.dump(export_site(**export_kwargs), sys.stdout, indent=2)
|
||||
return
|
||||
|
||||
if options['output'] and options['output'] != '-':
|
||||
try:
|
||||
with open(options['output'], 'wb') as output:
|
||||
export_site_tar(output)
|
||||
export_site_tar(output, export_kwargs=export_kwargs)
|
||||
except OSError as e:
|
||||
raise CommandError(e)
|
||||
return
|
||||
|
|
|
@ -21,7 +21,8 @@ import tarfile
|
|||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from combo.data.utils import ImportSiteError, import_site, import_site_tar
|
||||
from combo.data.exceptions import ImportSiteError
|
||||
from combo.data.utils import import_site, import_site_tar
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
|
|
@ -32,7 +32,7 @@ class Migration(migrations.Migration):
|
|||
(
|
||||
'user',
|
||||
models.ForeignKey(
|
||||
null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.16 on 2023-10-03 12:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('data', '0065_snapshot_uuids'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='page',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=150, verbose_name='Slug'),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,20 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('data', '0066_auto_20231003_1421'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='pagesnapshot',
|
||||
name='application_slug',
|
||||
field=models.CharField(max_length=100, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagesnapshot',
|
||||
name='application_version',
|
||||
field=models.CharField(max_length=100, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,62 @@
|
|||
# Generated by Django 3.2.16 on 2024-01-09 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('data', '0067_application'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='configjsoncell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='feedcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='fortunecell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jsoncell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='linkcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='linklistcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='menucell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parentcontentcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='textcell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='unlockmarkercell',
|
||||
name='extra_css_class',
|
||||
field=models.CharField(blank=True, max_length=500, verbose_name='Extra classes for CSS styling'),
|
||||
),
|
||||
]
|
|
@ -14,6 +14,7 @@
|
|||
# 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
|
||||
import copy
|
||||
import datetime
|
||||
import hashlib
|
||||
|
@ -38,6 +39,8 @@ from django.contrib.contenttypes.models import ContentType
|
|||
from django.core import serializers
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import models, transaction
|
||||
from django.db.models import JSONField, Max, Q
|
||||
from django.db.models.base import ModelBase
|
||||
|
@ -57,7 +60,7 @@ from django.template.defaultfilters import yesno
|
|||
from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.encoding import force_str, smart_bytes
|
||||
from django.utils.encoding import force_bytes, force_str, smart_bytes
|
||||
from django.utils.html import strip_tags
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.text import slugify
|
||||
|
@ -65,18 +68,21 @@ from django.utils.timezone import now
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from combo import utils
|
||||
from combo.apps.wcs.utils import get_wcs_matching_card_model, is_wcs_enabled
|
||||
from combo.apps.export_import.models import Application
|
||||
from combo.apps.wcs.utils import (
|
||||
get_wcs_dependencies_from_template,
|
||||
get_wcs_dependency_from_carddef_reference,
|
||||
get_wcs_matching_card_model,
|
||||
is_wcs_enabled,
|
||||
)
|
||||
from combo.utils import NothingInCacheException
|
||||
|
||||
from .exceptions import ImportSiteError, PostException
|
||||
from .fields import RichTextField, TemplatableURLField
|
||||
from .library import get_cell_class, get_cell_classes, register_cell_class
|
||||
from .widgets import FlexSize
|
||||
|
||||
|
||||
class PostException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def element_is_visible(element, user=None, ignore_superuser=False):
|
||||
if user is not None and user.is_superuser and not ignore_superuser:
|
||||
return True
|
||||
|
@ -108,7 +114,7 @@ def format_sub_slug(sub_slug):
|
|||
|
||||
if 'P<' not in sub_slug:
|
||||
# simple sub_slug without regex
|
||||
sub_slug = '(?P<%s>[a-z0-9]+)' % sub_slug
|
||||
sub_slug = '(?P<%s>[a-zA-Z0-9_-]+)' % sub_slug
|
||||
# search all named-groups in sub_slug
|
||||
for i, m in enumerate(re.finditer(r'P<[\w_-]+>', sub_slug)):
|
||||
# extract original name
|
||||
|
@ -197,7 +203,7 @@ class Page(models.Model):
|
|||
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
title = models.CharField(_('Title'), max_length=150)
|
||||
slug = models.SlugField(_('Slug'))
|
||||
slug = models.SlugField(_('Slug'), max_length=150)
|
||||
sub_slug = models.CharField(
|
||||
_('Sub Slug'),
|
||||
max_length=150,
|
||||
|
@ -256,6 +262,8 @@ class Page(models.Model):
|
|||
|
||||
_level = None
|
||||
|
||||
application_component_type = 'pages'
|
||||
|
||||
class Meta:
|
||||
ordering = ['order']
|
||||
|
||||
|
@ -299,7 +307,7 @@ class Page(models.Model):
|
|||
if not Page.objects.exists():
|
||||
slug = 'index'
|
||||
else:
|
||||
base_slug = slugify(self.title)[:40]
|
||||
base_slug = slugify(self.title)[:140]
|
||||
slug = base_slug.strip('-')
|
||||
i = 1
|
||||
while Page.objects.filter(slug=slug, parent_id=self.parent_id).exists():
|
||||
|
@ -355,6 +363,16 @@ class Page(models.Model):
|
|||
return self._children
|
||||
return Page.objects.filter(parent_id=self.id)
|
||||
|
||||
def has_navigable_children(self):
|
||||
if hasattr(self, '_children'):
|
||||
for child in self._children:
|
||||
if not getattr(child, 'exclude_from_navigation', True):
|
||||
return True
|
||||
return Page.objects.filter(
|
||||
parent_id=self.id,
|
||||
exclude_from_navigation=False,
|
||||
).exists()
|
||||
|
||||
def has_children(self):
|
||||
if hasattr(self, '_children'):
|
||||
return bool(self._children)
|
||||
|
@ -377,6 +395,24 @@ class Page(models.Model):
|
|||
def get_descendants_and_me(self):
|
||||
return self.get_descendants(include_myself=True)
|
||||
|
||||
def get_dependencies(self):
|
||||
yield from self.get_children()
|
||||
yield self.edit_role
|
||||
yield self.subpages_edit_role
|
||||
yield from self.groups.all()
|
||||
for value in self.extra_variables.values():
|
||||
yield from get_wcs_dependencies_from_template(value)
|
||||
if self.sub_slug:
|
||||
result = get_wcs_matching_card_model(self.sub_slug, with_site_title=False)
|
||||
if result:
|
||||
yield get_wcs_dependency_from_carddef_reference(*result)
|
||||
for cell in self.get_cells(prefetch_validity_info=True):
|
||||
validity_info = cell.get_validity_info()
|
||||
if validity_info is not None:
|
||||
# invalid cell, don't check dependencies
|
||||
continue
|
||||
yield from cell.get_dependencies()
|
||||
|
||||
def get_template_display_name(self):
|
||||
try:
|
||||
return settings.COMBO_PUBLIC_TEMPLATES[self.template_name]['name']
|
||||
|
@ -539,8 +575,8 @@ class Page(models.Model):
|
|||
extra_labels.append(_('redirection'))
|
||||
return extra_labels
|
||||
|
||||
def get_cells(self):
|
||||
return CellBase.get_cells(page=self)
|
||||
def get_cells(self, prefetch_validity_info=False):
|
||||
return CellBase.get_cells(page=self, prefetch_validity_info=prefetch_validity_info)
|
||||
|
||||
def build_cell_cache(self):
|
||||
cell_classes = get_cell_classes()
|
||||
|
@ -575,6 +611,9 @@ class Page(models.Model):
|
|||
for key in list(cell['fields'].keys()):
|
||||
if key.startswith('cached_'):
|
||||
del cell['fields'][key]
|
||||
if self.picture:
|
||||
with self.picture.open() as f:
|
||||
serialized_page['fields']['picture:base64'] = force_str(base64.encodebytes(f.read()))
|
||||
return serialized_page
|
||||
|
||||
@classmethod
|
||||
|
@ -603,12 +642,25 @@ class Page(models.Model):
|
|||
)
|
||||
% json_page['fields']['title'],
|
||||
)
|
||||
decoded_picture = None
|
||||
if json_page['fields'].get('picture:base64'):
|
||||
decoded_picture = base64.decodebytes(force_bytes(json_page['fields']['picture:base64']))
|
||||
del json_page['fields']['picture:base64']
|
||||
page_uuid = page.uuid
|
||||
page = next(serializers.deserialize('json', json.dumps([json_page]), ignorenonexistent=True))
|
||||
page.object.snapshot = snapshot
|
||||
if snapshot:
|
||||
# keep the generated uuid
|
||||
page.object.uuid = page_uuid
|
||||
if decoded_picture and page.object.picture:
|
||||
original_path = page.object.picture.path
|
||||
original_name = page.object.picture.name
|
||||
name = original_name
|
||||
if name.startswith('page-pictures/'):
|
||||
name = name[len('page-pictures/') :]
|
||||
page.object.picture.save(name, ContentFile(decoded_picture))
|
||||
os.rename(default_storage.path(page.object.picture.name), original_path)
|
||||
page.object.picture.name = original_name
|
||||
page.save()
|
||||
for cell in json_page.get('cells'):
|
||||
cell['fields']['groups'] = [[x] for x in cell['fields']['groups'] if isinstance(x, str)]
|
||||
|
@ -636,7 +688,7 @@ class Page(models.Model):
|
|||
cell.object.import_subobjects(cell_data)
|
||||
|
||||
@classmethod
|
||||
def load_serialized_pages(cls, json_site, request=None):
|
||||
def load_serialized_pages(cls, json_site, request=None, job=None):
|
||||
cells_to_load = []
|
||||
to_load = []
|
||||
imported_pages = []
|
||||
|
@ -646,6 +698,8 @@ class Page(models.Model):
|
|||
|
||||
for json_page in json_site:
|
||||
# pre-create pages
|
||||
if 'uuid' not in json_page['fields']:
|
||||
raise ImportSiteError(_('Unable to import : given export is too old'))
|
||||
page, created = Page.objects.get_or_create(uuid=json_page['fields']['uuid'])
|
||||
to_load.append((page, created, json_page))
|
||||
|
||||
|
@ -660,6 +714,8 @@ class Page(models.Model):
|
|||
for page, created, json_page in to_load:
|
||||
imported_pages.append(cls.load_serialized_page(json_page, page=page, request=request))
|
||||
cells_to_load.extend(json_page.get('cells'))
|
||||
if job is not None:
|
||||
job.increment_count()
|
||||
|
||||
# and cells
|
||||
cls.load_serialized_cells(cells_to_load)
|
||||
|
@ -685,15 +741,17 @@ class Page(models.Model):
|
|||
return self.last_update_timestamp
|
||||
|
||||
def get_extra_variables(self, request, original_context):
|
||||
result = {}
|
||||
context = RequestContext(request)
|
||||
context.push(original_context)
|
||||
for key, tplt in (self.extra_variables or {}).items():
|
||||
try:
|
||||
result[key] = Template(tplt).render(context)
|
||||
except (TemplateSyntaxError, VariableDoesNotExist):
|
||||
continue
|
||||
return result
|
||||
if not hasattr(self, '_cached_extra_variables'):
|
||||
result = {}
|
||||
context = RequestContext(request)
|
||||
context.push(original_context)
|
||||
for key, tplt in (self.extra_variables or {}).items():
|
||||
try:
|
||||
result[key] = Template(tplt).render(context)
|
||||
except (TemplateSyntaxError, VariableDoesNotExist):
|
||||
continue
|
||||
self._cached_extra_variables = result
|
||||
return self._cached_extra_variables
|
||||
|
||||
def get_extra_variables_keys(self):
|
||||
return sorted((self.extra_variables or {}).keys())
|
||||
|
@ -701,6 +759,12 @@ class Page(models.Model):
|
|||
def is_new(self):
|
||||
return self.creation_timestamp > timezone.now() - datetime.timedelta(days=7)
|
||||
|
||||
@property
|
||||
def applications(self):
|
||||
if getattr(self, '_applications', None) is None:
|
||||
Application.load_for_object(self)
|
||||
return self._applications
|
||||
|
||||
def duplicate(self, title=None, parent=False):
|
||||
# clone current page
|
||||
new_page = copy.deepcopy(self)
|
||||
|
@ -738,24 +802,32 @@ class Page(models.Model):
|
|||
class PageSnapshot(models.Model):
|
||||
page = models.ForeignKey(Page, on_delete=models.SET_NULL, null=True)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
|
||||
comment = models.TextField(blank=True, null=True)
|
||||
serialization = JSONField(blank=True, default=dict)
|
||||
label = models.CharField(_('Label'), max_length=150, blank=True)
|
||||
application_slug = models.CharField(max_length=100, null=True)
|
||||
application_version = models.CharField(max_length=100, null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ('-timestamp',)
|
||||
|
||||
@classmethod
|
||||
def take(cls, page, request=None, comment=None, deletion=False, label=None):
|
||||
def take(cls, page, request=None, comment=None, deletion=False, label=None, application=None):
|
||||
snapshot = cls(page=page, comment=comment, label=label or '')
|
||||
if request and not request.user.is_anonymous:
|
||||
snapshot.user = request.user
|
||||
if not deletion:
|
||||
snapshot.serialization = page.get_serialized_page()
|
||||
# remove order and parent from serialization
|
||||
del snapshot.serialization['fields']['order']
|
||||
del snapshot.serialization['fields']['parent']
|
||||
else:
|
||||
snapshot.serialization = {}
|
||||
snapshot.comment = comment or _('deletion')
|
||||
if application:
|
||||
snapshot.application_slug = application.slug
|
||||
snapshot.application_version = application.version_number
|
||||
snapshot.save()
|
||||
|
||||
def get_page(self):
|
||||
|
@ -780,6 +852,9 @@ class PageSnapshot(models.Model):
|
|||
return self.load_page(json_page)
|
||||
|
||||
def load_page(self, json_page, snapshot=None):
|
||||
# keep current page order and parent
|
||||
json_page['fields']['order'] = self.page.order
|
||||
json_page['fields']['parent'] = self.page.parent.natural_key() if self.page.parent else None
|
||||
try:
|
||||
post_save.disconnect(cell_maintain_page_cell_cache)
|
||||
post_delete.disconnect(cell_maintain_page_cell_cache)
|
||||
|
@ -793,6 +868,54 @@ class PageSnapshot(models.Model):
|
|||
page.build_cell_cache()
|
||||
return page
|
||||
|
||||
def load_history(self):
|
||||
if self.page is None:
|
||||
self._history = []
|
||||
return
|
||||
history = PageSnapshot.objects.filter(page=self.page)
|
||||
self._history = [s.id for s in history]
|
||||
|
||||
@property
|
||||
def previous(self):
|
||||
if not hasattr(self, '_history'):
|
||||
self.load_history()
|
||||
|
||||
try:
|
||||
idx = self._history.index(self.id)
|
||||
except ValueError:
|
||||
return None
|
||||
if idx == 0:
|
||||
return None
|
||||
return self._history[idx - 1]
|
||||
|
||||
@property
|
||||
def next(self):
|
||||
if not hasattr(self, '_history'):
|
||||
self.load_history()
|
||||
|
||||
try:
|
||||
idx = self._history.index(self.id)
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
return self._history[idx + 1]
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def first(self):
|
||||
if not hasattr(self, '_history'):
|
||||
self.load_history()
|
||||
|
||||
return self._history[0]
|
||||
|
||||
@property
|
||||
def last(self):
|
||||
if not hasattr(self, '_history'):
|
||||
self.load_history()
|
||||
|
||||
return self._history[-1]
|
||||
|
||||
|
||||
class Redirect(models.Model):
|
||||
old_url = models.CharField(max_length=512)
|
||||
|
@ -832,7 +955,7 @@ class CellBase(models.Model, metaclass=CellMeta):
|
|||
placeholder = models.CharField(max_length=20)
|
||||
order = models.PositiveIntegerField()
|
||||
slug = models.SlugField(_('Slug'), blank=True)
|
||||
extra_css_class = models.CharField(_('Extra classes for CSS styling'), max_length=100, blank=True)
|
||||
extra_css_class = models.CharField(_('Extra classes for CSS styling'), max_length=500, blank=True)
|
||||
template_name = models.CharField(_('Cell Template'), max_length=50, blank=True, null=True)
|
||||
condition = models.CharField(_('Display condition'), max_length=1000, blank=True, null=True)
|
||||
|
||||
|
@ -1153,6 +1276,14 @@ class CellBase(models.Model, metaclass=CellMeta):
|
|||
def get_label(self):
|
||||
return self.get_verbose_name()
|
||||
|
||||
def get_computed_strings(self):
|
||||
yield self.condition
|
||||
|
||||
def get_dependencies(self):
|
||||
yield from self.groups.all()
|
||||
for string in self.get_computed_strings():
|
||||
yield from get_wcs_dependencies_from_template(string)
|
||||
|
||||
def get_manager_tabs(self):
|
||||
from combo.manager.forms import CellVisibilityForm
|
||||
|
||||
|
@ -1386,7 +1517,7 @@ class CellBase(models.Model, metaclass=CellMeta):
|
|||
validity_info.invalid_reason_code, validity_info.invalid_reason_code
|
||||
)
|
||||
|
||||
def is_placeholder_active(self):
|
||||
def is_placeholder_active(self, traverse_cells=True):
|
||||
if not self.placeholder:
|
||||
return False
|
||||
if self.placeholder.startswith('_'):
|
||||
|
@ -1394,7 +1525,7 @@ class CellBase(models.Model, metaclass=CellMeta):
|
|||
|
||||
request = RequestFactory().get('/')
|
||||
if not hasattr(self.page, '_placeholders'):
|
||||
self.page._placeholders = self.page.get_placeholders(request, traverse_cells=True)
|
||||
self.page._placeholders = self.page.get_placeholders(request, traverse_cells=traverse_cells)
|
||||
for placeholder in self.page._placeholders:
|
||||
if placeholder.key == self.placeholder:
|
||||
return True
|
||||
|
@ -1825,6 +1956,10 @@ class LinkCell(CellBase):
|
|||
def render_for_search(self):
|
||||
return ''
|
||||
|
||||
def get_dependencies(self):
|
||||
yield from super().get_dependencies()
|
||||
yield self.link_page
|
||||
|
||||
def get_external_links_data(self):
|
||||
if not self.url:
|
||||
return []
|
||||
|
@ -1946,6 +2081,9 @@ class LinkListCell(CellBase):
|
|||
del link['pk']
|
||||
del link['fields']['placeholder']
|
||||
del link['fields']['page']
|
||||
for key in list(link['fields'].keys()):
|
||||
if key.startswith('cached_'):
|
||||
del link['fields'][key]
|
||||
return {'links': links}
|
||||
|
||||
def import_subobjects(self, cell_json):
|
||||
|
@ -1955,6 +2093,8 @@ class LinkListCell(CellBase):
|
|||
links = serializers.deserialize('json', json.dumps(cell_json['links']), ignorenonexistent=True)
|
||||
for link in links:
|
||||
link.save()
|
||||
# will populate cached_* attributes
|
||||
link.object.save()
|
||||
|
||||
def duplicate_m2m(self, new_cell):
|
||||
# duplicate also link items
|
||||
|
|
|
@ -26,26 +26,10 @@ from django.utils.translation import gettext_lazy as _
|
|||
from combo.apps.assets.models import Asset
|
||||
from combo.apps.assets.utils import add_tar_content, clean_assets_files, tar_assets_files, untar_assets_files
|
||||
|
||||
from .exceptions import ImportSiteError, MissingGroups, MissingSubSlug
|
||||
from .models import Page, SiteSettings, extract_context_from_sub_slug
|
||||
|
||||
|
||||
class MissingSubSlug(Exception):
|
||||
def __init__(self, page):
|
||||
self.page = page
|
||||
|
||||
|
||||
class ImportSiteError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingGroups(ImportSiteError):
|
||||
def __init__(self, names):
|
||||
self.names = names
|
||||
|
||||
def __str__(self):
|
||||
return _('Missing groups: %s') % ', '.join(self.names)
|
||||
|
||||
|
||||
def export_site(pages=True, cartography=True, pwa=True, assets=True, payment=True, site_settings=True):
|
||||
'''Dump site objects to JSON-dumpable dictionnary'''
|
||||
|
||||
|
@ -86,7 +70,7 @@ def export_site(pages=True, cartography=True, pwa=True, assets=True, payment=Tru
|
|||
return export
|
||||
|
||||
|
||||
def import_site(data, if_empty=False, clean=False, request=None):
|
||||
def import_site(data, if_empty=False, clean=False, request=None, job=None):
|
||||
if 'combo.apps.lingo' in settings.INSTALLED_APPS:
|
||||
from combo.apps.lingo.models import PaymentBackend, Regie
|
||||
|
||||
|
@ -142,7 +126,7 @@ def import_site(data, if_empty=False, clean=False, request=None):
|
|||
if data.get('map-layers') and cartography_support:
|
||||
MapLayer.load_serialized_objects(data.get('map-layers'))
|
||||
Asset.load_serialized_objects(data.get('assets') or [])
|
||||
pages = Page.load_serialized_pages(data.get('pages') or [], request=request)
|
||||
pages = Page.load_serialized_pages(data.get('pages') or [], request=request, job=job)
|
||||
|
||||
if data.get('pwa') and pwa_support:
|
||||
PwaSettings.load_serialized_settings(data['pwa'].get('settings'))
|
||||
|
@ -158,9 +142,10 @@ def import_site(data, if_empty=False, clean=False, request=None):
|
|||
raise ImportSiteError(message)
|
||||
try:
|
||||
page_slug = message.split("'['")[1].split("']'")[0]
|
||||
cell_class = message.split('(')[1].split(':')[0]
|
||||
except IndexError:
|
||||
raise ImportSiteError(message)
|
||||
raise ImportSiteError(_('Unknown page "%s".') % page_slug)
|
||||
raise ImportSiteError(_('Unknown page "%s" for cell "%s".') % (page_slug, cell_class))
|
||||
else:
|
||||
return pages
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ class Select2WidgetMixin:
|
|||
super().__init__(choices=choices)
|
||||
|
||||
if self.select2_enabled:
|
||||
self.attrs['data-autocomplete'] = 'true'
|
||||
self.attrs['data-combo-autocomplete'] = 'true'
|
||||
self.attrs['lang'] = settings.LANGUAGE_CODE
|
||||
if model:
|
||||
self.attrs['data-select2-url'] = reverse(
|
||||
|
|
|
@ -7,8 +7,8 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: combo 0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-08-14 08:07+0000\n"
|
||||
"PO-Revision-Date: 2023-08-14 10:11+0200\n"
|
||||
"POT-Creation-Date: 2024-04-16 10:53+0200\n"
|
||||
"PO-Revision-Date: 2024-04-16 10:54+0200\n"
|
||||
"Last-Translator: Thomas NOËL <tnoel@entrouvert.com>\n"
|
||||
"Language: French\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
|
@ -22,6 +22,11 @@ msgstr ""
|
|||
msgid "Assets"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: apps/assets/forms.py
|
||||
#, python-format
|
||||
msgid "Uploaded image exceeds size limits: %(detail)s"
|
||||
msgstr "L’image téléversée dépasse la taille limite autorisée : %(detail)s"
|
||||
|
||||
#: apps/assets/forms.py
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
@ -57,7 +62,8 @@ msgstr "Êtes-vous sûr·e de vouloir supprimer ceci ?"
|
|||
#: apps/maps/templates/maps/map_cell_form.html
|
||||
#: apps/maps/templates/maps/map_layer_confirm_delete.html
|
||||
#: apps/search/templates/combo/manager/search-cell-form.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
#: data/templates/combo/manager/link-list-cell-form.html
|
||||
#: manager/templates/combo/delete_page.html
|
||||
#: manager/templates/combo/generic_confirm_delete.html
|
||||
|
@ -70,6 +76,7 @@ msgstr "Supprimer"
|
|||
#: apps/assets/templates/combo/manager_asset_overwrite.html
|
||||
#: apps/assets/templates/combo/manager_asset_upload.html
|
||||
#: apps/assets/templates/combo/manager_assets_import.html
|
||||
#: apps/dataviz/templates/combo/chartngcell_export_form.html
|
||||
#: apps/gallery/templates/combo/gallery_image_form.html
|
||||
#: apps/lingo/templates/lingo/combo/cancel-item.html
|
||||
#: apps/lingo/templates/lingo/paymentbackend_confirm_delete.html
|
||||
|
@ -175,7 +182,7 @@ msgid "Name"
|
|||
msgstr "Nom"
|
||||
|
||||
#: apps/assets/templates/combo/manager_assets_fragment.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: data/models.py
|
||||
msgid "Size"
|
||||
msgstr "Taille"
|
||||
|
@ -264,10 +271,22 @@ msgstr "Variables de page"
|
|||
msgid "Filters"
|
||||
msgstr "Filtres"
|
||||
|
||||
#: apps/dataviz/forms.py
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
|
||||
#: apps/dataviz/forms.py
|
||||
msgid "Picture (SVG)"
|
||||
msgstr "Image (SVG)"
|
||||
|
||||
#: apps/dataviz/forms.py
|
||||
msgid "Table (ODS)"
|
||||
msgstr "Tableau (ODS)"
|
||||
|
||||
#: apps/dataviz/models.py apps/family/models.py apps/gallery/models.py
|
||||
#: apps/lingo/models.py apps/maps/models.py apps/search/models.py
|
||||
#: apps/wcs/models.py
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: data/models.py public/views.py
|
||||
msgid "Title"
|
||||
msgstr "Titre"
|
||||
|
@ -304,7 +323,7 @@ msgstr "Slug"
|
|||
#: apps/dataviz/models.py apps/lingo/models.py
|
||||
#: apps/lingo/templates/lingo/combo/items.html apps/maps/models.py
|
||||
#: apps/notifications/models.py apps/pwa/models.py
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: data/models.py manager/forms.py
|
||||
msgid "Label"
|
||||
msgstr "Libellé"
|
||||
|
@ -461,6 +480,26 @@ msgstr "Tableau"
|
|||
msgid "Table (inverted)"
|
||||
msgstr "Tableau (inversé)"
|
||||
|
||||
#: apps/dataviz/models.py
|
||||
msgid "Display of total"
|
||||
msgstr "Affichage du total"
|
||||
|
||||
#: apps/dataviz/models.py
|
||||
msgid "None"
|
||||
msgstr "Aucun"
|
||||
|
||||
#: apps/dataviz/models.py
|
||||
msgid "Total line and total column"
|
||||
msgstr "Ligne de total et colonne de total"
|
||||
|
||||
#: apps/dataviz/models.py
|
||||
msgid "Total line"
|
||||
msgstr "Ligne de total"
|
||||
|
||||
#: apps/dataviz/models.py
|
||||
msgid "Total column"
|
||||
msgstr "Colonne de total"
|
||||
|
||||
#: apps/dataviz/models.py
|
||||
msgid "Height"
|
||||
msgstr "Hauteur"
|
||||
|
@ -485,10 +524,6 @@ msgstr "Tri des données"
|
|||
msgid "This setting only applies for one-dimensional charts."
|
||||
msgstr "Cette option s’applique uniquement aux graphes unidimensionnels."
|
||||
|
||||
#: apps/dataviz/models.py
|
||||
msgid "None"
|
||||
msgstr "Aucun"
|
||||
|
||||
#: apps/dataviz/models.py
|
||||
msgid "Alphabetically"
|
||||
msgstr "Alphabétique"
|
||||
|
@ -548,6 +583,20 @@ msgstr ""
|
|||
"cellules de type « Graphe » apparaitront. De plus, si un filtre a une "
|
||||
"valeur, elle devra être la même pour chaque cellule."
|
||||
|
||||
#: apps/dataviz/templates/combo/chartngcell.html
|
||||
#: apps/dataviz/templates/combo/chartngcell_export_form.html
|
||||
#: apps/lingo/templates/lingo/combo/credits.html
|
||||
#: apps/lingo/templates/lingo/combo/item.html
|
||||
#: apps/lingo/templates/lingo/combo/items.html
|
||||
#: apps/lingo/templates/lingo/combo/payments.html
|
||||
#: apps/lingo/templates/lingo/transaction_export.html
|
||||
msgid "Download"
|
||||
msgstr "Télécharger"
|
||||
|
||||
#: apps/dataviz/templates/combo/chartngcell_export_form.html
|
||||
msgid "Export data"
|
||||
msgstr "Exporter les données"
|
||||
|
||||
#: apps/dataviz/views.py
|
||||
msgid "Wrong parameters."
|
||||
msgstr "Paramètres invalides."
|
||||
|
@ -581,6 +630,78 @@ msgstr "Erreur HTTP inconnue : %s"
|
|||
msgid "No data"
|
||||
msgstr "Pas de données"
|
||||
|
||||
#: apps/export_import/api_views.py
|
||||
msgid "Pages (agent portal)"
|
||||
msgstr "Pages (portail agent)"
|
||||
|
||||
#: apps/export_import/api_views.py
|
||||
msgid "Page (agent portal)"
|
||||
msgstr "Page (portail agent)"
|
||||
|
||||
#: apps/export_import/api_views.py manager/forms.py
|
||||
#: manager/templates/combo/manager_home.html
|
||||
msgid "Pages"
|
||||
msgstr "Pages"
|
||||
|
||||
#: apps/export_import/api_views.py apps/search/forms.py
|
||||
#: manager/templates/combo/page_history.html
|
||||
#: manager/templates/combo/page_view.html
|
||||
#: manager/templates/combo/snapshot_restore.html
|
||||
#: manager/templates/combo/snapshot_save.html
|
||||
msgid "Page"
|
||||
msgstr "Page"
|
||||
|
||||
#: apps/export_import/api_views.py data/models.py manager/forms.py
|
||||
msgid "Roles"
|
||||
msgstr "Rôles"
|
||||
|
||||
#: apps/export_import/api_views.py
|
||||
msgid "Role"
|
||||
msgstr "Rôle"
|
||||
|
||||
#: apps/export_import/api_views.py
|
||||
msgid "Invalid tar file, missing manifest"
|
||||
msgstr "Fichier tar invalide, manifeste manquant"
|
||||
|
||||
#: apps/export_import/api_views.py
|
||||
msgid "Invalid tar file"
|
||||
msgstr "Fichier tar invalide"
|
||||
|
||||
#: apps/export_import/apps.py
|
||||
msgid "Export/Import"
|
||||
msgstr "Export/Import"
|
||||
|
||||
#: apps/export_import/models.py
|
||||
msgid "Registered"
|
||||
msgstr "Enregistré"
|
||||
|
||||
#: apps/export_import/models.py apps/lingo/models.py
|
||||
msgid "Running"
|
||||
msgstr "En cours"
|
||||
|
||||
#: apps/export_import/models.py
|
||||
msgid "Failed"
|
||||
msgstr "En erreur"
|
||||
|
||||
#: apps/export_import/models.py
|
||||
msgid "Completed"
|
||||
msgstr "Terminé"
|
||||
|
||||
#: apps/export_import/models.py
|
||||
#, python-format
|
||||
msgid "Application (%s)"
|
||||
msgstr "Application (%s)"
|
||||
|
||||
#: apps/export_import/models.py
|
||||
#, python-format
|
||||
msgid "%(current_count)s (unknown total)"
|
||||
msgstr "%(current_count)s (total inconnu)"
|
||||
|
||||
#: apps/export_import/models.py
|
||||
#, python-format
|
||||
msgid "%(current_count)s/%(total_count)s (%(percent)s%%)"
|
||||
msgstr "%(current_count)s/%(total_count)s (%(percent)s%%)"
|
||||
|
||||
#: apps/family/apps.py
|
||||
msgid "Family"
|
||||
msgstr "Famille"
|
||||
|
@ -766,7 +887,8 @@ msgid "Ingenico (formerly Ogone)"
|
|||
msgstr "Ingenico (précédemment Ogone)"
|
||||
|
||||
#: apps/lingo/models.py apps/maps/models.py apps/wcs/models.py
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
#: manager/templates/combo/page_history.html
|
||||
msgid "Identifier"
|
||||
msgstr "Identifiant"
|
||||
|
@ -828,6 +950,11 @@ msgstr "Options de transaction"
|
|||
msgid "Basket items must be paid individually"
|
||||
msgstr "Les éléments du panier doivent être payés individuellement"
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgid "The invoice endpoint handle the for-payment parameter"
|
||||
msgstr ""
|
||||
"Le point d'accès « invoice » prend en charge le paramètre « for-payment »"
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgid "Regie"
|
||||
msgstr "Régie"
|
||||
|
@ -865,6 +992,7 @@ msgid "Details"
|
|||
msgstr "Détails"
|
||||
|
||||
#: apps/lingo/models.py apps/lingo/templates/lingo/basketitem_error_list.html
|
||||
#: apps/lingo/templates/lingo/combo/credits.html
|
||||
#: apps/lingo/templates/lingo/combo/items.html
|
||||
#: apps/lingo/templates/lingo/combo/payments.html
|
||||
#: apps/lingo/templates/lingo/tipi_form.html
|
||||
|
@ -884,10 +1012,6 @@ msgstr "Un prélèvement automatique a lieu pour cette facture."
|
|||
msgid "Due date is over."
|
||||
msgstr "La date limite est dépassée."
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgid "Running"
|
||||
msgstr "En cours"
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgid "Paid"
|
||||
msgstr "Payé"
|
||||
|
@ -944,7 +1068,8 @@ msgid "Basket Link"
|
|||
msgstr "Lien vers le panier"
|
||||
|
||||
#: apps/lingo/models.py apps/wcs/models.py
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
#: data/models.py
|
||||
msgid "Text"
|
||||
msgstr "Texte"
|
||||
|
@ -1009,7 +1134,33 @@ msgstr "Chargement des règlements…"
|
|||
|
||||
#: apps/lingo/models.py
|
||||
msgid "Payments cell"
|
||||
msgstr "Règlements"
|
||||
msgstr "Cellule règlements"
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgid "Hide if no credits"
|
||||
msgstr "Cacher en l’absence d’avoirs"
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgid "Credits to display"
|
||||
msgstr "Avoirs à afficher"
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgctxt "credits"
|
||||
msgid "Active"
|
||||
msgstr "Actifs"
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgctxt "credits"
|
||||
msgid "Historical"
|
||||
msgstr "Historiques"
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgid "Loading credits..."
|
||||
msgstr "Chargement des avoirs…"
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgid "Credits cell"
|
||||
msgstr "Cellule avoirs"
|
||||
|
||||
#: apps/lingo/models.py
|
||||
msgid "Indigo/PES v2"
|
||||
|
@ -1139,6 +1290,31 @@ msgstr ""
|
|||
msgid "Remove"
|
||||
msgstr "Supprimer"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/credits.html
|
||||
#: apps/lingo/templates/lingo/combo/items.html
|
||||
#: apps/lingo/templates/lingo/combo/payments.html
|
||||
msgid "Number"
|
||||
msgstr "Numéro"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/credits.html
|
||||
msgid "Credit date"
|
||||
msgstr "Date de l’avoir"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/credits.html
|
||||
#: apps/lingo/templates/lingo/combo/items.html
|
||||
#: apps/lingo/templates/lingo/combo/payments.html
|
||||
#, python-format
|
||||
msgid "%(amount)s€"
|
||||
msgstr "%(amount)s €"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/credits.html
|
||||
msgid "credit left:"
|
||||
msgstr "avoir restant :"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/credits.html
|
||||
msgid "No credits yet"
|
||||
msgstr "Aucun avoir"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/invoice_email_notification_body.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
|
@ -1295,22 +1471,10 @@ msgstr "En attente de paiement."
|
|||
msgid "Payments certificate:"
|
||||
msgstr "Attestation de paiement :"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/item.html
|
||||
#: apps/lingo/templates/lingo/combo/items.html
|
||||
#: apps/lingo/templates/lingo/combo/payments.html
|
||||
#: apps/lingo/templates/lingo/transaction_export.html
|
||||
msgid "Download"
|
||||
msgstr "Télécharger"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/item.html
|
||||
msgid "Email:"
|
||||
msgstr "Courriel :"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/items.html
|
||||
#: apps/lingo/templates/lingo/combo/payments.html
|
||||
msgid "Number"
|
||||
msgstr "Numéro"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/items.html
|
||||
msgid "Issue date"
|
||||
msgstr "Date d’émission"
|
||||
|
@ -1323,12 +1487,6 @@ msgstr "Date limite de paiement"
|
|||
msgid "Amount already paid"
|
||||
msgstr "Montant déjà payé"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/items.html
|
||||
#: apps/lingo/templates/lingo/combo/payments.html
|
||||
#, python-format
|
||||
msgid "%(amount)s€"
|
||||
msgstr "%(amount)s €"
|
||||
|
||||
#: apps/lingo/templates/lingo/combo/items.html
|
||||
msgctxt "left to pay"
|
||||
msgid "left:"
|
||||
|
@ -1408,7 +1566,6 @@ msgstr "Plateformes de paiement"
|
|||
|
||||
#: apps/lingo/templates/lingo/paymentbackend_list.html
|
||||
#: apps/lingo/templates/lingo/regie_list.html
|
||||
#: manager/templates/combo/manager_home.html
|
||||
msgid "New"
|
||||
msgstr "Nouvelle"
|
||||
|
||||
|
@ -1644,6 +1801,11 @@ msgid "We are sorry but an error occured when retrieving the payment."
|
|||
msgstr ""
|
||||
"Nous sommes désolés mais une erreur a eu lieu à la récupération du règlement."
|
||||
|
||||
#: apps/lingo/views.py
|
||||
msgid "We are sorry but an error occured when retrieving the credit."
|
||||
msgstr ""
|
||||
"Nous sommes désolés mais une erreur a eu lieu à la récupération de l’avoir."
|
||||
|
||||
#: apps/lingo/views.py
|
||||
msgid "Sorry, the provided amount is invalid."
|
||||
msgstr "Le montant que vous avez entré n’est pas valide."
|
||||
|
@ -1701,6 +1863,21 @@ msgstr ""
|
|||
"Ce paramétrage n’aura pas d’effet parce que l’action lors d’un clic sur un "
|
||||
"marqueur est : « %s »."
|
||||
|
||||
#: apps/maps/forms.py
|
||||
msgid ""
|
||||
"Invalid zoom configuration: minimal zoom must be lower than maximal zoom"
|
||||
msgstr ""
|
||||
"Configuration invalide, le niveau de zoom minimal doit être sous le niveau "
|
||||
"de zoom maximal."
|
||||
|
||||
#: apps/maps/forms.py
|
||||
msgid ""
|
||||
"Invalid zoom configuration: initial zoom is not between minimal & maximal "
|
||||
"zoom"
|
||||
msgstr ""
|
||||
"Configuration invalide, le niveau de zoom initial doit être entre le niveau "
|
||||
"de zoom minimal et le niveau de zoom maximal."
|
||||
|
||||
#: apps/maps/manager_views.py
|
||||
#, python-format
|
||||
msgid "added layer \"%(layer)s\" to cell \"%(cell)s\""
|
||||
|
@ -2150,6 +2327,10 @@ msgstr "Niveau de zoom maximal"
|
|||
msgid "Group markers in clusters"
|
||||
msgstr "Grouper les marqueurs"
|
||||
|
||||
#: apps/maps/models.py
|
||||
msgid "Include address search button"
|
||||
msgstr "Inclure un bouton de recherche d’adresse"
|
||||
|
||||
#: apps/maps/models.py
|
||||
msgid "Marker behaviour on click"
|
||||
msgstr "Action lors d’un clic sur un marqueur"
|
||||
|
@ -2265,7 +2446,9 @@ msgstr "Couches cartographiques :"
|
|||
|
||||
#: apps/maps/templates/maps/map_cell_form.html
|
||||
#: apps/search/templates/combo/manager/search-cell-form.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-list.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
#: data/templates/combo/manager/link-list-cell-form.html
|
||||
msgid "Edit"
|
||||
msgstr "Modifier"
|
||||
|
@ -2435,6 +2618,7 @@ msgid "Mobile Application"
|
|||
msgstr "Application mobile"
|
||||
|
||||
#: apps/pwa/templates/combo/pwa/manager_home.html
|
||||
#: manager/templates/combo/manager_home.html
|
||||
#: manager/templates/combo/page_view.html
|
||||
msgid "Navigation"
|
||||
msgstr "Navigation"
|
||||
|
@ -2478,13 +2662,6 @@ msgstr "Afficher la description des pages dans les résultats"
|
|||
msgid "Update \"Page Contents\" engine"
|
||||
msgstr "Modification du moteur « Contenu des pages »"
|
||||
|
||||
#: apps/search/forms.py manager/templates/combo/page_history.html
|
||||
#: manager/templates/combo/page_view.html
|
||||
#: manager/templates/combo/snapshot_restore.html
|
||||
#: manager/templates/combo/snapshot_save.html
|
||||
msgid "Page"
|
||||
msgstr "Page"
|
||||
|
||||
#: apps/search/forms.py
|
||||
msgid "Select a page to limit the search on this page and sub pages contents."
|
||||
msgstr ""
|
||||
|
@ -2591,6 +2768,15 @@ msgstr "Aucun résultat."
|
|||
msgid "Forms"
|
||||
msgstr "Démarches"
|
||||
|
||||
#: apps/wcs/apps.py
|
||||
msgid "Backoffice submission"
|
||||
msgstr "Saisie backoffice"
|
||||
|
||||
#: apps/wcs/apps.py
|
||||
#, python-format
|
||||
msgid "Backoffice submission (%s)"
|
||||
msgstr "Saisie backoffice (%s)"
|
||||
|
||||
#: apps/wcs/apps.py apps/wcs/templates/combo/wcs/tracking_code_input.html
|
||||
msgid "Tracking Code"
|
||||
msgstr "Code de suivi"
|
||||
|
@ -2640,7 +2826,8 @@ msgstr "Personnaliser l’affichage"
|
|||
|
||||
#: apps/wcs/forms.py apps/wcs/models.py
|
||||
#: apps/wcs/templates/combo/wcs/care_forms.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Status"
|
||||
msgstr "Statut"
|
||||
|
||||
|
@ -2845,7 +3032,8 @@ msgid "Number of cards per page (default 10)"
|
|||
msgstr "Nombre de fiches par page (par défaut : 10)"
|
||||
|
||||
#: apps/wcs/models.py
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Display mode"
|
||||
msgstr "Mode d’affichage"
|
||||
|
||||
|
@ -2859,6 +3047,11 @@ msgctxt "card-display-mode"
|
|||
msgid "Table"
|
||||
msgstr "Tableau"
|
||||
|
||||
#: apps/wcs/models.py
|
||||
msgctxt "card-display-mode"
|
||||
msgid "List"
|
||||
msgstr "Liste"
|
||||
|
||||
#: apps/wcs/models.py
|
||||
msgid "Display filters on the same line"
|
||||
msgstr "Afficher les filtres sur la même ligne"
|
||||
|
@ -2896,12 +3089,14 @@ msgid "From cell %s"
|
|||
msgstr "Depuis la cellule %s"
|
||||
|
||||
#: apps/wcs/models.py
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Receipt date"
|
||||
msgstr "Date de création"
|
||||
|
||||
#: apps/wcs/models.py
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Last modified"
|
||||
msgstr "Date de dernière modification"
|
||||
|
||||
|
@ -2937,7 +3132,8 @@ msgstr "Filtrer par :"
|
|||
msgid "Unknown Card"
|
||||
msgstr "Fiche inconnue"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/cards.html
|
||||
#: apps/wcs/templates/combo/wcs/cards-as-list.html
|
||||
#: apps/wcs/templates/combo/wcs/cards-as-table.html
|
||||
msgid "There are no cards."
|
||||
msgstr "Il n’y a aucune fiche."
|
||||
|
||||
|
@ -2994,156 +3190,177 @@ msgstr "authentification nécessaire"
|
|||
msgid "More items"
|
||||
msgstr "Afficher davantage d’éléments"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Grid Layout:"
|
||||
msgstr "Disposition de la grille :"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Add"
|
||||
msgstr "Ajouter"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Layout"
|
||||
msgstr "Disposition"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Automatic"
|
||||
msgstr "Automatique"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: manager/forms.py
|
||||
msgid "1 column"
|
||||
msgstr "Une colonne"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: manager/forms.py
|
||||
msgid "2 columns"
|
||||
msgstr "Deux colonnes"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: manager/forms.py
|
||||
msgid "3 columns"
|
||||
msgstr "Trois colonnes"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-list.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Content type"
|
||||
msgstr "Type de contenu"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Card field"
|
||||
msgstr "Champ de la fiche"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "User field"
|
||||
msgstr "Champ utilisateur"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Card information field"
|
||||
msgstr "Champ information de la fiche"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Custom"
|
||||
msgstr "Personnalisé"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-list.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
#: data/models.py
|
||||
msgid "Link"
|
||||
msgstr "Lien"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Card Fields"
|
||||
msgstr "Champs de la fiche"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "User Fields"
|
||||
msgstr "Champs utilisateur"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Card Information Fields"
|
||||
msgstr "Champs information de la fiche"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Field content"
|
||||
msgstr "Contenu du champ"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Label & Value"
|
||||
msgstr "Libellé et valeur"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Label only"
|
||||
msgstr "Libellé uniquement"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Value only"
|
||||
msgstr "Valeur uniquement"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Subtitle"
|
||||
msgstr "Sous-titre"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "File display mode"
|
||||
msgstr "Mode d’affichage du fichier"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Thumbnail"
|
||||
msgstr "Vignette"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Empty value display mode"
|
||||
msgstr "Mode d’affichage en cas de valeur absente"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Display as empty"
|
||||
msgstr "Inclure la case vide"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Hide"
|
||||
msgstr "Ne pas inclure la case"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Display a custom text"
|
||||
msgstr "Inclure un texte personnalisé"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
msgid "Empty value custom text"
|
||||
msgstr "Texte personnalisé"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
#: manager/forms.py
|
||||
msgid "Value template"
|
||||
msgstr "Gabarit"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-list.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Label template"
|
||||
msgstr "Libellé (gabarit)"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Link destination"
|
||||
msgstr "Destination du lien"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-list.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "URL (Template)"
|
||||
msgstr "URL (Gabarit)"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-card.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Button"
|
||||
msgstr "Bouton"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Display headers:"
|
||||
msgstr "Afficher les en-têtes :"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Display headers"
|
||||
msgstr "Afficher les en-têtes"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Custom text to replace empty value"
|
||||
msgstr "Texte personnalisé pour remplacer une valeur vide"
|
||||
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display.html
|
||||
#: apps/wcs/templates/combo/wcs/manager/card-cell-form-display-as-table.html
|
||||
msgid "Header"
|
||||
msgstr "En-tête"
|
||||
|
||||
|
@ -3184,6 +3401,15 @@ msgstr "Il n’y a aucune demande."
|
|||
msgid "There are no done forms or they have been removed."
|
||||
msgstr "Il n’y a aucune demande traitée ou celles-ci ont été supprimées."
|
||||
|
||||
#: apps/wcs/utils.py
|
||||
#, python-format
|
||||
msgid "Unable to get WCS service (%s)"
|
||||
msgstr "Impossible d’obtenir le service w.c.s. (%s)"
|
||||
|
||||
#: apps/wcs/utils.py
|
||||
msgid "Unable to get WCS data"
|
||||
msgstr "Impossible d’obtenir les données de w.c.s."
|
||||
|
||||
#: apps/wcs/views.py
|
||||
msgid "Looking up tracking code is currently rate limited."
|
||||
msgstr "La vitesse de recherche par code de suivi est actuellement réduite."
|
||||
|
@ -3193,6 +3419,11 @@ msgstr "La vitesse de recherche par code de suivi est actuellement réduite."
|
|||
msgid "Use tracking code %s"
|
||||
msgstr "Utiliser le code de suivi %s"
|
||||
|
||||
#: data/exceptions.py
|
||||
#, python-format
|
||||
msgid "Missing groups: %s"
|
||||
msgstr "Rôles manquants : %s"
|
||||
|
||||
#: data/forms.py manager/forms.py
|
||||
msgid "Invalid syntax."
|
||||
msgstr "Syntaxe invalide."
|
||||
|
@ -3239,10 +3470,6 @@ msgstr "URL de redirection"
|
|||
msgid "Public"
|
||||
msgstr "Publique"
|
||||
|
||||
#: data/models.py manager/forms.py
|
||||
msgid "Roles"
|
||||
msgstr "Rôles"
|
||||
|
||||
#: data/models.py settings.py
|
||||
msgid "Picture"
|
||||
msgstr "Image"
|
||||
|
@ -3286,6 +3513,10 @@ msgstr ""
|
|||
"Page parente inconnue pour « %s », la page a été placée à la racine du site "
|
||||
"et a été marquée comme exclue des menus."
|
||||
|
||||
#: data/models.py
|
||||
msgid "Unable to import : given export is too old"
|
||||
msgstr "Erreur à l’import : l’export envoyé est trop ancien"
|
||||
|
||||
#: data/models.py
|
||||
#, python-format
|
||||
msgid "Copy of %s"
|
||||
|
@ -3504,13 +3735,8 @@ msgstr "Autre :"
|
|||
|
||||
#: data/utils.py
|
||||
#, python-format
|
||||
msgid "Missing groups: %s"
|
||||
msgstr "Rôles manquants : %s"
|
||||
|
||||
#: data/utils.py
|
||||
#, python-format
|
||||
msgid "Unknown page \"%s\"."
|
||||
msgstr "Page inconnue (« %s »)"
|
||||
msgid "Unknown page \"%s\" for cell \"%s\"."
|
||||
msgstr "Page inconnue « %s » pour la cellule « %s »."
|
||||
|
||||
#: data/utils.py
|
||||
msgid "TAR file should provide _site.json file"
|
||||
|
@ -3580,10 +3806,6 @@ msgstr "Utilisateurs sans aucun de ces rôles"
|
|||
msgid "Site Export File"
|
||||
msgstr "Fichier d’export de site"
|
||||
|
||||
#: manager/forms.py manager/templates/combo/manager_home.html
|
||||
msgid "Pages"
|
||||
msgstr "Pages"
|
||||
|
||||
#: manager/forms.py
|
||||
msgid "Assets Files"
|
||||
msgstr "Fichiers de ressources"
|
||||
|
@ -3660,23 +3882,20 @@ msgid "Duplicate"
|
|||
msgstr "Dupliquer"
|
||||
|
||||
#: manager/templates/combo/manager_home.html
|
||||
msgid "Export Site"
|
||||
msgstr "Exporter le site"
|
||||
|
||||
#: manager/templates/combo/manager_home.html
|
||||
msgid "Import Site"
|
||||
msgstr "Importer un site"
|
||||
msgid "Pages outside applications"
|
||||
msgstr "Pages hors applications"
|
||||
|
||||
#: manager/templates/combo/manager_home.html
|
||||
msgid ""
|
||||
"\n"
|
||||
" Use drag and drop with the ⣿ handles to reorder and change hierarchy "
|
||||
"of pages.\n"
|
||||
" "
|
||||
" Use drag and drop with the ⣿ handles to reorder and change "
|
||||
"hierarchy of pages.\n"
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Vous pouvez utiliser les poignées ⣿ pour ordonner et modifier la hiérarchie "
|
||||
"des pages."
|
||||
"des pages.\n"
|
||||
" "
|
||||
|
||||
#: manager/templates/combo/manager_home.html
|
||||
#: manager/templates/combo/page_view.html
|
||||
|
@ -3692,8 +3911,30 @@ msgid ""
|
|||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Ce site n’a pas encore de pages. Cliquez sur le bouton « Nouvelle » dans le "
|
||||
"coin supérieur droit de la page pour en ajouter une première."
|
||||
"Ce site n’a pas encore de pages. Cliquez sur le bouton « Nouvelle page » "
|
||||
"dans le coin supérieur droit de la page pour en ajouter une première."
|
||||
|
||||
#: manager/templates/combo/manager_home.html
|
||||
#: manager/templates/combo/page_history.html
|
||||
msgid "Actions"
|
||||
msgstr "Actions"
|
||||
|
||||
#: manager/templates/combo/manager_home.html
|
||||
msgid "New page"
|
||||
msgstr "Nouvelle page"
|
||||
|
||||
#: manager/templates/combo/manager_home.html
|
||||
msgid "Export Site"
|
||||
msgstr "Exporter le site"
|
||||
|
||||
#: manager/templates/combo/manager_home.html
|
||||
msgid "Import Site"
|
||||
msgstr "Importer un site"
|
||||
|
||||
#: manager/templates/combo/manager_home.html
|
||||
#: manager/templates/combo/page_view.html
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: manager/templates/combo/page_add.html
|
||||
msgid "Edit Page"
|
||||
|
@ -3740,8 +3981,16 @@ msgid "Compare"
|
|||
msgstr "Comparer"
|
||||
|
||||
#: manager/templates/combo/page_history.html
|
||||
msgid "Actions"
|
||||
msgstr "Actions"
|
||||
#, python-format
|
||||
msgid "1 other this day"
|
||||
msgid_plural "%(counter)s others"
|
||||
msgstr[0] "1 autre ce jour"
|
||||
msgstr[1] "%(counter)s autres ce jour"
|
||||
|
||||
#: manager/templates/combo/page_history.html
|
||||
#, python-format
|
||||
msgid "Version %(version)s"
|
||||
msgstr "Version %(version)s"
|
||||
|
||||
#: manager/templates/combo/page_history.html
|
||||
msgid "view"
|
||||
|
@ -3927,6 +4176,22 @@ msgstr "vide"
|
|||
msgid "like parent"
|
||||
msgstr "identique à la page parente"
|
||||
|
||||
#: manager/templates/combo/page_view.html
|
||||
msgid "This page is readonly."
|
||||
msgstr "Cette page en lecture seule."
|
||||
|
||||
#: manager/templates/combo/page_view.html
|
||||
msgid "Restore version"
|
||||
msgstr "Restaurer cette version"
|
||||
|
||||
#: manager/templates/combo/page_view.html
|
||||
msgid "Export version"
|
||||
msgstr "Exporter cette version"
|
||||
|
||||
#: manager/templates/combo/page_view.html
|
||||
msgid "Inspect version"
|
||||
msgstr "Inspecter cette version"
|
||||
|
||||
#: manager/templates/combo/page_view.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
|
@ -4082,6 +4347,11 @@ msgstr ""
|
|||
msgid "Page %s has been duplicated."
|
||||
msgstr "La page « %s » a été dupliquée."
|
||||
|
||||
#: manager/views.py
|
||||
#, python-format
|
||||
msgid "Version %s"
|
||||
msgstr "Version %s"
|
||||
|
||||
#: manager/views.py
|
||||
msgid "Snapshot"
|
||||
msgstr "Sauvegarde"
|
||||
|
@ -4315,10 +4585,8 @@ msgstr "Colonne du milieu"
|
|||
|
||||
#: settings.py
|
||||
msgid ""
|
||||
"Map data © <a href=\"https://openstreetmap.org\">OpenStreetMap</a> "
|
||||
"contributors, <a href=\"http://creativecommons.org/licenses/by-sa/2.0/\">CC-"
|
||||
"BY-SA</a>"
|
||||
"Map data © <a href=\"https://www.openstreetmap.org/"
|
||||
"copyright\">OpenStreetMap</a>"
|
||||
msgstr ""
|
||||
"Données © contributeurs <a href='https://openstreetmap."
|
||||
"org'>OpenStreetMap</a>, <a href='http://creativecommons.org/licenses/by-"
|
||||
"sa/2.0/deed.fr'>CC-BY-SA</a>"
|
||||
"Données cartographiques © <a href=\"https://www.openstreetmap.org/"
|
||||
"copyright\">OpenStreetMap</a>"
|
||||
|
|
|
@ -7,8 +7,8 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: combo(js) 0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-09-12 16:14+0200\n"
|
||||
"PO-Revision-Date: 2021-09-21 19:18+0200\n"
|
||||
"POT-Creation-Date: 2024-04-05 15:48+0000\n"
|
||||
"PO-Revision-Date: 2024-04-05 17:49+0200\n"
|
||||
"Last-Translator: Frederic Peters <<fpeters@entrouvert.com>\n"
|
||||
"Language: French\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
|
@ -28,6 +28,18 @@ msgstr "Dézoomer"
|
|||
msgid "Display my position"
|
||||
msgstr "Afficher ma position"
|
||||
|
||||
#: apps/maps/static/js/combo.map.js
|
||||
msgid "Search address"
|
||||
msgstr "Chercher une adresse"
|
||||
|
||||
#: apps/maps/static/js/combo.map.js
|
||||
msgid "An error occured while fetching results"
|
||||
msgstr "Erreur à la récupération des résultats"
|
||||
|
||||
#: apps/maps/static/js/combo.map.js
|
||||
msgid "Searching..."
|
||||
msgstr "Recherche en cours…"
|
||||
|
||||
#: manager/static/js/combo.manager.js
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
|
@ -45,8 +57,8 @@ msgid "no"
|
|||
msgstr "non"
|
||||
|
||||
#: manager/static/js/combo.manager.js
|
||||
msgid "User field"
|
||||
msgstr "Champ utilisateur"
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: manager/static/js/combo.manager.js
|
||||
msgid "Custom"
|
||||
|
@ -56,6 +68,14 @@ msgstr "Personnalisé"
|
|||
msgid "Link"
|
||||
msgstr "Lien"
|
||||
|
||||
#: manager/static/js/combo.manager.js
|
||||
msgid "User field"
|
||||
msgstr "Champ utilisateur"
|
||||
|
||||
#: manager/static/js/combo.manager.js
|
||||
msgid "Card information field"
|
||||
msgstr "Champ information de la fiche"
|
||||
|
||||
#: manager/static/js/combo.manager.js
|
||||
msgid "Header:"
|
||||
msgstr "En-tête :"
|
||||
|
|
|
@ -159,7 +159,8 @@ div.cell h3 span.visibility-summary {
|
|||
|
||||
div.cell h3 span.invalid,
|
||||
ul.list-of-links span.invalid {
|
||||
color: #df2240;
|
||||
color: #df2240;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.invalid::before {
|
||||
|
@ -778,76 +779,21 @@ form .choices {
|
|||
}
|
||||
}
|
||||
|
||||
p.snapshot-description {
|
||||
font-size: 80%;
|
||||
margin: 0;
|
||||
.snapshots-list .collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.diff {
|
||||
margin: 1em 0;
|
||||
h3 {
|
||||
del, ins {
|
||||
font-weight: bold;
|
||||
background-color: transparent;
|
||||
}
|
||||
del {
|
||||
color: #fbb6c2 !important;
|
||||
}
|
||||
ins {
|
||||
color: #d4fcbc !important;
|
||||
}
|
||||
}
|
||||
a.button.button-paragraph {
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 150%;
|
||||
padding-top: 0.8em;
|
||||
padding-bottom: 0.8em;
|
||||
}
|
||||
|
||||
ins {
|
||||
text-decoration: none;
|
||||
background-color: #d4fcbc;
|
||||
}
|
||||
|
||||
del {
|
||||
text-decoration: line-through;
|
||||
background-color: #fbb6c2;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
table.diff {
|
||||
background: white;
|
||||
border: 1px solid #f3f3f3;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
colgroup, thead, tbody, td {
|
||||
border: 1px solid #f3f3f3;
|
||||
}
|
||||
tbody tr:nth-child(even) {
|
||||
background: #fdfdfd;
|
||||
}
|
||||
th, td {
|
||||
max-width: 30vw;
|
||||
/* it will not actually limit width as the table is set to
|
||||
* expand to 100% but it will prevent one side getting wider
|
||||
*/
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
}
|
||||
.diff_header {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
td.diff_header {
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
color: #606060;
|
||||
}
|
||||
.diff_next {
|
||||
display: none;
|
||||
}
|
||||
.diff_add {
|
||||
background-color: #aaffaa;
|
||||
}
|
||||
.diff_chg {
|
||||
background-color: #ffff77;
|
||||
}
|
||||
.diff_sub {
|
||||
background-color: #ffaaaa;
|
||||
}
|
||||
.application-logo, .application-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
|
|
@ -469,7 +469,7 @@ $(function() {
|
|||
});
|
||||
|
||||
function init_select2() {
|
||||
$('select[data-autocomplete]').each(function(idx, elem) {
|
||||
$('select[data-combo-autocomplete]').each(function(idx, elem) {
|
||||
$(elem).select2({
|
||||
ajax: {
|
||||
url: $(elem).data('select2-url'),
|
||||
|
@ -485,26 +485,8 @@ $(function() {
|
|||
|
||||
|
||||
// UI to customize the layout of the content of a wcs-card-cell
|
||||
const Card_cell_custom = function(cell, display_mode) {
|
||||
const Card_cell_custom = function(cell) {
|
||||
this.cell = cell;
|
||||
this.display_mode = display_mode;
|
||||
var selector = (this.display_mode == 'card') ? '.as-card' : '.as-table';
|
||||
if (display_mode == 'card') {
|
||||
this.gridSchema_default = {
|
||||
"grid_class": "fx-grid--auto",
|
||||
"cells": []
|
||||
}
|
||||
} else {
|
||||
this.gridSchema_default = {
|
||||
"grid_headers": false,
|
||||
"cells": []
|
||||
}
|
||||
}
|
||||
this.deletBtn_selector = selector + '.wcs-cards-cell--grid-cell-delete';
|
||||
this.editBtn_selector = selector + '.wcs-cards-cell--grid-cell-edit';
|
||||
this.contentEl_selector = selector + '.wcs-cards-cell--grid-cell-content';
|
||||
this.grid_cell_selector = selector + '.wcs-cards-cell--grid-cell';
|
||||
this.grid_cell_placeholder_selector = selector + '.wcs-cards-cell--grid-cell-placeholder';
|
||||
this.init();
|
||||
}
|
||||
|
||||
|
@ -538,7 +520,7 @@ Card_cell_custom.prototype = {
|
|||
open: function( event, ui ) {
|
||||
if (_self.display_mode == 'card') {
|
||||
$(_self.grid_form[0]).val(_self.gridSchema.grid_class || 'fx-grid--auto');
|
||||
} else {
|
||||
} else if (_self.display_mode == 'table') {
|
||||
$(_self.grid_form[0]).prop('checked', _self.gridSchema.grid_headers || false);
|
||||
}
|
||||
},
|
||||
|
@ -557,7 +539,7 @@ Card_cell_custom.prototype = {
|
|||
if (_self.display_mode == 'card') {
|
||||
const select_layout = _self.grid_form[0];
|
||||
form_datas.grid_class = select_layout.value;
|
||||
} else {
|
||||
} else if (_self.display_mode == 'table') {
|
||||
const with_headers = _self.grid_form[0];
|
||||
form_datas.grid_headers = with_headers.checked;
|
||||
}
|
||||
|
@ -571,7 +553,7 @@ Card_cell_custom.prototype = {
|
|||
grid__set_schema: function(form_datas){
|
||||
if (this.display_mode == 'card') {
|
||||
this.gridSchema.grid_class = form_datas.grid_class;
|
||||
} else {
|
||||
} else if (this.display_mode == 'table') {
|
||||
this.gridSchema.grid_headers = form_datas.grid_headers;
|
||||
}
|
||||
this.grid__set_layout();
|
||||
|
@ -587,19 +569,25 @@ Card_cell_custom.prototype = {
|
|||
this.grid_wrapper.classList.remove(this.grid_wrapper.dataset.grid_layout); }
|
||||
this.grid_wrapper.classList.add(this.gridSchema.grid_class);
|
||||
this.grid_wrapper.dataset.grid_layout = this.gridSchema.grid_class;
|
||||
} else {
|
||||
} else if (this.display_mode == 'table') {
|
||||
this.grid_layout_label.textContent = this.gridSchema.grid_headers ? gettext('yes') : gettext('no');
|
||||
}
|
||||
},
|
||||
grid_toggle: function() {
|
||||
if (this.toggleBtn.checked && $(this.displayModeSelect).val() == this.display_mode) {
|
||||
this.grid.hidden = false;
|
||||
if (this.toggleBtn.checked) {
|
||||
this.grids.forEach( (el) => {
|
||||
el.hidden = (el == this.grid) ? false : true
|
||||
})
|
||||
} else {
|
||||
this.grid.hidden = true;
|
||||
this.grids.forEach( (el) => {
|
||||
el.hidden = true;
|
||||
})
|
||||
}
|
||||
},
|
||||
// Grid cell methods
|
||||
grid_cell__init_form: function() {
|
||||
if (this._init_form_done) return
|
||||
this._init_form_done = true
|
||||
const varname_select = this.grid_cell_form.elements.field_varname;
|
||||
const user_varname_select = this.grid_cell_form.elements.user_field_varname;
|
||||
const link_select = this.grid_cell_form.elements.link_page;
|
||||
|
@ -740,8 +728,10 @@ Card_cell_custom.prototype = {
|
|||
grid_cell__add: function(schema_cell) {
|
||||
const new_grid_cell = this.grid_cell__new();
|
||||
this.grid_cell__set(new_grid_cell, schema_cell);
|
||||
new_grid_cell.deletBtn.addEventListener('click', () => {this.grid_cell__delete(new_grid_cell)});
|
||||
new_grid_cell.editBtn.addEventListener('click', () => {this.grid_cell__edit(new_grid_cell)});
|
||||
if (new_grid_cell.deletBtn)
|
||||
new_grid_cell.deletBtn.addEventListener('click', () => {this.grid_cell__delete(new_grid_cell)});
|
||||
if (new_grid_cell.editBtn)
|
||||
new_grid_cell.editBtn.addEventListener('click', () => {this.grid_cell__edit(new_grid_cell)});
|
||||
this.grid_wrapper.append(new_grid_cell);
|
||||
},
|
||||
grid_cell__delete: function(grid_cell) {
|
||||
|
@ -757,7 +747,7 @@ Card_cell_custom.prototype = {
|
|||
field.value = '';
|
||||
}
|
||||
|
||||
if (this.display_mode == 'card') {
|
||||
if (this.display_mode === 'card') {
|
||||
if (grid_cell.dataset.varname == '@custom@') {
|
||||
this.grid_cell_form.elements.entry_type.value = '@custom@';
|
||||
this.grid_cell_form.elements.custom_template.value = grid_cell.dataset.template || '';
|
||||
|
@ -799,13 +789,17 @@ Card_cell_custom.prototype = {
|
|||
}
|
||||
}
|
||||
this.grid_cell_form.elements.cell_size.value = grid_cell.dataset.cell_size || '';
|
||||
} else {
|
||||
|
||||
} else if (this.display_mode === 'table') {
|
||||
if (grid_cell.dataset.varname == '@custom@') {
|
||||
this.grid_cell_form.elements.entry_type.value = '@custom@';
|
||||
this.grid_cell_form.elements.custom_header.value = grid_cell.dataset.header || '';
|
||||
if (this.display_mode == 'table') {
|
||||
this.grid_cell_form.elements.custom_header.value = grid_cell.dataset.header || '';
|
||||
}
|
||||
this.grid_cell_form.elements.custom_template.value = grid_cell.dataset.template || '';
|
||||
} else if (grid_cell.dataset.varname == '@link@') {
|
||||
this.grid_cell_form.elements.entry_type.value = '@link@';
|
||||
this.grid_cell_form.elements.link_display_mode.value = grid_cell.dataset.display_mode;
|
||||
this.grid_cell_form.elements.link_header.value = grid_cell.dataset.header || '';
|
||||
this.grid_cell_form.elements.link_label_template.value = grid_cell.dataset.template || '';
|
||||
if (grid_cell.dataset.link_field) {
|
||||
|
@ -814,7 +808,6 @@ Card_cell_custom.prototype = {
|
|||
this.grid_cell_form.elements.link_page.value = grid_cell.dataset.page || '';
|
||||
this.grid_cell_form.elements.link_url_template.value = grid_cell.dataset.url_template || '';
|
||||
}
|
||||
this.grid_cell_form.elements.link_display_mode.value = grid_cell.dataset.display_mode;
|
||||
} else {
|
||||
if (grid_cell.dataset.varname.startsWith('user:')) {
|
||||
this.grid_cell_form.elements.entry_type.value = '@user-field@';
|
||||
|
@ -836,6 +829,10 @@ Card_cell_custom.prototype = {
|
|||
this.grid_cell_form.elements.field_empty_text.value = '';
|
||||
}
|
||||
}
|
||||
} else if (this.display_mode == 'list') {
|
||||
this.grid_cell_form.elements.entry_type.value = '@link@';
|
||||
this.grid_cell_form.elements.link_label_template.value = grid_cell.dataset.template || '';
|
||||
this.grid_cell_form.elements.link_url_template.value = grid_cell.dataset.url_template || '';
|
||||
}
|
||||
},
|
||||
grid_cell__add_set_fields: function(grid_cell) {
|
||||
|
@ -910,7 +907,8 @@ Card_cell_custom.prototype = {
|
|||
}
|
||||
}
|
||||
schema_cell.cell_size = form_datas.cell_size;
|
||||
} else {
|
||||
|
||||
} else if (this.display_mode == 'table') {
|
||||
if (form_datas.entry_type == '@custom@') {
|
||||
schema_cell.varname = '@custom@';
|
||||
schema_cell.header = form_datas.custom_header;
|
||||
|
@ -947,6 +945,10 @@ Card_cell_custom.prototype = {
|
|||
}
|
||||
schema_cell.empty_value = form_datas.field_empty_text;
|
||||
}
|
||||
} else if (this.display_mode == 'list') {
|
||||
schema_cell.varname = '@link@';
|
||||
schema_cell.template = form_datas.link_label_template;
|
||||
schema_cell.url_template = form_datas.link_url_template;
|
||||
}
|
||||
return schema_cell
|
||||
},
|
||||
|
@ -959,17 +961,23 @@ Card_cell_custom.prototype = {
|
|||
this.grid_cell__add(schema_cell);
|
||||
this.grid__store_schema();
|
||||
},
|
||||
grid_cell__init: function() {
|
||||
if (!this.gridSchema_existing) return;
|
||||
|
||||
grid_cell__init: function() {
|
||||
if (!this.gridSchema.cells.length) return;
|
||||
|
||||
if (this.grid_wrapper.childElementCount) {
|
||||
while (this.grid_wrapper.lastElementChild) {
|
||||
this.grid_wrapper.removeChild(this.grid_wrapper.lastElementChild);
|
||||
}
|
||||
}
|
||||
this.gridSchema.cells.forEach((el) => {
|
||||
this.grid_cell__add(el);
|
||||
});
|
||||
},
|
||||
// Init methods
|
||||
on: function() {
|
||||
if (!(this.toggleBtn.checked && $(this.displayModeSelect).val() == this.display_mode && !this.is_on)) {
|
||||
return false;
|
||||
if (!(this.toggleBtn.checked && this.displayModeSelect.value == this.display_mode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store = this.cell.querySelector('input[id$="-custom_schema"]');
|
||||
|
@ -980,7 +988,15 @@ Card_cell_custom.prototype = {
|
|||
console.error(e);
|
||||
this.gridSchema_existing = undefined;
|
||||
}
|
||||
this.gridSchema = this.gridSchema_existing || this.gridSchema_default;
|
||||
|
||||
this.allGridSchemas = Object.assign({}, this.gridSchema_default);
|
||||
if (this.gridSchema_existing) {
|
||||
if (this.gridSchema_existing.display_mode)
|
||||
Object.assign(this.allGridSchemas[this.gridSchema_existing.display_mode], this.gridSchema_existing);
|
||||
else
|
||||
Object.assign(this.allGridSchemas[this.display_mode], this.gridSchema_existing);
|
||||
}
|
||||
this.gridSchema = this.allGridSchemas[this.display_mode];
|
||||
|
||||
this.grid__set_layout();
|
||||
this.grid_cell__init();
|
||||
|
@ -1016,20 +1032,36 @@ Card_cell_custom.prototype = {
|
|||
this.is_on = true;
|
||||
},
|
||||
init_elements: function() {
|
||||
var selector = (this.display_mode == 'card') ? '.as-card' : '.as-table';
|
||||
var selector;
|
||||
if (this.display_mode == 'card') {
|
||||
selector = '.as-card';
|
||||
} else if (this.display_mode == 'table') {
|
||||
selector = '.as-table';
|
||||
} else {
|
||||
selector = '.as-list';
|
||||
}
|
||||
|
||||
this.deletBtn_selector = selector + '.wcs-cards-cell--grid-cell-delete';
|
||||
this.editBtn_selector = selector + '.wcs-cards-cell--grid-cell-edit';
|
||||
this.contentEl_selector = selector + '.wcs-cards-cell--grid-cell-content';
|
||||
this.grid_cell_selector = selector + '.wcs-cards-cell--grid-cell';
|
||||
this.grid_cell_placeholder_selector = selector + '.wcs-cards-cell--grid-cell-placeholder';
|
||||
|
||||
this.grids = this.cell.querySelectorAll('.wcs-cards-cell--grid');
|
||||
this.grid = this.cell.querySelector(selector + '.wcs-cards-cell--grid');
|
||||
|
||||
if (this.display_mode == 'card') {
|
||||
this.edit_grid_btn = this.cell.querySelector(selector + '.wcs-cards-cell--grid-layout-btn');
|
||||
this.grid_layout_label = this.cell.querySelector(selector + '.wcs-cards-cell--grid-layout-mode');
|
||||
} else {
|
||||
} else if (this.display_mode == 'table') {
|
||||
this.edit_grid_btn = this.cell.querySelector(selector + '.wcs-cards-cell--grid-headers-btn');
|
||||
this.grid_layout_label = this.cell.querySelector(selector + '.wcs-cards-cell--grid-headers-mode');
|
||||
}
|
||||
|
||||
const grid_form_tpl = this.cell.querySelector(selector + '.wcs-cards-cell--grid-form-tpl');
|
||||
this.grid_form = this.parse_tpl(grid_form_tpl);
|
||||
if (this.display_mode != 'list') {
|
||||
const grid_form_tpl = this.cell.querySelector(selector + '.wcs-cards-cell--grid-form-tpl');
|
||||
this.grid_form = this.parse_tpl(grid_form_tpl);
|
||||
}
|
||||
|
||||
this.add_grid_cell_btn = this.cell.querySelector(selector + '.wcs-cards-cell--add-grid-cell-btn');
|
||||
|
||||
|
@ -1042,25 +1074,48 @@ Card_cell_custom.prototype = {
|
|||
this.grid_wrapper = this.cell.querySelector(selector + '.wcs-cards-cell--grid-cells');
|
||||
},
|
||||
init: function() {
|
||||
const cardSchema_el = this.cell.querySelector('[id*="card-schema-eservices"]');
|
||||
const cardSchema_el = this.cell.querySelector('[id*="card-schema-"]');
|
||||
this.cardSchema = cardSchema_el ? JSON.parse(cardSchema_el.innerText) : undefined;
|
||||
|
||||
if (!this.cardSchema) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.is_on = false;
|
||||
this.gridSchema_default = {
|
||||
"card": {
|
||||
"display_mode": "card",
|
||||
"grid_class": "fx-grid--auto",
|
||||
"cells": []
|
||||
},
|
||||
"table": {
|
||||
"display_mode": "table",
|
||||
"grid_headers": false,
|
||||
"cells": []
|
||||
},
|
||||
"list": {
|
||||
"display_mode": "list",
|
||||
"cells": [
|
||||
{
|
||||
varname: "@link@",
|
||||
template: "{{ card.text }}",
|
||||
url_template: "{% if card_page_base_url %}{{ card_page_base_url }}{{ card.id }}/{% else %}{{ card.url }}{% endif %}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
this.init_elements();
|
||||
this.is_on = false;
|
||||
|
||||
this.toggleBtn = this.cell.querySelector('input[id$="-customize_display"]');
|
||||
this.displayModeSelect = this.cell.querySelector('select[id$="-display_mode"]');
|
||||
|
||||
$(this.toggleBtn).on('change', (e) => {
|
||||
$(this.displayModeSelect).on('change', (e) => {
|
||||
this.display_mode = e.target.value;
|
||||
this.init_elements();
|
||||
this.on();
|
||||
this.grid_toggle();
|
||||
}).change();
|
||||
$(this.displayModeSelect).on('change', (e) => {
|
||||
$(this.toggleBtn).on('change', (e) => {
|
||||
this.on();
|
||||
this.grid_toggle();
|
||||
}).change();
|
||||
|
@ -1070,13 +1125,9 @@ Card_cell_custom.prototype = {
|
|||
// Active custom card UI for each card cell
|
||||
$(function() {
|
||||
$('.wcs-card-cell').each(function(i, el) {
|
||||
const custom_card_as_card = new Card_cell_custom(el, 'card');
|
||||
const custom_card_as_card = new Card_cell_custom(el);
|
||||
$(el).on('combo:cellform-reloaded', function() {
|
||||
custom_card_as_card.init();
|
||||
});
|
||||
const custom_card_as_table = new Card_cell_custom(el, 'table');
|
||||
$(el).on('combo:cellform-reloaded', function() {
|
||||
custom_card_as_table.init();
|
||||
});
|
||||
})
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% load i18n %}
|
||||
{% block cell-form-appearance %}
|
||||
{{ appearance_form.as_p }}
|
||||
{% if cell.can_have_assets %}
|
||||
{% if cell.can_have_assets and not is_readonly %}
|
||||
<p><a rel="popup" data-selector="div#assets-listing" href="{% url 'combo-manager-slot-assets' cell_reference=cell.get_reference %}"
|
||||
>{% trans 'Assets' %}</a></p>
|
||||
{% endif %}
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
{% endblock %}
|
||||
{% block page-title %}{% firstof site_title "Combo" %}{% endblock %}
|
||||
{% block site-title %}{% firstof site_title "Combo" %}{% endblock %}
|
||||
{% block footer %}Combo — Copyright © Entr'ouvert{% endblock %}
|
||||
|
||||
{% block homepage-url %}
|
||||
{% url 'combo-manager-homepage' as default_homepage_url %}
|
||||
|
|
|
@ -22,19 +22,21 @@
|
|||
{% if tab.template %}{% include tab.template %}{% else %}{{ tab.form_instance.as_p }}{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="cell-properties--buttons">
|
||||
{% block cell-buttons %}
|
||||
<button class="submit-button save">{% trans 'Save' %}</button>
|
||||
<span>
|
||||
<a class="pk-button duplicate-button" rel="popup" title="{% trans 'Duplicate' %}"
|
||||
href="{% url 'combo-manager-page-duplicate-cell' page_pk=page.id cell_reference=cell.get_reference %}"
|
||||
><span>{% trans 'Duplicate' %}</span></a>
|
||||
<a class="pk-button delete-button" rel="popup" title="{% trans 'Delete' %}"
|
||||
href="{% url 'combo-manager-page-delete-cell' page_pk=page.id cell_reference=cell.get_reference %}"
|
||||
><span>{% trans 'Delete' %}</span></a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% if not is_readonly %}
|
||||
<div class="cell-properties--buttons">
|
||||
{% block cell-buttons %}
|
||||
<button class="submit-button save">{% trans 'Save' %}</button>
|
||||
<span>
|
||||
<a class="pk-button duplicate-button" rel="popup" title="{% trans 'Duplicate' %}"
|
||||
href="{% url 'combo-manager-page-duplicate-cell' page_pk=page.id cell_reference=cell.get_reference %}"
|
||||
><span>{% trans 'Duplicate' %}</span></a>
|
||||
<a class="pk-button delete-button" rel="popup" title="{% trans 'Delete' %}"
|
||||
href="{% url 'combo-manager-page-delete-cell' page_pk=page.id cell_reference=cell.get_reference %}"
|
||||
><span>{% trans 'Delete' %}</span></a>
|
||||
</span>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,45 +1,59 @@
|
|||
{% extends "combo/manager_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load i18n thumbnail %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Pages' %}</h2>
|
||||
<span class="actions">
|
||||
{% if user.is_superuser %}
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
{% endif %}
|
||||
{% if can_add_page %}
|
||||
<a rel="popup" href="{% url 'combo-manager-page-add' %}">{% trans 'New' %}</a>
|
||||
{% endif %}
|
||||
{% if user.is_superuser %}
|
||||
<ul class="extra-actions-menu">
|
||||
<li><a href="{% url 'combo-manager-site-export' %}" rel="popup" data-autoclose-dialog="true">{% trans 'Export Site' %}</a></li>
|
||||
<li><a href="{% url 'combo-manager-site-import' %}">{% trans 'Import Site' %}</a></li>
|
||||
<li><a href="{% url 'combo-manager-invalid-cell-report' %}">{% trans 'Anomaly report' %}</a></li>
|
||||
<li><a href="{% url 'combo-manager-site-settings' %}" rel="popup" data-autoclose-dialog="true">{% trans 'Site Settings' %}</a></li>
|
||||
{% for extra_action in extra_actions %}
|
||||
<li><a href="{{ extra_action.href }}">{{ extra_action.text }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if application %}
|
||||
<h2>
|
||||
{% if application.icon %}
|
||||
{% thumbnail application.icon '64x64' format='PNG' as im %}
|
||||
<img src="{{ im.url }}" alt="" class="application-logo" />
|
||||
{% endthumbnail %}
|
||||
{% endif %}
|
||||
{{ application }}
|
||||
</h2>
|
||||
{% elif no_application %}
|
||||
<h2>{% trans 'Pages outside applications' %}</h2>
|
||||
{% else %}
|
||||
<h2>{% trans 'Pages' %}</h2>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
{% if application %}
|
||||
<a href="{% url 'combo-manager-homepage' %}?application={{ application.slug }}">{{ application }}</a>
|
||||
{% elif no_application %}
|
||||
<a href="{% url 'combo-manager-homepage' %}?no-application">{% trans "Pages outside applications" %}</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if object_list %}
|
||||
|
||||
<p class="hint">
|
||||
{% blocktrans %}
|
||||
Use drag and drop with the ⣿ handles to reorder and change hierarchy of pages.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% if not application and not no_application %}
|
||||
<p class="hint">
|
||||
{% blocktrans %}
|
||||
Use drag and drop with the ⣿ handles to reorder and change hierarchy of pages.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="objects-list" id="pages-list" data-page-order-url="{% url 'combo-manager-page-order' %}">
|
||||
{% for page in object_list %}
|
||||
<div class="page level-{{page.level}}{% if collapse_pages %} untoggled{% endif %}" data-page-id="{{page.id}}" data-level="{{page.level}}">
|
||||
{% if user.is_superuser %}<span class="handle">⣿</span>{% endif %}
|
||||
{% if user.is_superuser and not application and not no_application %}<span class="handle">⣿</span>{% endif %}
|
||||
<span class="group1">
|
||||
<a href="{% url 'combo-manager-page-view' pk=page.id %}">
|
||||
{% if not application and not no_application %}
|
||||
{% for application in page.applications %}
|
||||
{% if application.icon %}
|
||||
{% thumbnail application.icon '16x16' format='PNG' as im %}
|
||||
<img src="{{ im.url }}" alt="" class="application-icon" width="16" />
|
||||
{% endthumbnail %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{{ page.title }}
|
||||
{% for label in page.extra_labels %}{% if forloop.first %}<small>({% endif %}{{ label }}{% if forloop.last %})</small>{% else %}, {% endif %}{% endfor %}
|
||||
</a>
|
||||
|
@ -68,3 +82,44 @@
|
|||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% if not application and not no_application %}
|
||||
<aside id="sidebar">
|
||||
{% if can_add_page or user.is_superuser %}
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
{% if can_add_page %}
|
||||
<a class="button button-paragraph" rel="popup" href="{% url 'combo-manager-page-add' %}">{% trans 'New page' %}</a>
|
||||
{% endif %}
|
||||
{% if user.is_superuser %}
|
||||
<a class="button button-paragraph" href="{% url 'combo-manager-site-export' %}" rel="popup" data-autoclose-dialog="true">{% trans 'Export Site' %}</a>
|
||||
<a class="button button-paragraph" href="{% url 'combo-manager-site-import' %}">{% trans 'Import Site' %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_superuser %}
|
||||
<h3>{% trans "Navigation" %}</h3>
|
||||
<a class="button button-paragraph" href="{% url 'combo-manager-invalid-cell-report' %}">{% trans 'Anomaly report' %}</a>
|
||||
<a class="button button-paragraph" href="{% url 'combo-manager-site-settings' %}" rel="popup" data-autoclose-dialog="true">{% trans 'Site Settings' %}</a>
|
||||
{% for extra_action in extra_actions %}
|
||||
<a class="button button-paragraph" href="{{ extra_action.href }}">{{ extra_action.text }}</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if applications %}
|
||||
<h3>{% trans "Applications" %}</h3>
|
||||
{% for application in applications %}
|
||||
<a class="button button-paragraph" href="?application={{ application.slug }}">
|
||||
{% thumbnail application.icon '16x16' format='PNG' as im %}
|
||||
<img src="{{ im.url }}" alt="" class="application-icon" width="16" />
|
||||
{% endthumbnail %}
|
||||
{{ application.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<a class="button button-paragraph" href="?no-application">
|
||||
{% trans "Pages outside applications" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -29,9 +29,9 @@
|
|||
<th>{% trans 'User' %}</th>
|
||||
<th>{% trans 'Actions' %}</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody class="snapshots-list">
|
||||
{% for snapshot in object_list %}
|
||||
<tr>
|
||||
<tr data-day="{{ snapshot.timestamp|date:"Y-m-d" }}" class="{% if snapshot.new_day %}new-day{% else %}collapsed{% endif %}">
|
||||
<td><span class="counter">#{{ snapshot.pk }}</span></td>
|
||||
<td>
|
||||
{% if object_list|length > 1 %}
|
||||
|
@ -41,6 +41,14 @@
|
|||
</td>
|
||||
<td>
|
||||
{{ snapshot.timestamp }}
|
||||
{% if snapshot.new_day and snapshot.day_other_count %} — <a class="reveal" href="#day-{{ snapshot.timestamp|date:"Y-m-d"}}">
|
||||
{% if snapshot.day_other_count >= 50 %}<strong>{% endif %}
|
||||
{% blocktrans trimmed count counter=snapshot.day_other_count %}
|
||||
1 other this day
|
||||
{% plural %}
|
||||
{{ counter }} others
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if snapshot.label %}
|
||||
|
@ -48,10 +56,11 @@
|
|||
{% elif snapshot.comment %}
|
||||
{{ snapshot.comment }}
|
||||
{% endif %}
|
||||
{% if snapshot.application_version %}({% blocktrans with version=snapshot.application_version %}Version {{ version }}{% endblocktrans %}){% endif %}
|
||||
</td>
|
||||
<td>{% if snapshot.user %} {{ snapshot.user.get_full_name }}{% endif %}</td>
|
||||
<td>
|
||||
<a href="{% url 'combo-snapshot-view' pk=snapshot.id %}">{% trans "view" %}</a>
|
||||
<a href="{% url 'combo-manager-snapshot-view' page_pk=page.pk pk=snapshot.pk %}">{% trans "view" %}</a>
|
||||
— <a href="{% url 'combo-manager-snapshot-export' page_pk=page.id pk=snapshot.id %}">{% trans "export" %}</a>
|
||||
— <a href="{% url 'combo-manager-snapshot-restore' page_pk=page.id pk=snapshot.id %}" rel="popup">{% trans "restore" %}</a>
|
||||
</td>
|
||||
|
@ -59,9 +68,17 @@
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% include "gadjo/pagination.html" %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
$('tr.new-day a.reveal').on('click', function() {
|
||||
var day = $(this).parents('tr.new-day').data('day');
|
||||
$('.snapshots-list tr[data-day="' + day + '"]:not(.new-day)').toggleClass('collapsed');
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,5 +1,15 @@
|
|||
{% extends "combo/page_history.html" %}
|
||||
{% load i18n %}
|
||||
{% load gadjo static i18n %}
|
||||
|
||||
{% block css %}
|
||||
{{ block.super }}
|
||||
<link rel="stylesheet" type="text/css" media="all" href="{% static "css/gadjo.snapshotdiff.css" %}?{% start_timestamp %}"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrascripts %}
|
||||
{{ block.super }}
|
||||
<script src="{% static "js/gadjo.snapshotdiff.js" %}?{% start_timestamp %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>{% trans 'Compare snapshots' %} ({% if mode == 'json' %}{% trans "JSON" %}{% else %}{% trans "Inspect" %}{% endif %})</h2>
|
||||
|
@ -16,7 +26,7 @@
|
|||
|
||||
{% block content %}
|
||||
<p class="snapshot-description">{{ fromdesc|safe }} ➔ {{ todesc|safe }}</p>
|
||||
<div class="diff">
|
||||
<div class="snapshot-diff">
|
||||
{% if mode == 'json' %}
|
||||
{{ diff_serialization|safe }}
|
||||
{% else %}
|
||||
|
|
|
@ -7,7 +7,11 @@
|
|||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'combo-manager-page-inspect' pk=object.id %}">{% trans 'Inspect' %}</a>
|
||||
{% if not is_readonly %}
|
||||
<a href="{% url 'combo-manager-page-inspect' pk=object.id %}">{% trans 'Inspect' %}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'combo-manager-snapshot-inspect' page_pk=snapshot.page_id pk=snapshot.pk %}">{% trans "Inspect" %}</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
|
|
|
@ -8,137 +8,200 @@
|
|||
<h2>{% trans 'Page' %} - {{ object.title }}{% if with_wcs and sub_slug_details.1 %} <span class="extra-info">({% blocktrans with card_model=sub_slug_details.1 %}page linked to card model "{{ card_model }}"{% endblocktrans %})</span>{% endif %}</h2>
|
||||
{% endwith %}
|
||||
<span class="actions">
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
<a class="action-see-online" href="{{ object.get_online_url }}">{% trans 'See online' %}</a>
|
||||
<ul class="extra-actions-menu">
|
||||
<li><a class="action-history" href="{% url 'combo-manager-page-history' pk=object.id %}">{% trans 'History' %}</a></li>
|
||||
<li><a class="action-inspect" href="{% url 'combo-manager-page-inspect' pk=object.id %}">{% trans 'Inspect' %}</a></li>
|
||||
<li><a {% if page_has_subpages %}rel="popup" data-autoclose-dialog="true" {% endif %}class="action-export" href="{% url 'combo-manager-page-export' pk=object.id %}">{% trans 'Export' %}</a></li>
|
||||
<li><a class="action-edit-page-variables" rel="popup" href="{% url 'combo-manager-page-edit-extra-variables' pk=object.id %}">{% trans "Edit extra page variables" %}</a></li>
|
||||
{% if with_wcs %}
|
||||
<li><a class="action-edit-page-linked-card" rel="popup" href="{% url 'combo-manager-page-edit-linked-card' pk=object.id %}">{% trans "Link a card model" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.data.add_page %}
|
||||
<li><a class="action-add-child" rel="popup" href="{% url 'combo-manager-page-add-child' pk=object.id %}">{% trans 'Add a child page' %}</a></li>
|
||||
<li><a class="action-edit-roles" rel="popup" href="{% url 'combo-manager-page-edit-roles' pk=object.id %}">{% trans 'Manage edit roles' %}</a></li>
|
||||
<li><a rel="popup" class="action-duplicate" href="{% url 'combo-manager-page-duplicate' pk=object.id %}">{% trans 'Duplicate' %}</a></li>
|
||||
<li><a rel="popup" class="action-save" href="{% url 'combo-manager-page-save' pk=object.id %}">{% trans 'Save snapshot' %}</a></li>
|
||||
<li><a class="action-delete" rel="popup" href="{% url 'combo-manager-page-delete' pk=object.id %}">{% trans 'Delete' %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if not is_readonly %}
|
||||
<a class="extra-actions-menu-opener"></a>
|
||||
<a class="action-see-online" href="{{ object.get_online_url }}">{% trans 'See online' %}</a>
|
||||
<ul class="extra-actions-menu">
|
||||
<li><a class="action-history" href="{% url 'combo-manager-page-history' pk=object.id %}">{% trans 'History' %}</a></li>
|
||||
<li><a class="action-inspect" href="{% url 'combo-manager-page-inspect' pk=object.id %}">{% trans 'Inspect' %}</a></li>
|
||||
<li><a {% if page_has_subpages %}rel="popup" data-autoclose-dialog="true" {% endif %}class="action-export" href="{% url 'combo-manager-page-export' pk=object.id %}">{% trans 'Export' %}</a></li>
|
||||
<li><a class="action-edit-page-variables" rel="popup" href="{% url 'combo-manager-page-edit-extra-variables' pk=object.id %}">{% trans "Edit extra page variables" %}</a></li>
|
||||
{% if with_wcs %}
|
||||
<li><a class="action-edit-page-linked-card" rel="popup" href="{% url 'combo-manager-page-edit-linked-card' pk=object.id %}">{% trans "Link a card model" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.data.add_page %}
|
||||
<li><a class="action-add-child" rel="popup" href="{% url 'combo-manager-page-add-child' pk=object.id %}">{% trans 'Add a child page' %}</a></li>
|
||||
<li><a class="action-edit-roles" rel="popup" href="{% url 'combo-manager-page-edit-roles' pk=object.id %}">{% trans 'Manage edit roles' %}</a></li>
|
||||
<li><a rel="popup" class="action-duplicate" href="{% url 'combo-manager-page-duplicate' pk=object.id %}">{% trans 'Duplicate' %}</a></li>
|
||||
<li><a rel="popup" class="action-save" href="{% url 'combo-manager-page-save' pk=object.id %}">{% trans 'Save snapshot' %}</a></li>
|
||||
<li><a class="action-delete" rel="popup" href="{% url 'combo-manager-page-delete' pk=object.id %}">{% trans 'Delete' %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<a class="action-see-online" href="{% url 'combo-snapshot-view' pk=snapshot.pk %}">{% trans 'See online' %}</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
{{ block.super }}
|
||||
<a href="{% url 'combo-manager-page-view' pk=object.id %}">{% trans 'Page' %} - {{object.title }}</a>
|
||||
{% if not is_readonly %}
|
||||
<a href="{% url 'combo-manager-page-view' pk=object.id %}">{% trans 'Page' %} - {{object.title }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'combo-manager-page-view' pk=snapshot.page_id %}">{% trans 'Page' %} - {{object.title }}</a>
|
||||
<a href="{% url 'combo-manager-page-history' pk=snapshot.page_id %}">{% trans 'History' %}</a>
|
||||
<a href="{% url 'combo-manager-snapshot-view' page_pk=snapshot.page_id pk=snapshot.pk %}">{{ snapshot.timestamp }}</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<aside id="sidebar">
|
||||
|
||||
<div class="page-options">
|
||||
<h3>{% trans 'Parameters' %}</h3>
|
||||
{% if not is_readonly %}
|
||||
<div class="page-options">
|
||||
<h3>{% trans 'Parameters' %}</h3>
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Title:' %}</label>
|
||||
{{object.title}}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-title' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
{% with object.get_sub_slug_details as sub_slug_details %}
|
||||
<p>
|
||||
<label>{% trans 'Slug:' %}</label>
|
||||
<tt>{{ object.slug }}{% if sub_slug_details and not sub_slug_details.1 %}/<span class="subslug">{{ sub_slug_details.0 }}</span>{% endif %}</tt>
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-slug' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
<label>{% trans 'Title:' %}</label>
|
||||
{{object.title}}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-title' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
{% endwith %}
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Description:' %}</label>
|
||||
{% if object.description %}{{ object.description|truncatewords:32 }}{% else %}<i>{% trans 'none' %}</i>{% endif %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-description' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
{% with object.get_sub_slug_details as sub_slug_details %}
|
||||
<p>
|
||||
<label>{% trans 'Slug:' %}</label>
|
||||
<tt>{{ object.slug }}{% if sub_slug_details and not sub_slug_details.1 %}/<span class="subslug">{{ sub_slug_details.0 }}</span>{% endif %}</tt>
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-slug' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
{% endwith %}
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Template:' %}</label>
|
||||
{{ object.get_template_display_name }}
|
||||
{% if object.missing_template %}<span class="error">({% trans "missing" %})</span>{% endif %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-select-template' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Visibility:' %}</label>
|
||||
{{ object.visibility }}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-visibility' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Include in navigation menus:' %}</label>
|
||||
{% if object.exclude_from_navigation %}{% trans 'no' %}{% else %}{% trans 'yes' %}{% endif %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-include-in-navigation' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Redirection:' %}</label>
|
||||
{% if object.redirect_url %}{{ object.redirect_url }}{% else %}<i>{% trans 'none' %}</i>{% endif %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-redirection' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Picture:' %}</label>
|
||||
{% if object.picture %}
|
||||
{% if object.picture_extension != '.svg' %}
|
||||
{% thumbnail object.picture "320x240" crop="50% 25%" upscale=False as im %}
|
||||
<img class="page-picture" src="{{im.url}}"/>
|
||||
{% endthumbnail %}
|
||||
{% else %}
|
||||
<img class="page-picture" src="{{page.picture.url}}"/>
|
||||
{% endif %}
|
||||
(<a href="{% url 'combo-manager-page-remove-picture' pk=object.id %}">{% trans 'remove' %}</a>)
|
||||
{% else %}<i>{% trans 'none' %}</i>{% endif %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-picture' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
{% if object.extra_variables %}
|
||||
<p>
|
||||
<label>{% trans 'Extra variables:' %}</label>
|
||||
{% for key in object.get_extra_variables_keys %}<i>{{ key }}</i>{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-extra-variables' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
<label>{% trans 'Description:' %}</label>
|
||||
{% if object.description %}{{ object.description|truncatewords:32 }}{% else %}<i>{% trans 'none' %}</i>{% endif %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-description' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Template:' %}</label>
|
||||
{{ object.get_template_display_name }}
|
||||
{% if object.missing_template %}<span class="error">({% trans "missing" %})</span>{% endif %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-select-template' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Visibility:' %}</label>
|
||||
{{ object.visibility }}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-visibility' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Include in navigation menus:' %}</label>
|
||||
{% if object.exclude_from_navigation %}{% trans 'no' %}{% else %}{% trans 'yes' %}{% endif %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-include-in-navigation' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Redirection:' %}</label>
|
||||
{% if object.redirect_url %}{{ object.redirect_url }}{% else %}<i>{% trans 'none' %}</i>{% endif %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-redirection' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label>{% trans 'Picture:' %}</label>
|
||||
{% if object.picture %}
|
||||
{% if object.picture_extension != '.svg' %}
|
||||
{% thumbnail object.picture "320x240" crop="50% 25%" upscale=False as im %}
|
||||
<img class="page-picture" src="{{im.url}}"/>
|
||||
{% endthumbnail %}
|
||||
{% else %}
|
||||
<img class="page-picture" src="{{page.picture.url}}"/>
|
||||
{% endif %}
|
||||
(<a href="{% url 'combo-manager-page-remove-picture' pk=object.id %}">{% trans 'remove' %}</a>)
|
||||
{% else %}<i>{% trans 'none' %}</i>{% endif %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-picture' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
|
||||
{% if object.extra_variables %}
|
||||
<p>
|
||||
<label>{% trans 'Extra variables:' %}</label>
|
||||
{% for key in object.get_extra_variables_keys %}<i>{{ key }}</i>{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
(<a rel="popup" href="{% url 'combo-manager-page-edit-extra-variables' pk=object.id %}">{% trans 'change' %}</a>)
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if object.parent_id or previous_page or next_page %}
|
||||
<div class="page-options navigation">
|
||||
<h3>{% trans 'Navigation' %}</h3>
|
||||
<ul>
|
||||
{% if object.parent_id and request.user.is_superuser %}
|
||||
<li class="nav-up"><a href="{% url 'combo-manager-page-view' pk=object.parent_id %}">{{ object.parent.title }}</a></li>
|
||||
{% endif %}
|
||||
{% if previous_page %}
|
||||
<li class="nav-left"><a href="{% url 'combo-manager-page-view' pk=previous_page.pk %}">{{ previous_page.title }}</a></li>
|
||||
{% endif %}
|
||||
{% if next_page %}
|
||||
<li class="nav-right"><a href="{% url 'combo-manager-page-view' pk=next_page.pk %}">{{ next_page.title }}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% if optional_placeholders %}
|
||||
<div class="page-options">
|
||||
<h3>{% trans 'Optional sections' %}</h3>
|
||||
<ul>
|
||||
{% for placeholder in optional_placeholders %}
|
||||
<li>
|
||||
{{ placeholder.name }} ({% if placeholder.is_empty %}{% trans "empty" %}{% else %}{% trans "like parent" %}{% endif %})
|
||||
(<a href="{% url 'combo-manager-page-view' pk=object.id %}?include-section={{ placeholder.key }}">{% trans 'change' %}</a>)
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.parent_id or previous_page or next_page %}
|
||||
<div class="page-options navigation">
|
||||
<h3>{% trans 'Navigation' %}</h3>
|
||||
<ul>
|
||||
{% if object.parent_id and request.user.is_superuser %}
|
||||
<li class="nav-up"><a href="{% url 'combo-manager-page-view' pk=object.parent_id %}">{{ object.parent.title }}</a></li>
|
||||
{% endif %}
|
||||
{% if previous_page %}
|
||||
<li class="nav-left"><a href="{% url 'combo-manager-page-view' pk=previous_page.pk %}">{{ previous_page.title }}</a></li>
|
||||
{% endif %}
|
||||
{% if next_page %}
|
||||
<li class="nav-right"><a href="{% url 'combo-manager-page-view' pk=next_page.pk %}">{{ next_page.title }}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.applications %}
|
||||
<h3>{% trans "Applications" %}</h3>
|
||||
{% for application in object.applications %}
|
||||
<a class="button button-paragraph" href="{% url 'combo-manager-homepage' %}?application={{ application.slug }}">
|
||||
{% thumbnail application.icon '16x16' format='PNG' as im %}
|
||||
<img src="{{ im.url }}" alt="" class="application-icon" width="16" />
|
||||
{% endthumbnail %}
|
||||
{{ application.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if optional_placeholders %}
|
||||
<div class="page-options">
|
||||
<h3>{% trans 'Optional sections' %}</h3>
|
||||
<ul>
|
||||
{% for placeholder in optional_placeholders %}
|
||||
<li>
|
||||
{{ placeholder.name }} ({% if placeholder.is_empty %}{% trans "empty" %}{% else %}{% trans "like parent" %}{% endif %})
|
||||
(<a href="{% url 'combo-manager-page-view' pk=object.id %}?include-section={{ placeholder.key }}">{% trans 'change' %}</a>)
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
|
||||
<div class="infonotice">
|
||||
<p>{% trans "This page is readonly." %}</p>
|
||||
</div>
|
||||
<p>
|
||||
{% if snapshot.label %}
|
||||
<strong>{{ snapshot.label }}</strong>
|
||||
{% elif snapshot.comment %}
|
||||
{{ snapshot.comment }}
|
||||
{% endif %}
|
||||
<br />
|
||||
{{ snapshot.timestamp|date:"d/m/Y H:i" }} {% if snapshot.user %}({{ snapshot.user }}){% endif %}
|
||||
</p>
|
||||
{% if snapshot.previous or snapshot.next %}
|
||||
<p class="snapshots-navigation">
|
||||
{% if snapshot.pk != snapshot.first %}
|
||||
<a class="button" href="{% url 'combo-manager-snapshot-view' page_pk=snapshot.page_id pk=snapshot.first %}">≪</a>
|
||||
<a class="button" href="{% url 'combo-manager-snapshot-view' page_pk=snapshot.page_id pk=snapshot.previous %}"><</a>
|
||||
{% else %}
|
||||
<a class="button disabled" href="#">≪</a>
|
||||
<a class="button disabled" href="#"><</a>
|
||||
{% endif %}
|
||||
{% if snapshot.pk != snapshot.last %}
|
||||
<a class="button" href="{% url 'combo-manager-snapshot-view' page_pk=snapshot.page_id pk=snapshot.next %}">></a>
|
||||
<a class="button" href="{% url 'combo-manager-snapshot-view' page_pk=snapshot.page_id pk=snapshot.last %}">≫</a>
|
||||
{% else %}
|
||||
<a class="button disabled" href="#">></a>
|
||||
<a class="button disabled" href="#">≫</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<div>
|
||||
<a class="button button-paragraph" href="{% url 'combo-manager-snapshot-restore' page_pk=snapshot.page_id pk=snapshot.pk %}" rel="popup">{% trans "Restore version" %}</a>
|
||||
<a class="button button-paragraph" href="{% url 'combo-manager-snapshot-export' page_pk=snapshot.page_id pk=snapshot.pk %}">{% trans "Export version" %}</a>
|
||||
<a class="button button-paragraph" href="{% url 'combo-manager-snapshot-inspect' page_pk=snapshot.page_id pk=snapshot.pk %}">{% trans "Inspect version" %}</a>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</aside>
|
||||
|
@ -165,7 +228,9 @@
|
|||
{% for placeholder in placeholders %}
|
||||
<div class="placeholder" data-placeholder-key="{{ placeholder.key }}">
|
||||
<h2>{{ placeholder.name }}</h2>
|
||||
<a class="placeholder-options-link" data-popup href="{% url 'combo-manage-placeholder-options' page_pk=object.id placeholder=placeholder.key %}">{% trans "Options" %}</a>
|
||||
{% if not is_readonly %}
|
||||
<a class="placeholder-options-link" data-popup href="{% url 'combo-manage-placeholder-options' page_pk=object.id placeholder=placeholder.key %}">{% trans "Options" %}</a>
|
||||
{% endif %}
|
||||
<div class="cell-list">
|
||||
{% for cell in placeholder.cells %}
|
||||
<div id="cell-{{cell.get_reference}}" class="cell {{cell.class_name}}" data-cell-reference="{{ cell.get_reference }}">
|
||||
|
@ -196,26 +261,28 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="manager-add-new-cell">
|
||||
<a href="#">{% trans 'Add a new cell' %}</a>
|
||||
<div style="display: none">
|
||||
<select>
|
||||
{% for label, celltypes in cell_type_groups %}
|
||||
{% if label %}
|
||||
<optgroup label="{{label}}">
|
||||
{% endif %}
|
||||
{% for cell_type in celltypes %}
|
||||
<option data-add-url="{% url 'combo-manager-page-add-cell' page_pk=object.id cell_type=cell_type.cell_type_str variant=cell_type.variant ph_key=placeholder.key %}"
|
||||
>{{cell_type.name}}</option>
|
||||
{% if not is_readonly %}
|
||||
<div class="manager-add-new-cell">
|
||||
<a href="#">{% trans 'Add a new cell' %}</a>
|
||||
<div style="display: none">
|
||||
<select>
|
||||
{% for label, celltypes in cell_type_groups %}
|
||||
{% if label %}
|
||||
<optgroup label="{{label}}">
|
||||
{% endif %}
|
||||
{% for cell_type in celltypes %}
|
||||
<option data-add-url="{% url 'combo-manager-page-add-cell' page_pk=object.id cell_type=cell_type.cell_type_str variant=cell_type.variant ph_key=placeholder.key %}"
|
||||
>{{cell_type.name}}</option>
|
||||
{% endfor %}
|
||||
{% if label %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if label %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button>ok</button>
|
||||
</select>
|
||||
<button>ok</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue