Compare commits

..

163 Commits

Author SHA1 Message Date
Valentin Deniaud ff1e2f9bab dataviz: use select2 without ajax in filters cell (#71885)
gitea/combo/pipeline/head This commit looks good Details
2024-04-18 17:18:23 +02:00
Valentin Deniaud 4bad6b488b dataviz: use select2 widget for all filters (#71885) 2024-04-18 17:17:34 +02:00
Valentin Deniaud e8bd91b44e dataviz: refactor building of choice list (#71885) 2024-04-18 17:11:16 +02:00
Valentin Deniaud af473d684e translation update
gitea/combo/pipeline/head This commit looks good Details
2024-04-16 10:57:17 +02:00
Valentin Deniaud 6be1d6c5fc dataviz: allow control of total display in tables (#85654)
gitea/combo/pipeline/head This commit looks good Details
2024-04-16 10:32:04 +02:00
Valentin Deniaud 5e8b58c6ca dataviz: transpose data before ods export to match html display (#85654) 2024-04-16 10:32:04 +02:00
Valentin Deniaud b20464820a tests: fix useless assertions on chart cell form fields (#85654) 2024-04-16 10:32:04 +02:00
Lauréline Guérin 8a10f3eab2 snapshots: json diff, use gadjo to collapse lines between changes (#89483)
gitea/combo/pipeline/head This commit looks good Details
2024-04-16 10:14:18 +02:00
Yann Weber d9b5247f44 wcs: check code syntax before searching for it (#89461)
gitea/combo/pipeline/head This commit looks good Details
2024-04-11 18:22:15 +02:00
Frédéric Péters 46e7c037f5 translation update
gitea/combo/pipeline/head This commit looks good Details
2024-04-05 17:49:41 +02:00
Lauréline Guérin 10055d8e54
export_import: post bundle (#89034)
gitea/combo/pipeline/head This commit looks good Details
2024-04-03 17:20:31 +02:00
Frédéric Péters 1a964cd76b translation update
gitea/combo/pipeline/head This commit looks good Details
2024-04-01 18:15:27 +02:00
Frédéric Péters a688c183e8 misc: adjust default osm attribution (#88906)
gitea/combo/pipeline/head This commit looks good Details
2024-03-31 08:45:37 +02:00
Frédéric Péters 7df1e3f997 translation update
gitea/combo/pipeline/head This commit looks good Details
2024-03-29 10:13:34 +01:00
Frédéric Péters 7b66dca2ba maps: add option to include a search button (#88131)
gitea/combo/pipeline/head This commit looks good Details
2024-03-29 08:29:05 +01:00
Lauréline Guérin d964be219e
snapshots: do not delete snapshots on user deletion (#88622)
gitea/combo/pipeline/head This commit looks good Details
2024-03-26 13:18:45 +01:00
Frédéric Péters bfdefa73cc translation update
gitea/combo/pipeline/head This commit looks good Details
2024-03-25 16:28:35 +01:00
Lauréline Guérin 820bab39b7
export_import: limit APIs to admin users (#88132)
gitea/combo/pipeline/head This commit looks good Details
2024-03-25 09:43:42 +01:00
Lauréline Guérin 50cd07545c
export_import: invalid bundle (#88132) 2024-03-25 09:43:41 +01:00
Frédéric Péters 1a4be6ec3e translation update
gitea/combo/pipeline/head This commit looks good Details
2024-03-19 16:57:54 +01:00
Lauréline Guérin 21407807ad
export_import: rebuild page positions after import (#86627)
gitea/combo/pipeline/head This commit looks good Details
2024-03-15 08:28:42 +01:00
Lauréline Guérin 741efc0e35
misc: change create_bundle logic in tests (#86627) 2024-03-15 08:28:42 +01:00
Lauréline Guérin 370beb3a84
data: remove parent field from page snapshots (#86627) 2024-03-15 08:28:42 +01:00
Lauréline Guérin b20230d34f
data: remove order field from page snapshots (#86627) 2024-03-15 08:28:42 +01:00
Lauréline Guérin 150f6e954f
applications: don't fail if wcs is not responding (#86520)
gitea/combo/pipeline/head This commit looks good Details
2024-03-14 11:47:18 +01:00
Lauréline Guérin fd0d9c6fb7
applications: don't import get_wcs_dependencies_from_template everywhere (#86520) 2024-03-14 11:29:24 +01:00
Lauréline Guérin c1b431922f
applications: lingo cells dependencies to card models (#86520) 2024-03-14 11:29:22 +01:00
Lauréline Guérin 995e3773cf
applications: weekly agenda cell dependencies to card models (#86520) 2024-03-14 11:29:01 +01:00
Lauréline Guérin ea3d41d222
applications: search cell dependencies to pages and card models (#86520) 2024-03-14 11:28:25 +01:00
Lauréline Guérin 8f6ab11272
applications: cell condition dependencies to card models (#86520) 2024-03-14 11:27:31 +01:00
Lauréline Guérin 7e43932262
applications: page dependencies to card models (#86520) 2024-03-14 11:27:31 +01:00
Lauréline Guérin 81b27151a3
applications: card cell dependencies to pages (#86520) 2024-03-14 11:27:31 +01:00
Lauréline Guérin f74e768a11
applications: card cell dependencies to card models (#86520) 2024-03-14 11:27:31 +01:00
Lauréline Guérin 057c8f49a0
manager: fix cell form rendering when disabled (#87871)
gitea/combo/pipeline/head This commit looks good Details
2024-03-08 14:00:31 +01:00
Lauréline Guérin 9f6ca2c862
translation update
gitea/combo/pipeline/head This commit looks good Details
2024-03-07 20:22:12 +01:00
Lauréline Guérin 9480ed89dc
export_import: clean old jobs (#87614)
gitea/combo/pipeline/head This commit looks good Details
2024-03-07 20:09:52 +01:00
Lauréline Guérin d128ae63be
export_import: endpoints import and declare with async job (#87614) 2024-03-07 20:09:52 +01:00
Yann Weber 61fc9aab9b update translations
gitea/combo/pipeline/head This commit looks good Details
(#86995)
2024-03-05 16:55:12 +01:00
Yann Weber c9a4a74417 Revert "Update translation"
gitea/combo/pipeline/head This commit looks good Details
This reverts commit 7815d2d00e.
2024-03-05 15:38:49 +01:00
Yann Weber 7815d2d00e Update translation
gitea/combo/pipeline/head This commit looks good Details
(#86995)
2024-03-05 15:12:46 +01:00
Yann Weber d9fd99f1dd assets: add a check to see if uploaded images are handled by PIL (#86995)
gitea/combo/pipeline/head This commit looks good Details
2024-03-05 15:03:50 +01:00
Lauréline Guérin 59c14da940
translation update
gitea/combo/pipeline/head This commit looks good Details
2024-02-29 18:07:58 +01:00
Lauréline Guérin 7913efd0ab data: export/import of page picture content (#86870)
gitea/combo/pipeline/head This commit looks good Details
2024-02-29 12:07:14 +01:00
Lauréline Guérin f05be333cb assets: don't export or clean non asset files (#86870) 2024-02-29 12:07:14 +01:00
Lauréline Guérin 1f8509f715 misc: fix typo in tests (#86870) 2024-02-29 12:07:14 +01:00
Frédéric Péters e76a113f74 misc: extend regex used for identifiers in subslugs (#87480)
gitea/combo/pipeline/head This commit looks good Details
2024-02-29 10:45:57 +01:00
Corentin Sechet 3a6ebed4db cells: fix wrong display of 'site' field of tracking code cell in backoffice (#86508)
gitea/combo/pipeline/head This commit looks good Details
2024-02-28 13:03:17 +01:00
Benjamin Dauvergne bbb12f507f lingo: pass for-payment on reading invoice for payment (#76853)
gitea/combo/pipeline/head This commit looks good Details
Only if the regie announced its support in its invoices/ endpoint.
2024-02-26 19:28:32 +01:00
Benjamin Dauvergne cb33b47a19 lingo: provision Regie.has_invoice_for_payment during Regie.get_invoices() (#76853) 2024-02-26 19:28:32 +01:00
Benjamin Dauvergne c092e19c77 lingo: add Regie.has_invoice_for_payment boolean field (#76853) 2024-02-26 19:28:32 +01:00
Benjamin Dauvergne 78c036b7a2 lingo: report exception on invoice notification failure (#87025)
gitea/combo/pipeline/head This commit looks good Details
2024-02-24 17:41:04 +01:00
Frédéric Péters 5d80833736 misc: remove hardcoded service slug in card schema lookup (#87328)
gitea/combo/pipeline/head This commit looks good Details
2024-02-23 21:17:05 +01:00
Benjamin Dauvergne 361f0a9bb1 pwa: use setting or tenant URL to build VAPID JWT sub mailto claim (#87413)
gitea/combo/pipeline/head This commit looks good Details
DEFAULT_FROM_EMAIL is usually not read and only Apple accept an http URL
instead of a mailto: URL.
2024-02-23 15:34:55 +01:00
Benjamin Dauvergne 35e6b79120 pwa: never delete subscriptions (#85458)
gitea/combo/pipeline/head This commit looks good Details
Subscriptions must be garbage collected when notifications request
receive the 410 Gone status. Deleting all subscriptions would delete
subcriptions registered from another browser and there is no way to
relate existing subscription to a particular browser.
2024-02-23 11:13:55 +01:00
Benjamin Dauvergne b2d06e0234 pwa: push existing subscription at the beginning of each session (#85458) 2024-02-23 11:13:55 +01:00
Benjamin Dauvergne a5f8140d66 pwa: add Urgency: low header (#70987)
gitea/combo/pipeline/head This commit looks good Details
2024-02-23 11:13:27 +01:00
Benjamin Dauvergne 9f25287a66 pwa: conserve VAPID headers in cache for 12 hours (#70987)
The JSON webtoken is valid for 24 hours but only kept for 23 hours, to
prevent any use after expiration.

Also factorize webpush implementation from signal handling and remove
unused legacy settings support.
2024-02-23 11:13:27 +01:00
Benjamin Dauvergne 05e615ddc9 pwa: set TTL of push notification to 30 days (#70988)
gitea/combo/pipeline/head Build queued... Details
2024-02-22 15:44:40 +01:00
Lauréline Guérin 03361bfbb9 wcs: replace newlines by spaces in title and headers (#86308)
gitea/combo/pipeline/head This commit looks good Details
2024-02-16 10:13:18 +01:00
Yann Weber 1e65e2ea49 data: add true, false & null aliases to context (#82425)
gitea/combo/pipeline/head This commit looks good Details
2024-02-15 15:47:03 +01:00
Thomas Jund c02a001384 wcs: add submit-button class on tracking code input button (#85574)
gitea/combo/pipeline/head This commit looks good Details
2024-02-15 09:43:08 +01:00
Thomas Jund 51d327d72e html: correct bad markup on link-list-cell (#86666)
gitea/combo/pipeline/head This commit looks good Details
2024-02-12 10:06:30 +01:00
Yann Weber ec57e7c060 map: add int conversion before comparing zoom levels (#86631)
gitea/combo/pipeline/head This commit looks good Details
2024-02-07 11:17:05 +01:00
Benjamin Dauvergne 7aff4544fc utils: add missing warning log on network error (#85742)
gitea/combo/pipeline/head This commit looks good Details
2024-02-01 22:47:22 +01:00
Benjamin Dauvergne 253ae3863b datavis: log detailed errors for outdated statistics (#85742) 2024-02-01 22:47:22 +01:00
Benjamin Dauvergne 8a15bacc72 dataviz: do not log failure to update statistics as errors (#85742) 2024-02-01 22:47:22 +01:00
Frédéric Péters 5fb21cd6ec assets: double check for null bytes in filename (#86356)
gitea/combo/pipeline/head This commit looks good Details
2024-02-01 17:19:35 +01:00
Paul Marillonnet e06ea594d6 menu: include parenthood in subentries hint class logic (#86069)
gitea/combo/pipeline/head This commit looks good Details
2024-01-30 14:56:02 +01:00
Frédéric Péters 17e7166841 translation update
gitea/combo/pipeline/head This commit looks good Details
2024-01-30 13:57:03 +01:00
Lauréline Guérin 59103a7db8 maps: force break-word for long url in popup (#86048)
gitea/combo/pipeline/head This commit looks good Details
2024-01-30 13:30:06 +01:00
Lauréline Guérin 32641a04a1 misc: indent combo.map.scss with tabs (#86048) 2024-01-30 13:30:06 +01:00
Lauréline Guérin 97d0fd650e manager: list pages outside applications (#85927)
gitea/combo/pipeline/head This commit looks good Details
2024-01-30 13:29:14 +01:00
Corentin Sechet dd877aad7c js: configure unit tests (#83453)
gitea/combo/pipeline/head This commit looks good Details
2024-01-29 09:34:16 +01:00
Frédéric Péters 9b491b824f translation update
gitea/combo/pipeline/head This commit looks good Details
2024-01-19 09:01:36 +01:00
Valentin Deniaud ee51907385 dataviz: include reference to cell url when getting statistics data (#85815)
gitea/combo/pipeline/head This commit looks good Details
2024-01-18 14:26:05 +01:00
Yann Weber 12a3d5b392 map: add zoom level configuration validation (#64640)
gitea/combo/pipeline/head This commit looks good Details
2024-01-16 17:58:58 +01:00
Benjamin Dauvergne d51abbf3ee wcs: pass django's request to requests_wrapper (#75916)
gitea/combo/pipeline/head This commit looks good Details
To enable the ?nocache behaviour when calling w.c.s.
2024-01-15 16:15:04 +01:00
Benjamin Dauvergne 6fcb6845ba data: implement ?nocache parameter for JSON cells (#75916)
If ?nocache is given in the page query-string and an user is
authenticated, it changes invalidate_cache to True in RequestWrapper.request().

The template placeholder.html is modified to pass the ?nocache parameter
recursively to asynchronously loaded cells if it is active on the page.
2024-01-15 16:15:04 +01:00
Yann Weber 8e4d6aa904 translation update
gitea/combo/pipeline/head This commit looks good Details
2024-01-15 15:02:51 +01:00
Yann Weber 884cf93fae export_import: add check on 'uuid' field when importing page (#84541)
gitea/combo/pipeline/head This commit looks good Details
2024-01-15 14:34:27 +01:00
Yann Weber 3878028866 data: move exceptions class from data module in exceptions.py (#84541) 2024-01-15 14:34:27 +01:00
Yann Weber 43a6bb142d commands: rename lingo-poll-backend to lingo_poll_backend (#85511)
gitea/combo/pipeline/head This commit looks good Details
2024-01-15 11:51:09 +01:00
Frédéric Péters dab4a2ae1b misc: only init card cell custom schema form once (#85623)
gitea/combo/pipeline/head This commit looks good Details
2024-01-15 11:34:43 +01:00
Valentin Deniaud 34ed582d50 wcs: set card filters form prefix regardless of fields (#85223)
gitea/combo/pipeline/head This commit looks good Details
2024-01-15 11:30:21 +01:00
Lauréline Guérin 313660c702 export_import: raise 404 if page is not found (#85191)
gitea/combo/pipeline/head This commit looks good Details
2024-01-15 08:34:30 +01:00
Frédéric Péters a2ae3a5860 misc: add a nameid property to users (#85345)
gitea/combo/pipeline/head This commit looks good Details
2024-01-12 14:28:48 +01:00
Frédéric Péters 1d22ba93b0 general: increase size of extra_css_class field (#85419)
gitea/combo/pipeline/head This commit looks good Details
2024-01-09 10:55:28 +01:00
Lauréline Guérin 7a362a0193
wcs: card cell, fix migration for list display mode (#85368)
gitea/combo/pipeline/head This commit looks good Details
2024-01-08 11:58:16 +01:00
Frédéric Péters 77f5ea9ac4 translation update
gitea/combo/pipeline/head This commit looks good Details
2023-12-22 11:50:59 +01:00
Lauréline Guérin 335670c2f1
export_import: add roles with minor=True (#85022)
gitea/combo/pipeline/head This commit looks good Details
2023-12-21 14:59:01 +01:00
Lauréline Guérin fe7771f0eb
export_import: make endpoints generic for other kinds of objects (#85022) 2023-12-21 14:51:57 +01:00
Thomas Jund b02801ebc8
wcs: add specific templates for display mode list (#79989)
gitea/combo/pipeline/head This commit looks good Details
2023-12-19 14:36:43 +01:00
Thomas Jund cd88c9e309
wcs: stock schemas for all display modes separately (#79989) 2023-12-19 14:36:43 +01:00
Thomas Jund abda013cc3
wcs: create only one instance of Card_cell_custom for each card cell (#79989) 2023-12-19 14:36:43 +01:00
Lauréline Guérin e9bc442738
wcs: add a migration for cards in with list mode (#79989) 2023-12-19 14:36:43 +01:00
Lauréline Guérin 82698fcee9
wcs: add a list mode for cards cell (#79989) 2023-12-19 14:36:43 +01:00
Lauréline Guérin b4a12a1825
wcs: rename cards.html template (#79989) 2023-12-19 14:36:42 +01:00
Lauréline Guérin d2c5c4fa17
wcs: split template (#79989) 2023-12-19 14:36:42 +01:00
Frédéric Péters 20a37687d6 misc: make card linked to page update page title (#74073)
gitea/combo/pipeline/head This commit looks good Details
2023-12-18 13:20:06 +01:00
Valentin Deniaud 3cb658b30e settings: add lingo as statistics provider (#83886)
gitea/combo/pipeline/head This commit looks good Details
2023-12-18 10:04:55 +01:00
Nicolas Roche f44ab84e46 misc: remove copyright line from footer (#84812)
gitea/combo/pipeline/head This commit looks good Details
2023-12-15 17:49:04 +01:00
Lauréline Guérin 6897b15961
translation update
gitea/combo/pipeline/head This commit looks good Details
2023-12-05 10:43:29 +01:00
Lauréline Guérin 9c2dac3060
lingo: new cell for credits (#83908)
gitea/combo/pipeline/head This commit looks good Details
2023-12-04 18:16:57 +01:00
Lauréline Guérin dd9497e614 wcs: don't populate card cell context when looking for placeholders (#83797)
gitea/combo/pipeline/head This commit looks good Details
2023-11-24 08:39:53 +01:00
Frédéric Péters b535376efe misc: declare new importlib_metadata dependency, for pygal (#83745)
gitea/combo/pipeline/head This commit looks good Details
2023-11-21 10:50:53 +01:00
Frédéric Péters e697ef2b6d translation update
gitea/combo/pipeline/head There was a failure building this commit Details
2023-11-14 10:55:23 +01:00
Valentin Deniaud 3eb771ddbd dataviz: allow exporting graph to SVG or ODS (#65947)
gitea/combo/pipeline/head This commit looks good Details
2023-11-14 10:33:12 +01:00
Valentin Deniaud 78f08c8267 dataviz: add is_table_chart method (#65947) 2023-11-14 10:32:25 +01:00
Lauréline Guérin 6ee25f340e wcs: fix loading of card cell with filtering and pagination (#82583)
gitea/combo/pipeline/head This commit looks good Details
2023-11-14 10:29:00 +01:00
Thomas NOËL 76e0a432a6 debian: add back memory-report to uwsgi default configuration (#80451)
gitea/combo/pipeline/head This commit looks good Details
2023-11-13 11:29:22 +01:00
Frédéric Péters 18eeb74ca7 translation update
gitea/combo/pipeline/head This commit looks good Details
2023-11-10 19:26:22 +01:00
Frédéric Péters 62df2add65 wcs: redirect tracking code search engine results to backoffice (#83320)
gitea/combo/pipeline/head This commit looks good Details
2023-11-10 10:52:43 +01:00
Frédéric Péters d1321676f3 misc: add support for extra variables to skeleton pages (#83003)
gitea/combo/pipeline/head This commit looks good Details
2023-11-10 08:56:45 +01:00
Lauréline Guérin 5b09a4b15a wcs: set invalid_reason_codes for some cells (#83110)
gitea/combo/pipeline/head This commit looks good Details
2023-11-10 08:35:30 +01:00
Frédéric Péters 719c810dff wcs: only propose backoffice submission engine on agent portal (#83158)
gitea/combo/pipeline/head This commit looks good Details
2023-11-06 15:08:51 +01:00
Frédéric Péters 1c631a36da misc: rename data-autocomplete to avoid conflicts (#83122)
gitea/combo/pipeline/head This commit looks good Details
2023-11-04 17:54:03 +01:00
Lauréline Guérin 8bb8e53eec
wcs: set cell as invalid if wcs_site of reference is not found (#83106)
gitea/combo/pipeline/head This commit looks good Details
2023-11-03 14:18:31 +01:00
Lauréline Guérin a5474de140
export_import: don't check dependencies of invalid cells (#83106) 2023-11-03 14:18:30 +01:00
Frédéric Péters 5a88b7efdc notifications: allow null/empty content (#83002)
gitea/combo/pipeline/head This commit looks good Details
2023-10-31 16:54:17 +01:00
Thomas NOËL bf65795e9e debian: add uwsgi/combo SyslogIdentifier in service (#82956)
gitea/combo/pipeline/head This commit looks good Details
2023-10-31 10:05:06 +01:00
Emmanuel Cazenave e38e7eadfa setup: compute pep440 compliant dirty version number (#81731)
gitea/combo/pipeline/head This commit looks good Details
2023-10-30 17:18:01 +01:00
Frédéric Péters 35fa2fef6a lingo: fix typo in error message of poll backend command (#82923)
gitea/combo/pipeline/head This commit looks good Details
2023-10-29 10:25:07 +01:00
Frédéric Péters e92a1a72a2 translation update
gitea/combo/pipeline/head This commit looks good Details
2023-10-27 16:52:38 +02:00
Lauréline Guérin e6f3a2bdda applification: check for legacy elements in bundle-check endpoint (#82492)
gitea/combo/pipeline/head This commit looks good Details
2023-10-27 16:23:59 +02:00
Frédéric Péters 32b2bb43e9 wcs: ignore cards with unknown status when filtering on status (#82908)
gitea/combo/pipeline/head This commit looks good Details
2023-10-27 16:15:07 +02:00
Lauréline Guérin 93dff30566 export_import: add uuid for role in dependencies view (#82763)
gitea/combo/pipeline/head This commit looks good Details
2023-10-27 15:48:30 +02:00
Lauréline Guérin e320c819d3 wcs - basic-rich field display in card cell (#82687)
gitea/combo/pipeline/head This commit looks good Details
2023-10-27 09:29:11 +02:00
Frédéric Péters 06ddfb6b7a search: add an engine with backoffice submission forms as hits (#81533)
gitea/combo/pipeline/head This commit looks good Details
2023-10-27 08:40:19 +02:00
Frédéric Péters 914124a66c settings: set x-frame-options to sameorigin, for PWA preview mode (#82152)
gitea/combo/pipeline/head This commit looks good Details
2023-10-27 08:40:00 +02:00
Lauréline Guérin 0f02832df5
applification: fix unlink url (#82455)
gitea/combo/pipeline/head This commit looks good Details
2023-10-17 09:28:59 +02:00
Frédéric Péters d8fcad3ed6 translation update
gitea/combo/pipeline/head This commit looks good Details
2023-10-07 08:12:29 +02:00
Lauréline Guérin 47fdcc2cd6
manager: get snapshots to compare from application version (#82082)
gitea/combo/pipeline/head This commit looks good Details
2023-10-06 14:46:17 +02:00
Lauréline Guérin a06c667356
export_import: redirect to snapshot compare view if compare in GET params (#82082) 2023-10-06 14:46:17 +02:00
Lauréline Guérin 5ce17606c4
manager: display application version in history and compare views (#82082) 2023-10-06 14:46:17 +02:00
Lauréline Guérin 96ca2ec851
export_import: bundle-check endpoint (#82080)
gitea/combo/pipeline/head Build queued... Details
2023-10-06 14:45:13 +02:00
Lauréline Guérin 77c4b473b1
export_import: set application slug/version on snapshots (#82080) 2023-10-06 14:45:13 +02:00
Lauréline Guérin 5287443cbf
export_import: bundle-unlink endpoint (#82085)
gitea/combo/pipeline/head This commit looks good Details
2023-10-06 14:18:50 +02:00
Lauréline Guérin 105cd97ac3
manager: display applications of the page in sidebar (#82081)
gitea/combo/pipeline/head Build queued... Details
2023-10-06 14:18:33 +02:00
Lauréline Guérin 3cbae76331
manager: display applications on page list, filter by app (#82081) 2023-10-06 14:18:33 +02:00
Lauréline Guérin d519781b74
export_import: create application and elements on install and declare (#81927)
gitea/combo/pipeline/head This commit looks good Details
2023-10-06 14:18:12 +02:00
Lauréline Guérin cd99705f8c
export_import: add models for Application (#81927) 2023-10-06 14:18:12 +02:00
Lauréline Guérin b916c483f8 manager: home page, move boutons in sidebar (#82027)
gitea/combo/pipeline/head This commit looks good Details
2023-10-06 14:16:01 +02:00
Frédéric Péters bf4cbfe37a translation update
gitea/combo/pipeline/head This commit looks good Details
2023-10-05 22:15:36 +02:00
Frédéric Péters 65f8efc1f0 misc: increase page slug length (#81935)
gitea/combo/pipeline/head This commit looks good Details
2023-10-05 22:06:56 +02:00
Frédéric Péters c5f1ffb36e tox: keep on testing drf 3.12 only for now (#81947)
gitea/combo/pipeline/head This commit looks good Details
2023-10-05 09:55:52 +02:00
Frédéric Péters 0198eb2a9a setup: allow djangorestframework 3.14 (#81947)
gitea/combo/pipeline/head This commit looks good Details
2023-10-03 16:51:35 +02:00
Frédéric Péters f5ff197858 api: add module with applification API (#60773)
gitea/combo/pipeline/head This commit looks good Details
2023-10-03 13:11:29 +02:00
Frédéric Péters 2d8bf3a1aa ci: keep on using pylint 2 while pylint-django is not ready (#81905)
gitea/combo/pipeline/head This commit looks good Details
2023-10-03 06:35:12 +02:00
Valentin Deniaud b7017baf57 wcs: preserve ordering of static item field values in card filters (#81289)
gitea/combo/pipeline/head This commit looks good Details
2023-09-25 11:56:14 +02:00
Lauréline Guérin 7f35da936a
wcs: refresh cached fields for form links in list of links (#81157)
gitea/combo/pipeline/head This commit looks good Details
2023-09-19 16:23:19 +02:00
Frédéric Péters 22da07f739 translation update (for djangojs) (#81345)
gitea/combo/pipeline/head This commit looks good Details
2023-09-19 14:18:45 +02:00
Frédéric Péters 8c0d0dbf43 translation update
gitea/combo/pipeline/head This commit looks good Details
2023-09-19 10:14:01 +02:00
Lauréline Guérin 98e74b6da0 manager: fix display of invalid message for little screens (#79222)
gitea/combo/pipeline/head This commit looks good Details
2023-09-19 09:10:14 +02:00
Lauréline Guérin 6dda05741f wcs: set invalid_reason_codes for draft cell (#79222) 2023-09-19 09:10:14 +02:00
Lauréline Guérin c5b835d464 manager: add a snapshot inspect view (#81031) 2023-09-19 09:09:54 +02:00
Lauréline Guérin 4f2c606cd1 manager: view snapshot manage page (#81031) 2023-09-19 09:09:54 +02:00
Lauréline Guérin ef0dac26e1 manager: don't paginate history page (#81019)
gitea/combo/pipeline/head Build queued... Details
2023-09-19 09:09:24 +02:00
Lauréline Guérin c0c93aa639 wcs: add page variables in custom_title context (#80805)
gitea/combo/pipeline/head This commit looks good Details
2023-09-19 09:08:44 +02:00
Valentin Deniaud 4f129777cf dataviz: ignore hidden chart cells when building filters (#81148)
gitea/combo/pipeline/head This commit looks good Details
2023-09-18 16:41:09 +02:00
Valentin Deniaud e3832178ee fix quote style error (#79804)
gitea/combo/pipeline/head This commit looks good Details
2023-09-18 15:40:54 +02:00
Valentin Deniaud da721b70f2 import: mention cell in error message if related page not found (#79804)
gitea/combo/pipeline/head There was a failure building this commit Details
2023-09-18 15:33:29 +02:00
Lauréline Guérin 49f81f55a1
wcs: dont wait for cache if synchronous call (#80844)
gitea/combo/pipeline/head This commit looks good Details
2023-09-05 15:26:16 +02:00
Nicolas Roche b8f86ae74c commands: add an only-assets parameter to export command (#50399)
gitea/combo/pipeline/head This commit looks good Details
2023-08-25 14:30:18 +02:00
149 changed files with 9187 additions and 1561 deletions

4
.gitignore vendored
View File

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

2
Jenkinsfile vendored
View File

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

View File

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

View File

@ -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()}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

@ -0,0 +1,7 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = []
operations = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

38
combo/data/exceptions.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "Limage 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 sapplique 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 labsence davoirs"
#: 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 lavoir"
#: 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 lavoir."
#: apps/lingo/views.py
msgid "Sorry, the provided amount is invalid."
msgstr "Le montant que vous avez entré nest pas valide."
@ -1701,6 +1863,21 @@ msgstr ""
"Ce paramétrage naura pas deffet parce que laction lors dun 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 dadresse"
#: apps/maps/models.py
msgid "Marker behaviour on click"
msgstr "Action lors dun 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 laffichage"
#: 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 daffichage"
@ -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 ny 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 daffichage 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 daffichage 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 ny a aucune demande."
msgid "There are no done forms or they have been removed."
msgstr "Il ny 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 dobtenir le service w.c.s. (%s)"
#: apps/wcs/utils.py
msgid "Unable to get WCS data"
msgstr "Impossible dobtenir 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 à limport : lexport 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 dexport 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 na 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 na 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 &copy; <a href=\"https://openstreetmap.org\">OpenStreetMap</a> "
"contributors, <a href=\"http://creativecommons.org/licenses/by-sa/2.0/\">CC-"
"BY-SA</a>"
"Map data &copy; <a href=\"https://www.openstreetmap.org/"
"copyright\">OpenStreetMap</a>"
msgstr ""
"Données &copy; 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 &copy; <a href=\"https://www.openstreetmap.org/"
"copyright\">OpenStreetMap</a>"

View File

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

View File

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

View File

@ -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();
});
})
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 %}">&Lt;</a>
<a class="button" href="{% url 'combo-manager-snapshot-view' page_pk=snapshot.page_id pk=snapshot.previous %}">&LT;</a>
{% else %}
<a class="button disabled" href="#">&Lt;</a>
<a class="button disabled" href="#">&LT;</a>
{% endif %}
{% if snapshot.pk != snapshot.last %}
<a class="button" href="{% url 'combo-manager-snapshot-view' page_pk=snapshot.page_id pk=snapshot.next %}">&GT;</a>
<a class="button" href="{% url 'combo-manager-snapshot-view' page_pk=snapshot.page_id pk=snapshot.last %}">&Gt;</a>
{% else %}
<a class="button disabled" href="#">&GT;</a>
<a class="button disabled" href="#">&Gt;</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