├── .coveragerc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── COPYING ├── COPYING.fr ├── MANIFEST.in ├── README.md ├── ckanext ├── __init__.py └── scheming │ ├── 2.8_templates │ └── scheming │ │ └── snippets │ │ ├── multiple_text_asset.html │ │ ├── scheming_asset.html │ │ └── subfields_asset.html │ ├── __init__.py │ ├── assets │ ├── js │ │ ├── scheming-multiple-text.js │ │ └── scheming-repeating-subfields.js │ ├── resource.config │ ├── styles │ │ └── scheming.css │ └── webassets.yml │ ├── camel_photos.yaml │ ├── ckan_dataset.yaml │ ├── ckan_formpages.yaml │ ├── ckan_formpages_draft.yaml │ ├── codelist.json │ ├── custom_group_with_status.json │ ├── custom_org_with_address.json │ ├── errors.py │ ├── group_with_bookface.json │ ├── helpers.py │ ├── loader.py │ ├── logic.py │ ├── org_with_dept_id.json │ ├── plugins.py │ ├── presets.json │ ├── subfields.yaml │ ├── templates │ ├── base.html │ ├── organization │ │ └── edit_base.html │ └── scheming │ │ ├── display_snippets │ │ ├── date.html │ │ ├── datetime.html │ │ ├── datetime_tz.html │ │ ├── email.html │ │ ├── file_size.html │ │ ├── json.html │ │ ├── link.html │ │ ├── markdown.html │ │ ├── multiple_choice.html │ │ ├── multiple_text.html │ │ ├── repeating_subfields.html │ │ ├── select.html │ │ └── text.html │ │ ├── form_snippets │ │ ├── _organization_select.html │ │ ├── date.html │ │ ├── datetime.html │ │ ├── datetime_tz.html │ │ ├── help_text.html │ │ ├── json.html │ │ ├── large_text.html │ │ ├── license.html │ │ ├── markdown.html │ │ ├── multiple_checkbox.html │ │ ├── multiple_select.html │ │ ├── multiple_text.html │ │ ├── number.html │ │ ├── organization.html │ │ ├── organization_upload.html │ │ ├── radio.html │ │ ├── repeating_subfields.html │ │ ├── select.html │ │ ├── slug.html │ │ ├── text.html │ │ ├── textarea.html │ │ └── upload.html │ │ ├── group │ │ ├── about.html │ │ └── group_form.html │ │ ├── organization │ │ ├── about.html │ │ └── group_form.html │ │ ├── package │ │ ├── read.html │ │ ├── resource_read.html │ │ └── snippets │ │ │ ├── additional_info.html │ │ │ ├── package_form.html │ │ │ └── resource_form.html │ │ └── snippets │ │ ├── display_field.html │ │ ├── errors.html │ │ ├── form_field.html │ │ ├── multiple_text_asset.html │ │ ├── scheming_asset.html │ │ └── subfields_asset.html │ ├── tests │ ├── __init__.py │ ├── plugins.py │ ├── test_dataset_display.py │ ├── test_dataset_logic.py │ ├── test_datastore_choices.json │ ├── test_form.py │ ├── test_form_snippets.py │ ├── test_formpages.yaml │ ├── test_formpages_draft.yaml │ ├── test_group_display.py │ ├── test_group_logic.py │ ├── test_helpers.py │ ├── test_load.py │ ├── test_schema.json │ ├── test_subfields.yaml │ └── test_validation.py │ ├── validation.py │ └── views.py ├── setup.cfg ├── setup.py ├── test-requirements.txt ├── test.ini └── test_subclass.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = */tests/*, */site-packages/*, */python?.?/*, */ckan/* 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: actions/setup-python@v5 9 | with: 10 | python-version: '3.9' 11 | - name: Install requirements 12 | run: pip install flake8 pycodestyle 13 | - name: Check syntax 14 | run: flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics --exclude ckan 15 | 16 | test: 17 | needs: lint 18 | strategy: 19 | matrix: 20 | ckan-version: ["2.11", "2.10"] 21 | fail-fast: false 22 | 23 | name: CKAN ${{ matrix.ckan-version }} 24 | runs-on: ubuntu-latest 25 | container: 26 | image: ckan/ckan-dev:${{ matrix.ckan-version }} 27 | options: --user root 28 | services: 29 | solr: 30 | image: ckan/ckan-solr:${{ matrix.ckan-version }}-solr9 31 | postgres: 32 | image: ckan/ckan-postgres-dev:${{ matrix.ckan-version }} 33 | env: 34 | POSTGRES_USER: postgres 35 | POSTGRES_PASSWORD: postgres 36 | POSTGRES_DB: postgres 37 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 38 | redis: 39 | image: redis:3 40 | env: 41 | CKAN_SQLALCHEMY_URL: postgresql://ckan_default:pass@postgres/ckan_test 42 | CKAN_DATASTORE_WRITE_URL: postgresql://datastore_write:pass@postgres/datastore_test 43 | CKAN_DATASTORE_READ_URL: postgresql://datastore_read:pass@postgres/datastore_test 44 | CKAN_SOLR_URL: http://solr:8983/solr/ckan 45 | CKAN_REDIS_URL: redis://redis:6379/1 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Install requirements 50 | run: | 51 | pip install -e . 52 | # Replace default path to CKAN core config file with the one on the container 53 | sed -i -e 's/use = config:.*/use = config:\/srv\/app\/src\/ckan\/test-core.ini/' test.ini 54 | sed -i -e 's/use = config:.*/use = config:\/srv\/app\/src\/ckan\/test-core.ini/' test_subclass.ini 55 | - name: Setup extension 56 | run: | 57 | pip install -r test-requirements.txt 58 | ckan -c test.ini db init 59 | - name: Run all tests 60 | run: pytest --ckan-ini=test.ini --cov=ckanext.scheming ckanext/scheming/tests 61 | - name: Run plugin subclassing tests 62 | run: pytest --ckan-ini=test_subclass.ini ckanext/scheming/tests/test_dataset_display.py ckanext/scheming/tests/test_form.py::TestDatasetFormNew ckanext/scheming/tests/test_dataset_logic.py 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | ckanext_scheming.egg-info/* 3 | links/* 4 | build/* 5 | dist/* 6 | .eggs/* 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.0.1 2 | 3 | 2014-09-08 4 | 5 | * initial release 6 | 7 | 8 | ## 0.0.2 9 | 10 | 2014-12-01 11 | 12 | * automated tests and coverage 13 | * presets feature including: title, dataset_slug, tag_string_autocomplete, 14 | dataset_organization, resource_url_upload, resource_format_autocomplete, 15 | select presets 16 | 17 | 18 | ## 1.0.0 19 | 20 | 2015-10-14 21 | 22 | * first stable release 23 | * working group/org customization 24 | * new presets: multiple_checkbox, multiple_select, date 25 | * support for yaml schemas+presets 26 | * lots of fixes 27 | 28 | 29 | ## 1.1.0 30 | 31 | 2017-10-05 32 | 33 | * automated tests against ckan 2.4, 2.5 and 2.6 34 | * json_object field preset for arbitrary JSON objects as values 35 | * datetime and datetime_tz field presets for date+time validation 36 | * display_snippet=null to hide fields from being displayed 37 | * choices_helper option to use a helper function for custom choice lists 38 | * scheming_datastore_choices helper for pulling choice lists from a 39 | DataStore table 40 | * select_size option to customize select form snippet 41 | * sorted_choices option to sort choices before displaying them 42 | * automatic reloading on schema changes in development mode 43 | * improved test coverage and lots of fixes 44 | 45 | 46 | ## 1.2.0 47 | 48 | 2019-03-22 49 | 50 | * automated tests against 2.6, 2.7, 2.8 and master 51 | * fixes and tests for group and org schemas 52 | * form_attrs are now added to existing tag classes instead of replacing them 53 | * remove delete button from create form 54 | * support for custom types in the slug widget 55 | * added required fields markers for groups/orgs 56 | * allow hiding resource fields 57 | * other small fixes 58 | 59 | 60 | ## 2.0.0 61 | 62 | 2020-04-24 63 | 64 | * python 3 support (ckan 2.9+) 65 | * automated tests against 2.6, 2.7, 2.8 and master now using pytest 66 | * select_size option defaults to choices length 67 | * form_snippet=null to hide form fields for group/org forms 68 | * improved plugin reloading support 69 | * add support for group/org image uploads 70 | * other small fixes 71 | 72 | 73 | ## 2.1.0 74 | 75 | 2021-01-20 76 | 77 | * repeating_subfields feature for repeating groups of dataset fields 78 | * multiple_text preset added to support repating text fields 79 | * automated tests against 2.7, 2.8, 2.9 and 2.9 under python 3 80 | * examples converted to yaml for readability 81 | * allow display of data dictionary 82 | * fix auto-generation of resource names 83 | * restore license options in 2.9 84 | * add support for organization image uploads 85 | 86 | 87 | ## 3.0.0 88 | 89 | 2022-11-24 90 | 91 | * dataset metadata forms may now be split across multiple pages with 92 | start_form_page 93 | * new ckan_formpages.yaml example schema using dataset form pages 94 | * datastore_additional_choices option for adding static choices 95 | to a dynamic choice list 96 | * new markdown and radio field form snippets and presets 97 | * automated tests against 2.8 (py2), 2.9 (py2), 2.9 and 2.10 98 | * show extra resource fields on resource pages 99 | * csrf_input and humanize_entity_type support for ckan 2.10 100 | * improved documentation and examples 101 | * fixes for multiple_text form fields 102 | * fixes for repeating_subfields feature 103 | * fix for applying default org/group types 104 | * sync example dataset schemas, presets and templates with upstream ckan 105 | changes 106 | 107 | ## 3.1.0 108 | 109 | 2025-03-27 110 | 111 | * This version drops support for CKAN 2.8, 2.9 and adds support for 2.11 112 | * Pass dataset name to resource fields snippets [#437](https://github.com/ckan/ckanext-scheming/pull/354) 113 | * Allow literal parameters in validator string [#372](https://github.com/ckan/ckanext-scheming/pull/372) 114 | * Fix delete URL for custom organizations [#374](https://github.com/ckan/ckanext-scheming/pull/374) 115 | * Group Form Required Message Position [#376](https://github.com/ckan/ckanext-scheming/pull/376) 116 | * Resource Form Errors Super Fallback [#380](https://github.com/ckan/ckanext-scheming/pull/380) 117 | * Use form_snippets/help_text.html in repeating_subfields.html [#387](https://github.com/ckan/ckanext-scheming/pull/397) 118 | * Add form-select CSS class to select elements [#399](https://github.com/ckan/ckanext-scheming/pull/399) 119 | * Fix ckan version comparison [#406](https://github.com/ckan/ckanext-scheming/pull/406) 120 | * Add number and file_size snippets [#412](https://github.com/ckan/ckanext-scheming/pull/412) 121 | * Before and After Validators for Groups [#428](https://github.com/ckan/ckanext-scheming/pull/428) 122 | * Drop ckantoolkit requirement [#432](https://github.com/ckan/ckanext-scheming/pull/432) 123 | * Fix `is_organization` for custom `organization_type` [#437](https://github.com/ckan/ckanext-scheming/pull/437), [#431](https://github.com/ckan/ckanext-scheming/pull/431) 124 | * fix: Move toggle-more rows to bottom of resource table [#420](https://github.com/ckan/ckanext-scheming/pull/420) 125 | * fix: Add default text to update/edit buttons in group_form [#421](https://github.com/ckan/ckanext-scheming/pull/421) 126 | * fix: False is also a value for radio and select snippets [#417](https://github.com/ckan/ckanext-scheming/pull/417) 127 | * Remove presets parsing cache check [#425](https://github.com/ckan/ckanext-scheming/pull/425) 128 | * fix: optional multiple_text saved as empty string if missing on create 129 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | ckanext-scheming - Terms and Conditions of Use 2 | 3 | Unless otherwise noted, computer program source code of ckanext-scheming is 4 | covered under Crown Copyright, Government of Canada, and is distributed under the MIT License. 5 | 6 | 7 | MIT License 8 | 9 | Copyright (c) His Majesty the King in Right of Canada, represented by the President of the Treasury 10 | Board, 2013-2018 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 13 | associated documentation files (the "Software"), to deal in the Software without restriction, 14 | including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 15 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, 16 | subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all copies or substantial 19 | portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 22 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 23 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 24 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 25 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /COPYING.fr: -------------------------------------------------------------------------------- 1 | ckanext-scheming - Conditions régissant l'utilisation 2 | 3 | Sauf indication contraire, le code source de la ckanext-scheming 4 | est protégé par le droit d'auteur de la Couronne du gouvernement du Canada et distribué 5 | sous la licence MIT. 6 | 7 | 8 | Licence MIT 9 | 10 | (c) Droit d'auteur – Sa Majesté le Roi du chef du Canada, représentée par le président du Conseil 11 | du Trésor, 2013-2018 12 | 13 | La présente autorise toute personne d'obtenir gratuitement une copie du présent logiciel et des 14 | documents connexes (le « logiciel »), de traiter le logiciel sans restriction, y compris, mais sans 15 | s'y limiter, les droits d'utiliser, de copier, de modifier, de fusionner, de publier, de distribuer, 16 | d'accorder une sous licence et de vendre des copies dudit logiciel, et de permettre aux personnes 17 | auxquelles le logiciel est fourni de le faire, selon les conditions suivantes : 18 | 19 | L'avis de droit d'auteur ci dessus et le présent avis de permission seront inclus dans toutes les copies 20 | et les sections importantes du logiciel. 21 | 22 | LE LOGICIEL EST FOURNI « TEL QUEL », SANS AUCUNE GARANTIE, EXPRESSE OU IMPLICITE, Y COMPRIS, MAIS SANS 23 | S'Y LIMITER, LA GARANTIE DE QUALITÉ MARCHANDE, L'ADAPTATION À UN USAGE PARTICULIER ET L'ABSENCE DE 24 | CONTREFAÇON. EN AUCUN CAS LES AUTEURS OU LES DÉTENTEURS DU DROIT D'AUTEUR NE SERONT TENUS RESPONSABLES 25 | DE TOUTE DEMANDE, DOMMAGE OU BRIS DE CONTRAT, DÉLIT CIVIL OU TOUT AUTRE MANQUEMENT LIÉ AU LOGICIEL, 26 | À SON UTILISATION OU À D'AUTRES ÉCHANGES LIÉS AU LOGICIEL. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include ckanext *.html *.json *.yaml *.js *.html *.css *.yml *.config 2 | include README.md CHANGELOG.md *.ini 3 | -------------------------------------------------------------------------------- /ckanext/__init__.py: -------------------------------------------------------------------------------- 1 | # this is a namespace package 2 | try: 3 | import pkg_resources 4 | pkg_resources.declare_namespace(__name__) 5 | except ImportError: 6 | import pkgutil 7 | __path__ = pkgutil.extend_path(__path__, __name__) 8 | -------------------------------------------------------------------------------- /ckanext/scheming/2.8_templates/scheming/snippets/multiple_text_asset.html: -------------------------------------------------------------------------------- 1 | {% resource 'ckanext-scheming/js/scheming-multiple-text.js' %} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/2.8_templates/scheming/snippets/scheming_asset.html: -------------------------------------------------------------------------------- 1 | {% resource 'ckanext-scheming/styles/scheming.css' %} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/2.8_templates/scheming/snippets/subfields_asset.html: -------------------------------------------------------------------------------- 1 | {% resource 'ckanext-scheming/js/scheming-repeating-subfields.js' %} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckan/ckanext-scheming/4a4bf3366389902bc53ab654ec5caf1e6dd67a22/ckanext/scheming/__init__.py -------------------------------------------------------------------------------- /ckanext/scheming/assets/js/scheming-multiple-text.js: -------------------------------------------------------------------------------- 1 | var scheming_multiple_text_init_done = false; 2 | this.ckan.module('scheming-multiple-text', function($, _) { 3 | MultipleText = { 4 | 5 | multiple_add: function(field_name){ 6 | var fieldset = $('fieldset[name='+field_name+']'); 7 | let list = fieldset.find('ol') 8 | let items = list.find('li') 9 | var copy = items.last().clone(); 10 | let input = copy.find('input'); 11 | input.val(''); 12 | list.append(copy); 13 | input.focus(); 14 | }, 15 | initialize: function() { 16 | if (!scheming_multiple_text_init_done) { 17 | $(document).on('click', 'a[name="multiple-remove"]', function(e) { 18 | var list = $(this).closest('ol').find('li'); 19 | if (list.length != 1){ 20 | var $curr = $(this).closest('.multiple-text-field'); 21 | $curr.hide(100, function() { 22 | $curr.remove(); 23 | }); 24 | e.preventDefault(); 25 | } 26 | else{ 27 | list.first().find('input').val(''); 28 | } 29 | }); 30 | scheming_multiple_text_init_done = true; 31 | } 32 | } 33 | }; 34 | return MultipleText; 35 | }); 36 | -------------------------------------------------------------------------------- /ckanext/scheming/assets/js/scheming-repeating-subfields.js: -------------------------------------------------------------------------------- 1 | ckan.module('scheming-repeating-subfields', function($) { 2 | return { 3 | initialize: function() { 4 | $.proxyAll(this, /_on/); 5 | 6 | var $template = this.el.children('div[name="repeating-template"]'); 7 | this.template = $template.html(); 8 | $template.remove(); 9 | 10 | this.el.find('a[name="repeating-add"]').on("click", this._onCreateGroup); 11 | this.el.on('click', 'a[name="repeating-remove"]', this._onRemoveGroup); 12 | }, 13 | 14 | /** 15 | * Add new group to the fieldset. 16 | * 17 | * Fields inside every new group must be renamed in order to form correct 18 | * structure during validation: 19 | * 20 | * ... 21 | * (parent, INDEX-1, child-1), 22 | * (parent, INDEX-1, child-2), 23 | * --- 24 | * (parent, INDEX-2, child-1), 25 | * (parent, INDEX-2, child-2), 26 | * ... 27 | */ 28 | _onCreateGroup: function(e) { 29 | var $last = this.el.find('.scheming-subfield-group').last(); 30 | var group = ($last.data('groupIndex') + 1) || 0; 31 | var $copy = $( 32 | this.template.replace(/REPEATING-INDEX0/g, group) 33 | .replace(/REPEATING-INDEX1/g, group + 1)); 34 | this.el.find('.scheming-repeating-subfields-group').append($copy); 35 | 36 | this.initializeModules($copy); 37 | $copy.hide().show(100); 38 | $copy.find('input').first().focus(); 39 | // hook for late init when required for rendering polyfills 40 | this.el.trigger('scheming.subfield-group-init'); 41 | e.preventDefault(); 42 | }, 43 | 44 | /** 45 | * Remove existing group from the fieldset. 46 | */ 47 | _onRemoveGroup: function(e) { 48 | var $curr = $(e.target).closest('.scheming-subfield-group'); 49 | var $body = $curr.find('.panel-body.fields-content'); 50 | var $button = $curr.find('.btn-repeating-remove'); 51 | var $removed = $curr.find('.panel-body.fields-removed-notice'); 52 | $button.hide(); 53 | $removed.show(100); 54 | $body.hide(100, function() { 55 | $body.html(''); 56 | }); 57 | e.preventDefault(); 58 | }, 59 | 60 | /** 61 | * Enable functionality of data-module attribute inside dynamically added 62 | * groups. 63 | */ 64 | initializeModules: function(tpl) { 65 | $('[data-module]', tpl).each(function (index, element) { 66 | ckan.module.initializeElement(this); 67 | }); 68 | } 69 | }; 70 | }); 71 | -------------------------------------------------------------------------------- /ckanext/scheming/assets/resource.config: -------------------------------------------------------------------------------- 1 | [depends] 2 | main = base/main 3 | 4 | [groups] 5 | main = 6 | js/scheming-multiple-text.js 7 | js/scheming-repeating-subfields.js 8 | -------------------------------------------------------------------------------- /ckanext/scheming/assets/styles/scheming.css: -------------------------------------------------------------------------------- 1 | header.panel-heading { 2 | position: relative; 3 | } 4 | 5 | /* try to resemble .btn-remove-url style in resource form */ 6 | .btn.btn-danger.btn-repeating-remove { 7 | position: absolute; 8 | margin-right: 0; 9 | top: 9px; 10 | right: 7px; 11 | padding: 0 12px; 12 | border-radius: 100px; 13 | } 14 | 15 | li.multiple-text-field { 16 | position: relative; 17 | padding-bottom: 6px; 18 | } 19 | 20 | li.multiple-text-field input { 21 | padding-right: 90px; 22 | } 23 | 24 | /* try to resemble .btn-remove-url style in resource form */ 25 | a.btn.btn-multiple-remove { 26 | position: absolute; 27 | margin-right: 0; 28 | top: 6px; 29 | right: 7px; 30 | padding: 0 12px; 31 | border-radius: 100px; 32 | } 33 | 34 | /* remove ":" after form label */ 35 | .radio-group label::after { 36 | content: none; 37 | } 38 | 39 | .radio-group label { 40 | font-weight: normal; 41 | } 42 | -------------------------------------------------------------------------------- /ckanext/scheming/assets/webassets.yml: -------------------------------------------------------------------------------- 1 | scheming_css: 2 | output: ckanext-scheming/%(version)s_scheming_css.css 3 | contents: 4 | - styles/scheming.css 5 | 6 | subfields: 7 | filters: rjsmin 8 | output: ckanext-scheming/%(version)s_scheming_subfields.js 9 | extra: 10 | preload: 11 | - base/main 12 | contents: 13 | - js/scheming-repeating-subfields.js 14 | 15 | multiple_text: 16 | filters: rjsmin 17 | output: ckanext-scheming/%(version)s_scheming_multiple_text.js 18 | extra: 19 | preload: 20 | - base/main 21 | contents: 22 | - js/scheming-multiple-text.js 23 | -------------------------------------------------------------------------------- /ckanext/scheming/camel_photos.yaml: -------------------------------------------------------------------------------- 1 | dataset_type: camel-photos 2 | about_url: http://example.com/the-camel-photos-schema 3 | 4 | 5 | dataset_fields: 6 | 7 | - field_name: title 8 | label: Title 9 | preset: title 10 | form_placeholder: eg. Larry, Peter, Susan 11 | 12 | - field_name: name 13 | label: URL 14 | preset: dataset_slug 15 | form_placeholder: eg. camel-no-5 16 | 17 | - field_name: humps 18 | label: Humps 19 | validators: ignore_missing int_validator 20 | form_placeholder: eg. 2 21 | 22 | - field_name: category 23 | label: Category 24 | help_text: Make and model 25 | help_inline: true 26 | preset: select 27 | choices: 28 | - value: bactrian 29 | label: Bactrian Camel 30 | - value: hybrid 31 | label: Hybrid Camel 32 | - value: f2hybrid 33 | label: F2 Hybrid Camel 34 | - value: snowwhite 35 | label: Snow-white Dromedary 36 | - value: black 37 | label: Black Camel 38 | 39 | - field_name: personality 40 | label: Personality 41 | preset: multiple_checkbox 42 | choices: 43 | - value: friendly 44 | label: Often friendly 45 | - value: jealous 46 | label: Jealous of others 47 | - value: spits 48 | label: Tends to spit 49 | 50 | - field_name: other 51 | label: 52 | en: Other information 53 | output_validators: ignore_missing 54 | 55 | 56 | resource_fields: 57 | 58 | - field_name: url 59 | label: Photo 60 | preset: resource_url_upload 61 | form_placeholder: http://example.com/my-camel-photo.jpg 62 | upload_label: Photo 63 | 64 | - field_name: camels_in_photo 65 | label: Camels in Photo 66 | validators: ignore_missing int_validator 67 | form_placeholder: eg. 2 68 | 69 | - field_name: others_in_photo 70 | label: Other Thing in Photo 71 | output_validators: ignore_missing 72 | 73 | - field_name: datetime 74 | label: Date Taken 75 | label_time: Time Taken 76 | preset: datetime 77 | -------------------------------------------------------------------------------- /ckanext/scheming/ckan_dataset.yaml: -------------------------------------------------------------------------------- 1 | dataset_type: dataset 2 | about: A reimplementation of the default CKAN dataset schema 3 | about_url: https://github.com/ckan/ckanext-scheming 4 | 5 | 6 | dataset_fields: 7 | 8 | - field_name: title 9 | label: Title 10 | preset: title 11 | form_placeholder: eg. A descriptive title 12 | 13 | - field_name: name 14 | label: URL 15 | preset: dataset_slug 16 | form_placeholder: eg. my-dataset 17 | 18 | - field_name: notes 19 | label: Description 20 | form_snippet: markdown.html 21 | form_placeholder: eg. Some useful notes about the data 22 | 23 | - field_name: tag_string 24 | label: Tags 25 | preset: tag_string_autocomplete 26 | form_placeholder: eg. economy, mental health, government 27 | 28 | - field_name: license_id 29 | label: License 30 | form_snippet: license.html 31 | help_text: License definitions and additional information can be found at http://opendefinition.org/ 32 | 33 | - field_name: owner_org 34 | label: Organization 35 | preset: dataset_organization 36 | 37 | - field_name: url 38 | label: Source 39 | form_placeholder: http://example.com/dataset.json 40 | display_property: foaf:homepage 41 | display_snippet: link.html 42 | 43 | - field_name: version 44 | label: Version 45 | validators: ignore_missing unicode_safe package_version_validator 46 | form_placeholder: '1.0' 47 | 48 | - field_name: author 49 | label: Author 50 | form_placeholder: Joe Bloggs 51 | display_property: dc:creator 52 | 53 | - field_name: author_email 54 | label: Author Email 55 | form_placeholder: joe@example.com 56 | display_property: dc:creator 57 | display_snippet: email.html 58 | display_email_name_field: author 59 | 60 | - field_name: maintainer 61 | label: Maintainer 62 | form_placeholder: Joe Bloggs 63 | display_property: dc:contributor 64 | 65 | - field_name: maintainer_email 66 | label: Maintainer Email 67 | form_placeholder: joe@example.com 68 | display_property: dc:contributor 69 | display_snippet: email.html 70 | display_email_name_field: maintainer 71 | 72 | 73 | resource_fields: 74 | 75 | - field_name: url 76 | label: URL 77 | preset: resource_url_upload 78 | 79 | - field_name: name 80 | label: Name 81 | form_placeholder: eg. January 2011 Gold Prices 82 | 83 | - field_name: description 84 | label: Description 85 | form_snippet: markdown.html 86 | form_placeholder: Some useful notes about the data 87 | 88 | - field_name: format 89 | label: Format 90 | preset: resource_format_autocomplete 91 | -------------------------------------------------------------------------------- /ckanext/scheming/ckan_formpages.yaml: -------------------------------------------------------------------------------- 1 | dataset_type: formpages 2 | about: The default CKAN dataset schema with form split across multiple pages 3 | about_url: https://github.com/ckan/ckanext-scheming 4 | 5 | 6 | dataset_fields: 7 | 8 | - start_form_page: 9 | title: Basic Info 10 | description: Required and core dataset fields 11 | 12 | field_name: title 13 | label: Title 14 | preset: title 15 | form_placeholder: eg. A descriptive title 16 | 17 | - field_name: name 18 | label: URL 19 | preset: dataset_slug 20 | form_placeholder: eg. my-dataset 21 | 22 | - field_name: notes 23 | label: Description 24 | form_snippet: markdown.html 25 | form_placeholder: eg. Some useful notes about the data 26 | 27 | - field_name: owner_org 28 | label: Organization 29 | preset: dataset_organization 30 | 31 | - start_form_page: 32 | title: Detailed Info 33 | description: 34 | These fields improve search and give users important links 35 | 36 | field_name: tag_string 37 | label: Tags 38 | preset: tag_string_autocomplete 39 | form_placeholder: eg. economy, mental health, government 40 | 41 | - field_name: license_id 42 | label: License 43 | form_snippet: license.html 44 | help_text: License definitions and additional information can be found at http://opendefinition.org/ 45 | 46 | - field_name: url 47 | label: Source 48 | form_placeholder: http://example.com/dataset.json 49 | display_property: foaf:homepage 50 | display_snippet: link.html 51 | 52 | - field_name: version 53 | label: Version 54 | validators: ignore_missing unicode_safe package_version_validator 55 | form_placeholder: '1.0' 56 | 57 | - start_form_page: 58 | title: Contact Info 59 | description: Names and email addresses for this dataset 60 | 61 | field_name: author 62 | label: Author 63 | form_placeholder: Joe Bloggs 64 | display_property: dc:creator 65 | 66 | - field_name: author_email 67 | label: Author Email 68 | form_placeholder: joe@example.com 69 | display_property: dc:creator 70 | display_snippet: email.html 71 | display_email_name_field: author 72 | validators: ignore_missing unicode_safe strip_value email_validator 73 | 74 | - field_name: maintainer 75 | label: Maintainer 76 | form_placeholder: Joe Bloggs 77 | display_property: dc:contributor 78 | 79 | - field_name: maintainer_email 80 | label: Maintainer Email 81 | form_placeholder: joe@example.com 82 | display_property: dc:contributor 83 | display_snippet: email.html 84 | display_email_name_field: maintainer 85 | validators: ignore_missing unicode_safe strip_value email_validator 86 | 87 | 88 | resource_fields: 89 | 90 | - field_name: url 91 | label: URL 92 | preset: resource_url_upload 93 | 94 | - field_name: name 95 | label: Name 96 | form_placeholder: eg. January 2011 Gold Prices 97 | 98 | - field_name: description 99 | label: Description 100 | form_snippet: markdown.html 101 | form_placeholder: Some useful notes about the data 102 | 103 | - field_name: format 104 | label: Format 105 | preset: resource_format_autocomplete 106 | -------------------------------------------------------------------------------- /ckanext/scheming/ckan_formpages_draft.yaml: -------------------------------------------------------------------------------- 1 | dataset_type: formpages 2 | about: The default CKAN dataset schema with form split across multiple pages 3 | with required fields only enforced when publishing. See 4 | https://github.com/ckan/ckanext-scheming?tab=readme-ov-file#draft_fields_required 5 | about_url: https://github.com/ckan/ckanext-scheming 6 | 7 | draft_fields_required: false 8 | 9 | dataset_fields: 10 | 11 | - start_form_page: 12 | title: Basic Info 13 | description: Required and core dataset fields 14 | 15 | field_name: title 16 | label: Title 17 | preset: title 18 | form_placeholder: eg. A descriptive title 19 | 20 | - field_name: name 21 | label: URL 22 | preset: dataset_slug 23 | form_placeholder: eg. my-dataset 24 | 25 | - field_name: notes 26 | label: Description 27 | form_snippet: markdown.html 28 | form_placeholder: eg. Some useful notes about the data 29 | required: true 30 | validators: scheming_required unicode_safe 31 | 32 | - field_name: owner_org 33 | label: Organization 34 | preset: dataset_organization 35 | 36 | - start_form_page: 37 | title: Detailed Info 38 | description: 39 | These fields improve search and give users important links 40 | 41 | field_name: tag_string 42 | label: Tags 43 | preset: tag_string_autocomplete 44 | form_placeholder: eg. economy, mental health, government 45 | 46 | - field_name: license_id 47 | label: License 48 | form_snippet: license.html 49 | help_text: License definitions and additional information can be found at http://opendefinition.org/ 50 | 51 | - field_name: url 52 | label: Source 53 | form_placeholder: http://example.com/dataset.json 54 | display_property: foaf:homepage 55 | display_snippet: link.html 56 | 57 | - field_name: version 58 | label: Version 59 | validators: ignore_missing unicode_safe package_version_validator 60 | form_placeholder: '1.0' 61 | required: true 62 | validators: scheming_required unicode_safe package_version_validator 63 | 64 | - start_form_page: 65 | title: Contact Info 66 | description: Names and email addresses for this dataset 67 | 68 | field_name: author 69 | label: Author 70 | form_placeholder: Joe Bloggs 71 | display_property: dc:creator 72 | required: true 73 | validators: scheming_required unicode_safe 74 | 75 | - field_name: author_email 76 | label: Author Email 77 | form_placeholder: joe@example.com 78 | display_property: dc:creator 79 | display_snippet: email.html 80 | display_email_name_field: author 81 | validators: ignore_missing unicode_safe strip_value email_validator 82 | 83 | - field_name: maintainer 84 | label: Maintainer 85 | form_placeholder: Joe Bloggs 86 | display_property: dc:contributor 87 | 88 | - field_name: maintainer_email 89 | label: Maintainer Email 90 | form_placeholder: joe@example.com 91 | display_property: dc:contributor 92 | display_snippet: email.html 93 | display_email_name_field: maintainer 94 | validators: ignore_missing unicode_safe strip_value email_validator 95 | 96 | 97 | resource_fields: 98 | 99 | - field_name: url 100 | label: URL 101 | preset: resource_url_upload 102 | 103 | - field_name: name 104 | label: Name 105 | form_placeholder: eg. January 2011 Gold Prices 106 | required: true 107 | validators: scheming_required unicode_safe 108 | 109 | - field_name: description 110 | label: Description 111 | form_snippet: markdown.html 112 | form_placeholder: Some useful notes about the data 113 | 114 | - field_name: format 115 | label: Format 116 | preset: resource_format_autocomplete 117 | -------------------------------------------------------------------------------- /ckanext/scheming/codelist.json: -------------------------------------------------------------------------------- 1 | { 2 | "dataset_type": "codelist", 3 | "about": "An example of a dataset type with no resources permitted", 4 | "about_url": "http://github.com/ckan/ckanext-scheming", 5 | "dataset_fields": [ 6 | { 7 | "field_name": "title", 8 | "label": "Code label", 9 | "preset": "title", 10 | "form_placeholder": "eg. Freshwater fish" 11 | }, 12 | { 13 | "field_name": "name", 14 | "label": "Code value", 15 | "preset": "dataset_slug", 16 | "form_placeholder": "eg. freshwater-fish" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /ckanext/scheming/custom_group_with_status.json: -------------------------------------------------------------------------------- 1 | { 2 | "group_type": "theme", 3 | "about_url": "http://github.com/ckan/ckanext-scheming", 4 | "fields": [ 5 | { 6 | "field_name": "title", 7 | "label": "Name", 8 | "validators": "ignore_missing unicode_safe", 9 | "form_snippet": "large_text.html", 10 | "form_attrs": {"data-module": "slug-preview-target"}, 11 | "form_placeholder": "My theme" 12 | }, 13 | { 14 | "field_name": "name", 15 | "label": "URL", 16 | "validators": "not_empty unicode_safe name_validator group_name_validator", 17 | "form_snippet": "slug.html", 18 | "form_placeholder": "my-theme" 19 | }, 20 | { 21 | "field_name": "notes", 22 | "label": "Description", 23 | "form_snippet": "markdown.html", 24 | "form_placeholder": "A little information about my group..." 25 | }, 26 | { 27 | "field_name": "url", 28 | "label": "Image URL", 29 | "form_placeholder": "http://example.com/my-image.jpg" 30 | }, 31 | { 32 | "field_name": "status", 33 | "label": "Status", 34 | "output_validators": "ignore_missing", 35 | "choices": [ 36 | { 37 | "label": "In Progress", 38 | "value": "in-progress" 39 | }, 40 | { 41 | "label": "Final", 42 | "value": "final" 43 | } 44 | ] 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /ckanext/scheming/custom_org_with_address.json: -------------------------------------------------------------------------------- 1 | { 2 | "organization_type": "publisher", 3 | "about_url": "http://github.com/ckan/ckanext-scheming", 4 | "fields": [ 5 | { 6 | "field_name": "title", 7 | "label": "Name", 8 | "validators": "ignore_missing unicode_safe", 9 | "form_snippet": "large_text.html", 10 | "form_attrs": {"data-module": "slug-preview-target"}, 11 | "form_placeholder": "My theme" 12 | }, 13 | { 14 | "field_name": "name", 15 | "label": "URL", 16 | "validators": "not_empty unicode_safe name_validator group_name_validator", 17 | "form_snippet": "slug.html", 18 | "form_placeholder": "my-theme" 19 | }, 20 | { 21 | "field_name": "notes", 22 | "label": "Description", 23 | "form_snippet": "markdown.html", 24 | "form_placeholder": "A little information about my group..." 25 | }, 26 | { 27 | "field_name": "url", 28 | "label": "Image URL", 29 | "form_placeholder": "http://example.com/my-image.jpg" 30 | }, 31 | { 32 | "field_name": "address", 33 | "label": "Address", 34 | "output_validators": "ignore_missing" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /ckanext/scheming/errors.py: -------------------------------------------------------------------------------- 1 | 2 | class SchemingException(Exception): 3 | pass 4 | 5 | -------------------------------------------------------------------------------- /ckanext/scheming/group_with_bookface.json: -------------------------------------------------------------------------------- 1 | { 2 | "group_type": "group", 3 | "about_url": "http://github.com/ckan/ckanext-scheming", 4 | "fields": [ 5 | { 6 | "field_name": "title", 7 | "label": "Name", 8 | "validators": "ignore_missing unicode_safe", 9 | "form_snippet": "large_text.html", 10 | "form_attrs": {"data-module": "slug-preview-target"}, 11 | "form_placeholder": "My Organization" 12 | }, 13 | { 14 | "field_name": "name", 15 | "label": "URL", 16 | "validators": "not_empty unicode_safe name_validator group_name_validator", 17 | "form_snippet": "slug.html", 18 | "form_placeholder": "my-organization" 19 | }, 20 | { 21 | "field_name": "notes", 22 | "label": "Description", 23 | "form_snippet": "markdown.html", 24 | "form_placeholder": "A little information about my organization..." 25 | }, 26 | { 27 | "field_name": "url", 28 | "label": "Image URL", 29 | "form_placeholder": "http://example.com/my-image.jpg" 30 | }, 31 | { 32 | "field_name": "bookface", 33 | "label": "Bookface", 34 | "form_placeholder": "http://bookface.example.com/ourgroup", 35 | "output_validators": "ignore_missing" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /ckanext/scheming/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | import datetime 5 | import pytz 6 | import json 7 | import six 8 | 9 | from jinja2 import Environment 10 | from ckan.plugins.toolkit import config, _, h 11 | 12 | from ckanapi import LocalCKAN, NotFound, NotAuthorized 13 | 14 | all_helpers = {} 15 | 16 | def helper(fn): 17 | """ 18 | collect helper functions into ckanext.scheming.all_helpers dict 19 | """ 20 | all_helpers[fn.__name__] = fn 21 | return fn 22 | 23 | 24 | def lang(): 25 | # access this function late in case ckan 26 | # is not set up fully when importing this module 27 | return h.lang() 28 | 29 | 30 | @helper 31 | def scheming_language_text(text, prefer_lang=None): 32 | """ 33 | :param text: {lang: text} dict or text string 34 | :param prefer_lang: choose this language version if available 35 | 36 | Convert "language-text" to users' language by looking up 37 | languag in dict or using gettext if not a dict 38 | """ 39 | if not text: 40 | return u'' 41 | 42 | assert text != {} 43 | if hasattr(text, 'get'): 44 | try: 45 | if prefer_lang is None: 46 | prefer_lang = lang() 47 | except TypeError: 48 | pass # lang() call will fail when no user language available 49 | else: 50 | try: 51 | return text[prefer_lang] 52 | except KeyError: 53 | pass 54 | 55 | default_locale = config.get('ckan.locale_default', 'en') 56 | try: 57 | return text[default_locale] 58 | except KeyError: 59 | pass 60 | 61 | l, v = sorted(text.items())[0] 62 | return v 63 | 64 | if isinstance(text, six.binary_type): 65 | text = text.decode('utf-8') 66 | t = _(text) 67 | return t 68 | 69 | 70 | @helper 71 | def scheming_field_choices(field): 72 | """ 73 | :param field: scheming field definition 74 | :returns: choices iterable or None if not found. 75 | """ 76 | if 'choices' in field: 77 | return field['choices'] 78 | if 'choices_helper' in field: 79 | choices_fn = getattr(h, field['choices_helper']) 80 | return choices_fn(field) 81 | 82 | 83 | @helper 84 | def scheming_choices_label(choices, value): 85 | """ 86 | :param choices: choices list of {"label": .., "value": ..} dicts 87 | :param value: value selected 88 | 89 | Return the label from choices with a matching value, or 90 | the value passed when not found. Result is passed through 91 | scheming_language_text before being returned. 92 | """ 93 | for c in choices: 94 | if c['value'] == value: 95 | return scheming_language_text(c.get('label', value)) 96 | return scheming_language_text(value) 97 | 98 | 99 | @helper 100 | def scheming_datastore_choices(field): 101 | """ 102 | Required scheming field: 103 | "datastore_choices_resource": "resource_id_or_alias" 104 | 105 | Optional scheming fields: 106 | "datastore_choices_columns": { 107 | "value": "value_column_name", 108 | "label": "label_column_name" } 109 | "datastore_choices_limit": 1000 (default) 110 | "datastore_additional_choices": [ 111 | { 112 | "value": "none", 113 | "label": "None" 114 | }, 115 | "..." 116 | ] 117 | 118 | When columns aren't specified the first column is used as value 119 | and second column used as label. 120 | """ 121 | resource_id = field['datastore_choices_resource'] 122 | limit = field.get('datastore_choices_limit', 1000) 123 | columns = field.get('datastore_choices_columns') 124 | fields = None 125 | if columns: 126 | fields = [columns['value'], columns['label']] 127 | 128 | # anon user must be able to read choices or this helper 129 | # could be used to leak data from private datastore tables 130 | lc = LocalCKAN(username='') 131 | try: 132 | result = lc.action.datastore_search( 133 | resource_id=resource_id, 134 | limit=limit, 135 | fields=fields) 136 | except (NotFound, NotAuthorized): 137 | return [] 138 | 139 | if not fields: 140 | fields = [f['id'] for f in result['fields'] if f['id'] != '_id'] 141 | 142 | datastore_choices = [{ 143 | 'value': r[fields[0]], 144 | 'label': r[fields[1]] 145 | } for r in result['records']] 146 | 147 | additional_choices = field.get('datastore_additional_choices', []) 148 | 149 | return additional_choices + datastore_choices 150 | 151 | 152 | @helper 153 | def scheming_field_required(field): 154 | """ 155 | Return field['required'] or guess based on validators if not present. 156 | """ 157 | if 'required' in field: 158 | return field['required'] 159 | return 'not_empty' in field.get('validators', '').split() 160 | 161 | 162 | @helper 163 | def scheming_dataset_schemas(expanded=True): 164 | """ 165 | Return the dict of dataset schemas. Or if scheming_datasets 166 | plugin is not loaded return None. 167 | """ 168 | from ckanext.scheming.plugins import SchemingDatasetsPlugin as p 169 | if p.instance: 170 | if expanded: 171 | return p.instance._expanded_schemas 172 | return p.instance._schemas 173 | 174 | 175 | @helper 176 | def scheming_get_presets(): 177 | """ 178 | Returns a dict of all defined presets. If the scheming_datasets 179 | plugin is not loaded return None. 180 | """ 181 | from ckanext.scheming.plugins import SchemingDatasetsPlugin as p 182 | if p.instance: 183 | return p._presets 184 | 185 | 186 | @helper 187 | def scheming_get_preset(preset_name): 188 | """ 189 | Returns the preset by the name `preset_name`.. If the scheming_datasets 190 | plugin is not loaded or the preset does not exist, return None. 191 | 192 | :param preset_name: The preset to lookup. 193 | :returns: The preset or None if not found. 194 | :rtype: None or dict 195 | """ 196 | schemas = scheming_get_presets() 197 | if schemas: 198 | return schemas.get(preset_name) 199 | 200 | 201 | @helper 202 | def scheming_get_dataset_schema(dataset_type, expanded=True): 203 | """ 204 | Return the schema for the dataset_type passed or None if 205 | no schema is defined for that dataset_type 206 | """ 207 | schemas = scheming_dataset_schemas(expanded) 208 | if schemas: 209 | return schemas.get(dataset_type) 210 | 211 | 212 | @helper 213 | def scheming_get_dataset_form_pages(dataset_type): 214 | """ 215 | Return the dataset fields for dataset_type grouped into 216 | separate pages based on start_form_page values, or [] 217 | if no pages were defined 218 | """ 219 | from ckanext.scheming.plugins import SchemingDatasetsPlugin as p 220 | if p.instance: 221 | return p.instance._dataset_form_pages.get(dataset_type) 222 | 223 | 224 | @helper 225 | def scheming_group_schemas(expanded=True): 226 | """ 227 | Return the dict of group schemas. Or if scheming_groups 228 | plugin is not loaded return None. 229 | """ 230 | from ckanext.scheming.plugins import SchemingGroupsPlugin as p 231 | if p.instance: 232 | if expanded: 233 | return p.instance._expanded_schemas 234 | return p.instance._schemas 235 | 236 | 237 | @helper 238 | def scheming_get_group_schema(group_type, expanded=True): 239 | """ 240 | Return the schema for the group_type passed or None if 241 | no schema is defined for that group_type 242 | """ 243 | schemas = scheming_group_schemas(expanded) 244 | if schemas: 245 | return schemas.get(group_type) 246 | 247 | 248 | @helper 249 | def scheming_organization_schemas(expanded=True): 250 | """ 251 | Return the dict of organization schemas. Or if scheming_organizations 252 | plugin is not loaded return None. 253 | """ 254 | from ckanext.scheming.plugins import SchemingOrganizationsPlugin as p 255 | if p.instance: 256 | if expanded: 257 | return p.instance._expanded_schemas 258 | return p.instance._schemas 259 | 260 | 261 | @helper 262 | def scheming_get_organization_schema(organization_type, expanded=True): 263 | """ 264 | Return the schema for the organization_type passed or None if 265 | no schema is defined for that organization_type 266 | """ 267 | schemas = scheming_organization_schemas(expanded) 268 | if schemas: 269 | return schemas.get(organization_type) 270 | 271 | 272 | @helper 273 | def scheming_get_schema(entity_type, object_type, expanded=True): 274 | """ 275 | Return the schema for the entity and object types passed 276 | or None if no schema is defined for the passed types 277 | """ 278 | if entity_type == 'dataset': 279 | return scheming_get_dataset_schema(object_type, expanded) 280 | elif entity_type == 'organization': 281 | return scheming_get_organization_schema(object_type, expanded) 282 | elif entity_type == 'group': 283 | return scheming_get_group_schema(object_type, expanded) 284 | 285 | 286 | @helper 287 | def scheming_field_by_name(fields, name): 288 | """ 289 | Simple helper to grab a field from a schema field list 290 | based on the field name passed. Returns None when not found. 291 | """ 292 | for f in fields: 293 | if f.get('field_name') == name: 294 | return f 295 | 296 | 297 | def date_tz_str_to_datetime(date_str): 298 | """Convert ISO-like formatted datestring with timezone to datetime object. 299 | 300 | This function converts ISO format datetime-strings into datetime objects. 301 | Times may be specified down to the microsecond. UTC offset or timezone 302 | information be included in the string. 303 | 304 | Note - Although originally documented as parsing ISO date(-times), this 305 | function doesn't fully adhere to the format. It allows microsecond 306 | precision, despite that not being part of the ISO format. 307 | """ 308 | split = date_str.split('T') 309 | 310 | if len(split) < 2: 311 | raise ValueError('Unable to parse time') 312 | 313 | tz_split = re.split('([Z+-])', split[1]) 314 | 315 | date = split[0] + 'T' + tz_split[0] 316 | time_tuple = re.split('[^\d]+', date, maxsplit=5) 317 | 318 | # Extract seconds and microseconds 319 | if len(time_tuple) >= 6: 320 | m = re.match('(?P\d{2})(\.(?P\d{3,6}))?$', 321 | time_tuple[5]) 322 | if not m: 323 | raise ValueError('Unable to parse %s as seconds.microseconds' % 324 | time_tuple[5]) 325 | seconds = int(m.groupdict().get('seconds')) 326 | microseconds = int(m.groupdict(0).get('microseconds')) 327 | time_tuple = time_tuple[:5] + [seconds, microseconds] 328 | 329 | final_date = datetime.datetime(*(int(x) for x in time_tuple)) 330 | 331 | # Apply the timezone offset 332 | if len(tz_split) > 1 and not tz_split[1] == 'Z': 333 | tz = tz_split[2] 334 | tz_tuple = re.split('[^\d]+', tz) 335 | 336 | if tz_tuple[0] == '': 337 | raise ValueError('Unable to parse timezone') 338 | offset = int(tz_tuple[0]) * 60 339 | 340 | if len(tz_tuple) > 1 and not tz_tuple[1] == '': 341 | offset += int(tz_tuple[1]) 342 | 343 | if tz_split[1] == '+': 344 | offset *= -1 345 | 346 | final_date += datetime.timedelta(minutes=offset) 347 | 348 | return final_date 349 | 350 | 351 | @helper 352 | def scheming_datetime_to_utc(date): 353 | if date.tzinfo: 354 | date = date.astimezone(pytz.utc) 355 | 356 | # Make date naive before returning 357 | return date.replace(tzinfo=None) 358 | 359 | 360 | @helper 361 | def scheming_datetime_to_tz(date, tz): 362 | if isinstance(tz, six.string_types): 363 | tz = pytz.timezone(tz) 364 | 365 | # Make date naive before returning 366 | return pytz.utc.localize(date).astimezone(tz).replace(tzinfo=None) 367 | 368 | 369 | @helper 370 | def scheming_get_timezones(field): 371 | def to_options(l): 372 | return [{'value': tz, 'text': tz} for tz in l] 373 | 374 | def validate_tz(l): 375 | return [tz for tz in l if tz in pytz.all_timezones] 376 | 377 | timezones = field.get('timezones') 378 | if timezones == 'all': 379 | return to_options(pytz.all_timezones) 380 | elif isinstance(timezones, list): 381 | return to_options(validate_tz(timezones)) 382 | 383 | return to_options(pytz.common_timezones) 384 | 385 | 386 | @helper 387 | def scheming_display_json_value(value, indent=2): 388 | """ 389 | Returns the object passed serialized as a JSON string. 390 | 391 | :param value: The object to serialize. 392 | :param indent: Indentation level to pass through to json.dumps(). 393 | :returns: The serialized object, or the original value if it could not be 394 | serialized. 395 | :rtype: string 396 | """ 397 | if isinstance(value, six.string_types): 398 | return value 399 | try: 400 | return json.dumps(value, indent=indent, sort_keys=True) 401 | except (TypeError, ValueError): 402 | return value 403 | 404 | 405 | @helper 406 | def scheming_render_from_string(source, **kwargs): 407 | # Temporary solution for rendering defaults and including the CKAN 408 | # helpers. The core CKAN lib does not include a string rendering 409 | # utility that works across 2.6-2.8. 410 | env = Environment(autoescape=True) 411 | template = env.from_string( 412 | source, 413 | globals={ 414 | 'h': h 415 | } 416 | ) 417 | 418 | return template.render(**kwargs) 419 | 420 | 421 | @helper 422 | def scheming_flatten_subfield(subfield, data): 423 | """ 424 | Return flattened_data that converts all nested data for this subfield 425 | into {field_name}-{index}-{subfield_name} values at the top level, 426 | so that it matches the names of form fields submitted. 427 | 428 | If data already contains flattened subfields (e.g. rendering values 429 | after a validation error) then they are returned as-is. 430 | """ 431 | flat = dict(data) 432 | 433 | if subfield['field_name'] not in data: 434 | return flat 435 | 436 | for i, record in enumerate(data[subfield['field_name']]): 437 | prefix = '{field_name}-{index}-'.format( 438 | field_name=subfield['field_name'], 439 | index=i, 440 | ) 441 | for k in record: 442 | flat[prefix + k] = record[k] 443 | return flat 444 | 445 | 446 | @helper 447 | def scheming_missing_required_fields(pages, data=None, package_id=None): 448 | if package_id: 449 | try: 450 | data = LocalCKAN().action.package_show(id=package_id) 451 | except (NotFound, NotAuthorized): 452 | pass 453 | if data is None: 454 | data = {} 455 | missing = [] 456 | for p in pages: 457 | missing.append([ 458 | f['field_name'] for f in p['fields'] 459 | if f.get('required') and not data.get(f['field_name']) 460 | ]) 461 | return missing 462 | -------------------------------------------------------------------------------- /ckanext/scheming/loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Load either yaml or json, based on the name of the resource 3 | """ 4 | 5 | import json 6 | 7 | def load(f): 8 | if is_yaml(f.name): 9 | import yaml 10 | return yaml.safe_load(f) 11 | return json.load(f) 12 | 13 | def loads(s, url): 14 | if is_yaml(url): 15 | import yaml 16 | return yaml.safe_load(s) 17 | return json.loads(s) 18 | 19 | def is_yaml(n): 20 | return n.lower().endswith(('.yaml', '.yml')) 21 | -------------------------------------------------------------------------------- /ckanext/scheming/logic.py: -------------------------------------------------------------------------------- 1 | from ckan.plugins.toolkit import get_or_bust, side_effect_free, ObjectNotFound 2 | 3 | from ckanext.scheming.helpers import ( 4 | scheming_dataset_schemas, scheming_get_dataset_schema, 5 | scheming_group_schemas, scheming_get_group_schema, 6 | scheming_organization_schemas, scheming_get_organization_schema, 7 | ) 8 | 9 | @side_effect_free 10 | def scheming_dataset_schema_list(context, data_dict): 11 | ''' 12 | Return a list of dataset types customized with the scheming extension 13 | ''' 14 | return list(scheming_dataset_schemas()) 15 | 16 | @side_effect_free 17 | def scheming_dataset_schema_show(context, data_dict): 18 | ''' 19 | Return the scheming schema for a given dataset type 20 | 21 | :param type: the dataset type 22 | :param expanded: True to expand presets (default) 23 | ''' 24 | t = get_or_bust(data_dict, 'type') 25 | expanded = data_dict.get('expanded', True) 26 | s = scheming_get_dataset_schema(t, expanded) 27 | if s is None: 28 | raise ObjectNotFound() 29 | return s 30 | 31 | @side_effect_free 32 | def scheming_group_schema_list(context, data_dict): 33 | ''' 34 | Return a list of group types customized with the scheming extension 35 | ''' 36 | return list(scheming_group_schemas()) 37 | 38 | @side_effect_free 39 | def scheming_group_schema_show(context, data_dict): 40 | ''' 41 | Return the scheming schema for a given group type 42 | 43 | :param type: the group type 44 | :param expanded: True to expand presets (default) 45 | ''' 46 | t = get_or_bust(data_dict, 'type') 47 | expanded = data_dict.get('expanded', True) 48 | s = scheming_get_group_schema(t, expanded) 49 | if s is None: 50 | raise ObjectNotFound() 51 | return s 52 | 53 | 54 | @side_effect_free 55 | def scheming_organization_schema_list(context, data_dict): 56 | ''' 57 | Return a list of organization types customized with the scheming extension 58 | ''' 59 | return list(scheming_organization_schemas()) 60 | 61 | @side_effect_free 62 | def scheming_organization_schema_show(context, data_dict): 63 | ''' 64 | Return the scheming schema for a given organization type 65 | 66 | :param type: the organization type 67 | :param expanded: True to expand presets (default) 68 | ''' 69 | t = get_or_bust(data_dict, 'type') 70 | expanded = data_dict.get('expanded', True) 71 | s = scheming_get_organization_schema(t, expanded) 72 | if s is None: 73 | raise ObjectNotFound() 74 | return s 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /ckanext/scheming/org_with_dept_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "organization_type": "organization", 3 | "about_url": "http://github.com/ckan/ckanext-scheming", 4 | "fields": [ 5 | { 6 | "field_name": "title", 7 | "label": "Name", 8 | "validators": "ignore_missing unicode_safe", 9 | "form_snippet": "large_text.html", 10 | "form_attrs": {"data-module": "slug-preview-target"}, 11 | "form_placeholder": "My Organization" 12 | }, 13 | { 14 | "field_name": "name", 15 | "label": "URL", 16 | "validators": "not_empty unicode_safe name_validator group_name_validator", 17 | "form_snippet": "slug.html", 18 | "form_placeholder": "my-organization" 19 | }, 20 | { 21 | "field_name": "notes", 22 | "label": "Description", 23 | "form_snippet": "markdown.html", 24 | "form_placeholder": "A little information about my organization..." 25 | }, 26 | { 27 | "field_name": "url", 28 | "label": "Image URL", 29 | "form_placeholder": "http://example.com/my-image.jpg" 30 | }, 31 | { 32 | "field_name": "department_id", 33 | "label": "Department ID", 34 | "form_placeholder": "e.g. 1042" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /ckanext/scheming/presets.json: -------------------------------------------------------------------------------- 1 | { 2 | "scheming_presets_version": 1, 3 | "about": "these are the default scheming field presets", 4 | "about_url": "http://github.com/ckan/ckanext-scheming#preset", 5 | "presets": [ 6 | { 7 | "preset_name": "title", 8 | "values": { 9 | "validators": "if_empty_same_as(name) unicode_safe", 10 | "form_snippet": "large_text.html", 11 | "form_attrs": { 12 | "data-module": "slug-preview-target" 13 | } 14 | } 15 | }, 16 | { 17 | "preset_name": "dataset_slug", 18 | "values": { 19 | "validators": "not_empty unicode_safe name_validator package_name_validator", 20 | "form_snippet": "slug.html" 21 | } 22 | }, 23 | { 24 | "preset_name": "tag_string_autocomplete", 25 | "values": { 26 | "validators": "ignore_missing tag_string_convert", 27 | "classes": ["control-full"], 28 | "form_attrs": { 29 | "data-module": "autocomplete", 30 | "data-module-tags": "", 31 | "data-module-source": "/api/2/util/tag/autocomplete?incomplete=?", 32 | "class": "" 33 | } 34 | } 35 | }, 36 | { 37 | "preset_name": "dataset_organization", 38 | "values": { 39 | "validators": "owner_org_validator unicode_safe", 40 | "form_snippet": "organization.html" 41 | } 42 | }, 43 | { 44 | "preset_name": "resource_url_upload", 45 | "values": { 46 | "validators": "ignore_missing unicode_safe remove_whitespace", 47 | "form_snippet": "upload.html", 48 | "form_placeholder": "http://example.com/my-data.csv", 49 | "upload_field": "upload", 50 | "upload_clear": "clear_upload", 51 | "upload_label": "File" 52 | } 53 | }, 54 | { 55 | "preset_name": "organization_url_upload", 56 | "values": { 57 | "validators": "ignore_missing unicode_safe remove_whitespace", 58 | "form_snippet": "organization_upload.html", 59 | "form_placeholder": "http://example.com/my-data.csv" 60 | } 61 | }, 62 | { 63 | "preset_name": "resource_format_autocomplete", 64 | "values": { 65 | "validators": "if_empty_guess_format ignore_missing clean_format unicode_safe", 66 | "form_placeholder": "eg. CSV, XML or JSON", 67 | "form_attrs": { 68 | "data-module": "autocomplete", 69 | "data-module-source": "/api/2/util/resource/format_autocomplete?incomplete=?" 70 | } 71 | } 72 | }, 73 | { 74 | "preset_name": "select", 75 | "values": { 76 | "form_snippet": "select.html", 77 | "display_snippet": "select.html", 78 | "validators": "scheming_required scheming_choices" 79 | } 80 | }, 81 | { 82 | "preset_name": "multiple_checkbox", 83 | "values": { 84 | "form_snippet": "multiple_checkbox.html", 85 | "display_snippet": "multiple_choice.html", 86 | "validators": "scheming_multiple_choice", 87 | "output_validators": "scheming_multiple_choice_output" 88 | } 89 | }, 90 | { 91 | "preset_name": "multiple_select", 92 | "values": { 93 | "form_snippet": "multiple_select.html", 94 | "display_snippet": "multiple_choice.html", 95 | "validators": "scheming_multiple_choice", 96 | "output_validators": "scheming_multiple_choice_output" 97 | } 98 | }, 99 | { 100 | "preset_name": "date", 101 | "values": { 102 | "form_snippet": "date.html", 103 | "display_snippet": "date.html", 104 | "validators": "scheming_required isodate convert_to_json_if_date" 105 | } 106 | }, 107 | { 108 | "preset_name": "datetime", 109 | "values": { 110 | "form_snippet": "datetime.html", 111 | "display_snippet": "datetime.html", 112 | "validators": "scheming_isodatetime convert_to_json_if_datetime" 113 | } 114 | }, 115 | { 116 | "preset_name": "datetime_tz", 117 | "values": { 118 | "form_snippet": "datetime_tz.html", 119 | "display_snippet": "datetime_tz.html", 120 | "validators": "scheming_isodatetime_tz convert_to_json_if_datetime" 121 | } 122 | }, 123 | { 124 | "preset_name": "json_object", 125 | "values": { 126 | "validators": "scheming_required scheming_valid_json_object", 127 | "output_validators": "scheming_load_json", 128 | "form_snippet": "json.html", 129 | "display_snippet": "json.html" 130 | } 131 | }, 132 | { 133 | "preset_name": "multiple_text", 134 | "values": { 135 | "form_snippet": "multiple_text.html", 136 | "display_snippet": "multiple_text.html", 137 | "validators": "scheming_multiple_text", 138 | "output_validators": "scheming_load_json" 139 | } 140 | }, 141 | { 142 | "preset_name": "markdown", 143 | "values": { 144 | "form_snippet": "markdown.html", 145 | "display_snippet": "markdown.html" 146 | } 147 | }, 148 | { 149 | "preset_name": "radio", 150 | "values": { 151 | "form_snippet": "radio.html", 152 | "display_snippet": "select.html", 153 | "validators": "scheming_required scheming_choices" 154 | } 155 | } 156 | ] 157 | } 158 | -------------------------------------------------------------------------------- /ckanext/scheming/subfields.yaml: -------------------------------------------------------------------------------- 1 | dataset_type: subfields 2 | about: Example dataset schema with simple and repeating subfields 3 | about_url: https://github.com/ckan/ckanext-scheming 4 | 5 | 6 | dataset_fields: 7 | 8 | - field_name: title 9 | label: Title 10 | preset: title 11 | form_placeholder: eg. A descriptive title 12 | required: True 13 | 14 | - field_name: name 15 | label: URL 16 | preset: dataset_slug 17 | form_placeholder: eg. my-dataset 18 | 19 | - field_name: notes 20 | label: Description 21 | form_snippet: markdown.html 22 | form_placeholder: eg. Some useful notes about the data 23 | required: True 24 | 25 | - field_name: owner_org 26 | label: Organization 27 | preset: dataset_organization 28 | 29 | - field_name: license_id 30 | label: License 31 | form_snippet: license.html 32 | help_text: License definitions and additional information can be found at http://opendefinition.org/ 33 | 34 | - field_name: citation 35 | label: Citation 36 | repeating_subfields: 37 | - field_name: originator 38 | label: Originator 39 | preset: multiple_text 40 | form_blanks: 3 41 | required: true 42 | - field_name: publication_date 43 | label: Publication Date 44 | preset: date 45 | - field_name: online_linkage 46 | label: Online Linkage 47 | preset: multiple_text 48 | form_blanks: 2 49 | 50 | - field_name: contact_address 51 | label: Contact Address 52 | repeating_subfields: 53 | - field_name: address 54 | label: Address 55 | required: true 56 | - field_name: city 57 | label: City 58 | - field_name: state 59 | label: State 60 | - field_name: postal_code 61 | label: Postal Code 62 | - field_name: country 63 | label: Country 64 | 65 | 66 | resource_fields: 67 | 68 | - field_name: url 69 | label: URL 70 | preset: resource_url_upload 71 | 72 | - field_name: name 73 | label: Title 74 | form_placeholder: Descriptive name of the resource. 75 | 76 | - field_name: description 77 | label: Description 78 | form_snippet: markdown.html 79 | form_placeholder: Summary explanation of file contents, purpose, origination, methods and usage guidance. 80 | 81 | - field_name: schedule 82 | label: Schedule 83 | repeating_subfields: 84 | - field_name: impact 85 | label: Impact 86 | preset: select 87 | choices: 88 | - label: All 89 | value: A 90 | - label: Partial 91 | value: P 92 | - label: Corrections 93 | value: C 94 | required: true 95 | - field_name: frequency 96 | label: Frequency 97 | preset: select 98 | choices: 99 | - label: Daily 100 | value: 1d 101 | - label: Weekly 102 | value: 7d 103 | - label: Monthly 104 | value: 1m 105 | - label: Quarterly 106 | value: 3m 107 | - label: Semiannual 108 | value: 6m 109 | - label: Annual 110 | value: 1y 111 | - label: Decennial 112 | value: 10y 113 | 114 | - field_name: format 115 | label: Format 116 | preset: resource_format_autocomplete 117 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/base.html: -------------------------------------------------------------------------------- 1 | {% ckan_extends %} 2 | 3 | {% block styles %} 4 | {{ super() }} 5 | {% include 'scheming/snippets/scheming_asset.html' %} 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/organization/edit_base.html: -------------------------------------------------------------------------------- 1 | {% ckan_extends %} 2 | 3 | {% block styles %} 4 | {{ super() }} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/date.html: -------------------------------------------------------------------------------- 1 | {% if data[field.field_name] %} 2 | {{ data[field.field_name].split()[0] }} 3 | {% endif %} 4 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/datetime.html: -------------------------------------------------------------------------------- 1 | {% extends 'scheming/display_snippets/text.html' %} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/datetime_tz.html: -------------------------------------------------------------------------------- 1 | {{ h.render_datetime(data[field.field_name], date_format='%Y-%m-%d %H:%M %Z') }} -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/email.html: -------------------------------------------------------------------------------- 1 | {{ h.mail_to(email_address=data[field.field_name], 2 | name=data[field.display_email_name_field] if field.display_email_name_field and data[field.display_email_name_field] 3 | else data[field.field_name]) }} 4 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/file_size.html: -------------------------------------------------------------------------------- 1 | {{ h.localised_filesize(data[field.field_name]) }} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/json.html: -------------------------------------------------------------------------------- 1 | {% if data[field.field_name] %}{{ h.scheming_display_json_value(data[field.field_name], indent=field.get('indent', 2)) }}{% endif %} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/link.html: -------------------------------------------------------------------------------- 1 | {{ h.link_to(data[field.field_name], data[field.field_name], rel=field.display_property, target='_blank') }} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/markdown.html: -------------------------------------------------------------------------------- 1 | {{ h.render_markdown(data[field.field_name]) }} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/multiple_choice.html: -------------------------------------------------------------------------------- 1 | {%- set values = data[field.field_name] -%} 2 | {%- set labels = [] -%} 3 | 4 | {%- for choice in h.scheming_field_choices(field) -%} 5 | {%- if choice.value in values -%} 6 | {%- do labels.append(h.scheming_language_text(choice.label)) -%} 7 | {%- endif -%} 8 | {%- endfor -%} 9 | 10 | {%- if labels|length == 1 -%} 11 | {{ labels[0] }} 12 | {%- else -%} 13 | {%- if field.get('sorted_choices') -%} 14 | {%- set labels = labels|sort -%} 15 | {%- endif -%} 16 | 21 | {%- endif -%} 22 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/multiple_text.html: -------------------------------------------------------------------------------- 1 | {%- set values = data[field.field_name] -%} 2 |
    3 | {%- for element in values -%} 4 |
  1. {{ element }}
  2. 5 | {%- endfor -%} 6 |
7 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/repeating_subfields.html: -------------------------------------------------------------------------------- 1 | {% set fields = data[field.field_name] %} 2 | 3 | {% block subfield_display %} 4 | {% for field_data in fields %} 5 |
6 |
7 | {{ h.scheming_language_text(field.repeating_label or field.label) }} {{ loop.index }} 8 |
9 |
10 |
11 | {% for subfield in field.repeating_subfields %} 12 |
13 | {{ h.scheming_language_text(subfield.label) }} 14 |
15 |
16 | {%- snippet 'scheming/snippets/display_field.html', 17 | field=subfield, 18 | data=field_data, 19 | entity_type=entity_type, 20 | object_type=object_type 21 | -%} 22 |
23 | {% endfor %} 24 |
25 |
26 |
27 | {% endfor %} 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/select.html: -------------------------------------------------------------------------------- 1 | {{ h.scheming_choices_label( 2 | h.scheming_field_choices(field), 3 | data[field.field_name]) }} 4 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/display_snippets/text.html: -------------------------------------------------------------------------------- 1 | {{ data[field.field_name] }} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/_organization_select.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {# this snippet is meant to be called from organization.html, 4 | not used as a form_snippet directly #} 5 | 6 | {% macro _organization() %} 7 | {% set existing_org = data.owner_org or data.group_id %} 8 | {% call form.input_block('field-organizations', 9 | label=h.scheming_language_text(field.label), 10 | error=errors[field.field_name], 11 | is_required=org_required, 12 | classes=field.classes if 'classes' in field else ['form-group', 'control-medium'], 13 | extra_html=caller() if caller, 14 | ) %} 15 |
17 | 28 |
29 | {% endcall %} 30 | {% endmacro %} 31 | 32 | {% call _organization() %} 33 | {%- snippet 'scheming/form_snippets/help_text.html', field=field %} 34 | {% endcall %} 35 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/date.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | {% call form.input( 3 | field.field_name, 4 | id='field-' + field.field_name, 5 | label=h.scheming_language_text(field.label), 6 | placeholder=h.scheming_language_text(field.form_placeholder), 7 | type='date', 8 | value=(data.get(field.field_name) or '').split()[0], 9 | error=errors[field.field_name], 10 | classes=field.classes if 'classes' in field else ['control-medium'], 11 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 12 | is_required=h.scheming_field_required(field) 13 | ) 14 | %} 15 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 16 | {% endcall %} 17 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/datetime.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | {% set date = data.get(field.field_name + '_date') %} 3 | {% set time = data.get(field.field_name + '_time') %} 4 | 5 | {% if not date is string and not time is string %} 6 | {% set date = data.get(field.field_name) %} 7 | 8 | {% if date %} 9 | {% set parts = data.get(field.field_name).split() %} 10 | {% set date = parts[0] %} 11 | {% set time = parts[1] %} 12 | {% endif %} 13 | {% endif %} 14 | {% call form.input( 15 | field.field_name + '_date', 16 | id='field-' + field.field_name + '-date', 17 | label= h.scheming_language_text(field.label), 18 | type='date', 19 | value=date, 20 | error=errors[field.field_name + '_date'], 21 | classes=field.classes if 'classes' in field else ['control-medium'], 22 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 23 | is_required=h.scheming_field_required(field) 24 | ) 25 | %} 26 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 27 | {% endcall %} 28 | 29 | {% call form.input( 30 | field.field_name + '_time', 31 | id='field-' + field.field_name + '-time', 32 | label= h.scheming_language_text(field.get('label_time', 'Time')), 33 | type='time', 34 | value=time, 35 | error=errors[field.field_name + '_time'], 36 | classes=field.classes if 'classes' in field else ['control-medium'], 37 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 38 | is_required=h.scheming_field_required(field) 39 | ) 40 | %} 41 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 42 | {% endcall %} 43 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/datetime_tz.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | {% set date = data.get(field.field_name + '_date') %} 3 | {% set time = data.get(field.field_name + '_time') %} 4 | {% set tz = data.get(field.field_name + '_tz', h.get_display_timezone().zone) %} 5 | 6 | 7 | {% if not date is string and not time is string %} 8 | {% set date = data.get(field.field_name) %} 9 | 10 | {% if date %} 11 | {% set tz = h.get_display_timezone().zone %} 12 | {% set localdate = h.scheming_datetime_to_tz(h.date_str_to_datetime(date), tz).isoformat() %} 13 | 14 | {% set parts = localdate.split('T') %} 15 | {% set date = parts[0] %} 16 | {% set time = parts[1] %} 17 | {% endif %} 18 | {% endif %} 19 | {% call form.input( 20 | field.field_name + '_date', 21 | id='field-' + field.field_name + '-date', 22 | label= h.scheming_language_text(field.label), 23 | type='date', 24 | value=date, 25 | error=errors[field.field_name + '_date'], 26 | classes=field.classes if 'classes' in field else ['control-medium'], 27 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 28 | is_required=h.scheming_field_required(field) 29 | ) 30 | %} 31 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 32 | {% endcall %} 33 | 34 | {% call form.input( 35 | field.field_name + '_time', 36 | id='field-' + field.field_name + '-time', 37 | label= h.scheming_language_text(field.get('label_time', 'Time')), 38 | type='time', 39 | value=time, 40 | error=errors[field.field_name + '_time'], 41 | classes=field.classes if 'classes' in field else ['control-medium'], 42 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 43 | is_required=h.scheming_field_required(field) 44 | ) 45 | %} 46 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 47 | {% endcall %} 48 | 49 | {% set tz_list = h.scheming_get_timezones(field) %} 50 | {% call form.select( 51 | field.field_name + '_tz', 52 | id='field-' + field.field_name + '-tz', 53 | label=h.scheming_language_text(field.get('label_tz', 'Timezone')), 54 | options=tz_list, 55 | selected=tz, 56 | error=errors[field.field_name + '_tz'], 57 | classes=field.classes if 'classes' in field else ['control-medium'], 58 | attrs=dict({"class": "form-control form-select"}, **(field.get('form_attrs', {}))), 59 | is_required=h.scheming_field_required(field) 60 | ) 61 | %} 62 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 63 | {% endcall %} 64 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/help_text.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {%- if field.help_text -%} 4 | {% set text = h.scheming_language_text(field.help_text) %} 5 | {{- form.info( 6 | text=text|safe if field.get('help_allow_html', false) else text, 7 | inline=field.get('help_inline', false) 8 | ) -}} 9 | {%- endif -%} 10 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/json.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | {% set value = data[field.field_name] %} 3 | {% call form.textarea( 4 | field.field_name, 5 | id='field-' + field.field_name, 6 | label=h.scheming_language_text(field.label), 7 | placeholder=h.scheming_language_text(field.form_placeholder), 8 | value=h.scheming_display_json_value(value, indent=field.get('indent', 2)) if value else None, 9 | error=errors[field.field_name], 10 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 11 | is_required=h.scheming_field_required(field), 12 | ) 13 | %} 14 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 15 | {% endcall %} 16 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/large_text.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {% call form.input( 4 | field.field_name, 5 | id='field-' + field.field_name, 6 | label=h.scheming_language_text(field.label), 7 | placeholder=h.scheming_language_text(field.form_placeholder), 8 | value=data[field.field_name], 9 | error=errors[field.field_name], 10 | classes=field.classes if 'classes' in field else ['control-full', 'control-large'], 11 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 12 | is_required=h.scheming_field_required(field) 13 | ) 14 | %} 15 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 16 | {% endcall %} 17 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/license.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {%- set options=[] -%} 4 | {%- if field.get('form_include_blank_choice', false) -%} 5 | {%- do options.append({ 6 | 'value': '', 7 | 'text': ''}) -%} 8 | {%- endif -%} 9 | {%- if field.get('sorted_choices', true) -%} 10 | {%- set licenses = licenses|sort -%} 11 | {%- endif -%} 12 | {%- for license_desc, license_id in licenses -%} 13 | {%- if license_id or not h.scheming_field_required(field) -%} 14 | {%- do options.append({ 15 | 'value': license_id, 16 | 'text': license_desc}) -%} 17 | {%- endif -%} 18 | {%- endfor -%} 19 | 20 | {% call form.select( 21 | field.field_name, 22 | id='field-' + field.field_name, 23 | label=h.scheming_language_text(field.label), 24 | options=options, 25 | selected=data.get(field.field_name, field.get('default', 'notspecified')), 26 | error=errors[field.field_name], 27 | classes=field.classes if 'classes' in field else ['control-medium'], 28 | attrs=field.form_attrs if 'form_attrs' in field else { 29 | "data-module": "autocomplete" 30 | }, 31 | is_required=h.scheming_field_required(field), 32 | ) 33 | %} 34 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 35 | {% endcall %} 36 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/markdown.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {% call form.markdown( 4 | field.field_name, 5 | id='field-' + field.field_name, 6 | label=h.scheming_language_text(field.label), 7 | placeholder=h.scheming_language_text(field.form_placeholder), 8 | value=data[field.field_name], 9 | error=errors[field.field_name], 10 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 11 | is_required=h.scheming_field_required(field) 12 | ) 13 | %} 14 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 15 | {% endcall %} 16 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/multiple_checkbox.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | 16 | 17 | {%- call form.input_block( 18 | label=h.scheming_language_text(field.label), 19 | classes=field.classes if 'classes' in field else ['control-medium'], 20 | error=errors[field.field_name], 21 | is_required=h.scheming_field_required(field)) -%} 22 | {%- set choices = [] -%} 23 | {%- for c in h.scheming_field_choices(field) -%} 24 | {%- do choices.append( 25 | (c.value, h.scheming_language_text(c.label))) -%} 26 | {%- endfor -%} 27 | {%- if field.get('sorted_choices') -%} 28 | {%- set choices = choices|sort(case_sensitive=false, attribute=1) -%} 29 | {%- endif -%} 30 |
31 | {%- for val, label in choices -%} 32 | 40 | {%- endfor -%} 41 |
42 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 43 | {%- endcall -%} 44 | 45 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/multiple_select.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {% macro help_text() %} 4 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 5 | {% endmacro %} 6 | 7 | {%- call form.input_block( 8 | "field-" + field.field_name, 9 | label=h.scheming_language_text(field.label), 10 | classes=field.classes if 'classes' in field else ['control-full'], 11 | error=errors[field.field_name], 12 | is_required=h.scheming_field_required(field), 13 | extra_html=help_text() 14 | ) -%} 15 | {%- set choices = [] -%} 16 | {%- for c in h.scheming_field_choices(field) -%} 17 | {%- do choices.append( 18 | (c.value, h.scheming_language_text(c.label))) -%} 19 | {%- endfor -%} 20 | {%- if field.get('sorted_choices') -%} 21 | {%- set choices = choices|sort(case_sensitive=false, attribute=1) -%} 22 | {%- endif -%} 23 | 38 | {%- endcall -%} 39 | 40 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/multiple_text.html: -------------------------------------------------------------------------------- 1 | {% include 'scheming/snippets/multiple_text_asset.html' %} 2 | 3 | {% import 'macros/form.html' as form %} 4 | 5 | {% macro multiple_text(element, error) %} 6 |
  • 7 | 13 | {% block delete_button_text %}{{ _('Remove') }}{% endblock %} 15 | {% if error and error is iterable %}{{ error|join(', ') }}{% endif %} 16 |
  • 17 | {% endmacro %} 18 | 19 | {%- set values = data.get(field.field_name, []) or ([''] * field.get('form_blanks', 1)) -%} 20 | {%- if values is string -%} 21 | {%- set values = [values] -%} 22 | {%- endif -%} 23 | 24 | {% call form.input_block( 25 | 'field-' + field.field_name, 26 | h.scheming_language_text(field.label) or field.field_name, 27 | "", 28 | field.classes if 'classes' in field else ['control-medium'], 29 | dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 30 | is_required=h.scheming_field_required(field)) %} 31 |
    32 |
      33 | {%- for element in values -%} 34 | {{ multiple_text(element, errors[field.field_name] if loop.index == 1 else "") }} 35 | {%- endfor -%} 36 |
    37 | 38 | {% set help_text = h.scheming_language_text(field.help_text) %} 39 | {% if help_text %} 40 |
    41 | {{ help_text }} 42 |
    43 | {% endif %} 44 | 45 | 49 |
    50 | {% endcall %} 51 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/number.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | {% call form.input( 3 | field.field_name, 4 | id='field-' + field.field_name, 5 | label=h.scheming_language_text(field.label), 6 | placeholder=h.scheming_language_text(field.form_placeholder), 7 | type='number', 8 | value=data.get(field.field_name), 9 | error=errors[field.field_name], 10 | classes=field.classes if 'classes' in field else ['control-medium'], 11 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 12 | is_required=h.scheming_field_required(field) 13 | ) 14 | %} 15 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 16 | {% endcall %} 17 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/organization.html: -------------------------------------------------------------------------------- 1 | {# This is specific to datasets' owner_org field and won't work #} 2 | {# if used with other fields #} 3 | 4 | 5 | {% macro organization_option_tag(organization, selected_org) %} 6 | {% block organization_option scoped %} 7 | 10 | {% endblock %} 11 | {% endmacro %} 12 | 13 |
    14 | {% snippet "scheming/form_snippets/_organization_select.html", 15 | field=field, 16 | data=data, 17 | errors=errors, 18 | organizations_available=h.organizations_available('create_dataset'), 19 | org_required=not h.check_config_permission('create_unowned_dataset') 20 | or h.scheming_field_required(field), 21 | organization_option_tag=organization_option_tag %} 22 | 23 | {% block package_metadata_fields_visibility %} 24 |
    25 | 26 |
    27 | 32 |
    33 |
    34 | {% endblock %} 35 | 36 |
    37 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/organization_upload.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {% set is_upload = data.image_url and not data.image_url.startswith('http') %} 4 | {% set is_url = data.image_url and data.image_url.startswith('http') %} 5 | 6 | {{ form.image_upload( 7 | data, 8 | errors, 9 | is_upload_enabled=h.uploads_enabled(), 10 | is_url=is_url, 11 | is_upload=is_upload 12 | ) 13 | }} 14 | 15 | {# image_upload macro doesn't support call #} 16 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 17 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/radio.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {%- set options=[] -%} 4 | {%- set form_restrict_choices_to=field.get('form_restrict_choices_to') -%} 5 | {%- if not h.scheming_field_required(field) or 6 | field.get('form_include_blank_choice', false) -%} 7 | {%- do options.append({'value': '', 'text': ''}) -%} 8 | {%- endif -%} 9 | {%- for c in h.scheming_field_choices(field) -%} 10 | {%- if not form_restrict_choices_to or c.value in form_restrict_choices_to -%} 11 | {%- do options.append({ 12 | 'value': c.value|string, 13 | 'text': h.scheming_language_text(c.label) }) -%} 14 | {%- endif -%} 15 | {%- endfor -%} 16 | {%- if field.get('sorted_choices') -%} 17 | {%- set options = options|sort(case_sensitive=false, attribute='text') -%} 18 | {%- endif -%} 19 | {%- if data[field.field_name] is defined -%} 20 | {%- set option_selected = data[field.field_name]|string -%} 21 | {%- else -%} 22 | {%- set option_selected = None -%} 23 | {%- endif -%} 24 | 25 | {%- call form.input_block( 26 | label=h.scheming_language_text(field.label), 27 | classes=field.classes if 'classes' in field else ['control-medium'], 28 | error=errors[field.field_name], 29 | is_required=h.scheming_field_required(field)) -%} 30 |
    31 | {%- for c in field.choices -%} 32 |
    33 | 41 |
    42 | {%- endfor -%} 43 |
    44 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 45 | {%- endcall -%} 46 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/repeating_subfields.html: -------------------------------------------------------------------------------- 1 | {# A complex field with repeating sub-fields #} 2 | 3 | {% include 'scheming/snippets/subfields_asset.html' %} 4 | {% import 'macros/form.html' as form %} 5 | 6 | {% macro repeating_panel(index, index1) %} 7 |
    8 |
    9 | {% block repeating_panel_header %} 10 |
    11 | {% block field_removal_button%} 12 | {% block delete_button_text %}{{ _('Remove') }}{% endblock %} 14 | {% endblock %} 15 | {{ h.scheming_language_text(field.repeating_label or field.label) }} {{ index1 }} 16 |
    17 | {% endblock %} 18 |
    19 | {% for subfield in field.repeating_subfields %} 20 | {% set sf = dict( 21 | subfield, 22 | field_name=field.field_name ~ '-' ~ index ~ '-' ~ subfield.field_name) 23 | %} 24 | {%- snippet 'scheming/snippets/form_field.html', 25 | field=sf, 26 | data=flat, 27 | errors=flaterr, 28 | licenses=licenses, 29 | entity_type=entity_type, 30 | object_type=object_type 31 | -%} 32 | {% endfor %} 33 |
    34 | 43 |
    44 |
    45 | {% endmacro %} 46 | 47 | {% set flat = h.scheming_flatten_subfield(field, data) %} 48 | {% set flaterr = h.scheming_flatten_subfield(field, errors) %} 49 | 50 | {% call form.input_block( 51 | 'field-' + field.field_name, 52 | h.scheming_language_text(field.label) or field.field_name, 53 | [], 54 | field.classes if 'classes' in field else ['control-medium'], 55 | is_required=h.scheming_field_required(field)) %} 56 |
    57 |
    58 | {% set alert_warning = h.scheming_language_text(field.form_alert_warning) %} 59 | {% if alert_warning %} 60 |
    61 | {{ alert_warning|safe }} 62 |
    63 | {% endif %} 64 | 65 | {%- set group_data = data[field.field_name] -%} 66 | {%- set group_count = group_data|length -%} 67 | {%- if not group_count and 'id' not in data -%} 68 | {%- set group_count = field.form_blanks|default(1) -%} 69 | {%- endif -%} 70 | 71 |
    72 | {% for index in range(group_count) %} 73 | {{ repeating_panel(index, index + 1) }} 74 | {% endfor %} 75 |
    76 |
    77 | {% block add_button %}{% block add_button_text %} {{ _('Add') }}{% endblock %}{% endblock %} 79 | 80 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 81 |
    82 | 83 |
    {{ repeating_panel('REPEATING-INDEX0', 'REPEATING-INDEX1') }}
    84 |
    85 |
    86 | {% endcall %} 87 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/select.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {%- set options=[] -%} 4 | {%- set form_restrict_choices_to=field.get('form_restrict_choices_to') -%} 5 | {%- if not h.scheming_field_required(field) or 6 | field.get('form_include_blank_choice', false) -%} 7 | {%- do options.append({'value': '', 'text': ''}) -%} 8 | {%- endif -%} 9 | {%- for c in h.scheming_field_choices(field) -%} 10 | {%- if not form_restrict_choices_to or c.value in form_restrict_choices_to -%} 11 | {%- do options.append({ 12 | 'value': c.value|string, 13 | 'text': h.scheming_language_text(c.label) }) -%} 14 | {%- endif -%} 15 | {%- endfor -%} 16 | {%- if field.get('sorted_choices') -%} 17 | {%- set options = options|sort(case_sensitive=false, attribute='text') -%} 18 | {%- endif -%} 19 | {%- if data[field.field_name] is defined -%} 20 | {%- set option_selected = data[field.field_name]|string -%} 21 | {%- elif field.default is defined -%} 22 | {%- set option_selected = field.default|string -%} 23 | {%- else -%} 24 | {%- set option_selected = None -%} 25 | {%- endif -%} 26 | 27 | {% call form.select( 28 | field.field_name, 29 | id='field-' + field.field_name, 30 | label=h.scheming_language_text(field.label), 31 | options=options, 32 | selected=option_selected, 33 | error=errors[field.field_name], 34 | classes=field.classes if 'classes' in field else ['control-medium'], 35 | attrs=dict({"class": "form-control form-select"}, **(field.get('form_attrs', {}))), 36 | is_required=h.scheming_field_required(field) 37 | ) 38 | %} 39 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 40 | {% endcall %} 41 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/slug.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {% set read_endpoint = '.read' if h.check_ckan_version(min_version="2.9") else '_read' %} 4 | 5 | {%- if entity_type == 'dataset' %} 6 | {%- set controller = 'package' -%} 7 | {%- elif entity_type == 'organization' %} 8 | {%- set controller = 'organization' -%} 9 | {%- elif entity_type == 'group' -%} 10 | {%- set controller = 'group' -%} 11 | {%- endif -%} 12 | 13 | {%- set module_placeholder = '<' + object_type + '>' -%} 14 | 15 | {%- set prefix = h.url_for(object_type + read_endpoint, id='') -%} 16 | {%- set domain = h.url_for(object_type + read_endpoint, id='', qualified=true) -%} 17 | {%- set domain = domain|replace("http://", "")|replace("https://", "") -%} 18 | {%- set attrs = { 19 | 'data-module': 'slug-preview-slug', 20 | 'data-module-prefix': domain, 21 | 'data-module-placeholder': module_placeholder } -%} 22 | 23 | {{ form.prepend( 24 | field.field_name, 25 | id='field-' + field.field_name, 26 | label=h.scheming_language_text(field.label), 27 | prepend=prefix, 28 | placeholder=h.scheming_language_text(field.form_placeholder), 29 | value=data[field.field_name], 30 | error=errors[field.field_name], 31 | attrs=attrs, 32 | is_required=h.scheming_field_required(field) 33 | ) }} 34 | 35 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 36 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/text.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {% call form.input( 4 | field.field_name, 5 | id='field-' + field.field_name, 6 | label=h.scheming_language_text(field.label), 7 | placeholder=h.scheming_language_text(field.form_placeholder), 8 | value=data[field.field_name], 9 | error=errors[field.field_name], 10 | classes=field.classes if 'classes' in field else ['control-medium'], 11 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 12 | is_required=h.scheming_field_required(field) 13 | ) 14 | %} 15 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 16 | {% endcall %} 17 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/textarea.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {% call form.textarea( 4 | field.field_name, 5 | id='field-' + field.field_name, 6 | label=h.scheming_language_text(field.label), 7 | placeholder=h.scheming_language_text(field.form_placeholder), 8 | value=data[field.field_name], 9 | error=errors[field.field_name], 10 | attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), 11 | is_required=h.scheming_field_required(field), 12 | ) 13 | %} 14 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 15 | {% endcall %} 16 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/form_snippets/upload.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | 3 | {%- set is_upload = (data.url_type == 'upload') -%} 4 | {{ form.image_upload( 5 | data, 6 | errors, 7 | field_url=field.field_name, 8 | field_upload=field.upload_field, 9 | field_clear=field.upload_clear, 10 | is_upload_enabled=h.uploads_enabled(), 11 | is_url=data[field.field_name] and not is_upload, 12 | is_upload=is_upload, 13 | upload_label=h.scheming_language_text(field.upload_label), 14 | url_label=h.scheming_language_text(field.label), 15 | placeholder=field.form_placeholder 16 | ) 17 | }} 18 | {# image_upload macro doesn't support call #} 19 | {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} 20 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/group/about.html: -------------------------------------------------------------------------------- 1 | {% extends "group/about.html" %} 2 | 3 | {% if group_dict is not defined%} 4 | {% set group_dict = c.group_dict %} 5 | {% endif %} 6 | 7 | 8 | {% block primary_content_inner %} 9 |
    10 | {% for f in c.scheming_fields %} 11 |
    {{ h.scheming_language_text(f.label) }}:
    12 |
    {{ group_dict[f.field_name] or (" "|safe) }}
    13 | {% endfor %} 14 |
    15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/group/group_form.html: -------------------------------------------------------------------------------- 1 | {% if group_type is not defined %} 2 | {% set group_type = c.group_type %} 3 | {% endif %} 4 | 5 | {%- if not group_type -%} 6 |

    7 | group_type not passed to template. your version of CKAN 8 | might not be compatible with ckanext-scheming 9 |

    10 | {%- endif -%} 11 | 12 |
    13 | {% block errors %} 14 | {%- if errors -%} 15 | {%- set schema = h.scheming_get_group_schema(group_type) -%} 16 | {%- snippet 'scheming/snippets/errors.html', 17 | errors=errors, fields=schema.fields, 18 | entity_type='group', object_type=group_type -%} 19 | {%- endif -%} 20 | {% endblock %} 21 | {{ h.csrf_input() if 'csrf_input' in h }} 22 | {%- set schema = h.scheming_get_group_schema(group_type) -%} 23 | {%- for field in schema['fields'] -%} 24 | {%- if field.form_snippet is not none -%} 25 | {%- snippet 'scheming/snippets/form_field.html', 26 | field=field, data=data, errors=errors, licenses=licenses, 27 | entity_type='group', object_type=group_type -%} 28 | {%- endif -%} 29 | {%- endfor -%} 30 | 31 |
    32 | {% block delete_button %} 33 | {% if action == 'edit' %} 34 | {% if h.check_access('group_delete', {'id': data.id}) and action=='edit' %} 35 | {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Group?')}) %} 36 | {% block delete_button_text %}{{ _('Delete') }}{% endblock %} 37 | {% endif %} 38 | {% endif %} 39 | {% endblock %} 40 | 57 |
    58 |
    59 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/organization/about.html: -------------------------------------------------------------------------------- 1 | {% extends "organization/about.html" %} 2 | 3 | {% if group_dict is not defined%} 4 | {% set group_dict = c.group_dict %} 5 | {% endif %} 6 | 7 | {% block primary_content_inner %} 8 |
    9 | {% for f in c.scheming_fields %} 10 |
    {{ h.scheming_language_text(f.label) }}:
    11 |
    {{ group_dict[f.field_name] or (" "|safe) }}
    12 | {% endfor %} 13 |
    14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/organization/group_form.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/form.html' as form %} 2 | {% if group_type is not defined %} 3 | {% set group_type = c.group_type %} 4 | {% endif %} 5 | 6 | 7 | {%- if not group_type -%} 8 |

    9 | group_type not passed to template. your version of CKAN 10 | might not be compatible with ckanext-scheming 11 |

    12 | {%- endif -%} 13 | 14 |
    15 | {% block errors %} 16 | {%- if errors -%} 17 | {%- set schema = h.scheming_get_organization_schema(group_type) -%} 18 | {%- snippet 'scheming/snippets/errors.html', 19 | errors=errors, fields=schema.fields, 20 | entity_type='organization', object_type=group_type -%} 21 | {%- endif -%} 22 | {% endblock %} 23 | {{ h.csrf_input() if 'csrf_input' in h }} 24 | {%- set schema = h.scheming_get_organization_schema(group_type) -%} 25 | {%- for field in schema['fields'] -%} 26 | {%- if field.form_snippet is not none -%} 27 | {%- snippet 'scheming/snippets/form_field.html', 28 | field=field, data=data, errors=errors, licenses=licenses, 29 | entity_type='organization', object_type=group_type -%} 30 | {%- endif -%} 31 | {%- endfor -%} 32 | 33 | {{ form.required_message() }} 34 | 35 |
    36 | {% block delete_button %} 37 | {% if action == 'edit' %} 38 | {% if h.check_access('group_delete', {'id': data.id}) and action=='edit' %} 39 | {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Organization?')}) %} 40 | {% block delete_button_text %}{{ _('Delete') }}{% endblock %} 41 | {% endif %} 42 | {% endif %} 43 | {% endblock %} 44 | 61 |
    62 |
    63 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/package/read.html: -------------------------------------------------------------------------------- 1 | {% extends "package/read.html" %} 2 | 3 | {%- set schema = h.scheming_get_dataset_schema(dataset_type) -%} 4 | 5 | {% block package_notes %} 6 | {%- if not dataset_type -%} 7 |

    8 | dataset_type not passed to template. your version of CKAN 9 | might not be compatible with ckanext-scheming 10 |

    11 | {%- endif -%} 12 | {% if (h.scheming_field_by_name(schema.dataset_fields, 'notes') or 13 | h.scheming_field_by_name(schema.dataset_fields, 'notes_translated')) and 14 | pkg.notes%} 15 |
    16 | {{ h.render_markdown(h.get_translated(pkg, 'notes')) }} 17 |
    18 | {% endif %} 19 | {% endblock %} 20 | 21 | {% block package_additional_info %} 22 | {% snippet "scheming/package/snippets/additional_info.html", 23 | pkg_dict=pkg, dataset_type=dataset_type, schema=schema %} 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/package/resource_read.html: -------------------------------------------------------------------------------- 1 | {% extends "package/resource_read.html" %} 2 | 3 | {%- set exclude_fields = [ 4 | 'name', 5 | 'description', 6 | 'url', 7 | 'format', 8 | ] -%} 9 | {%- set schema = h.scheming_get_dataset_schema(dataset_type) -%} 10 | 11 | {% block resource_additional_information_inner %} 12 | {% if res.datastore_active %} 13 | {% block resource_data_dictionary %} 14 | {{ super() }} 15 | {% endblock %} 16 | {% endif %} 17 | 18 |
    19 | {%- block additional_info_heading -%}

    {{ _('Additional Information') }}

    {%- endblock -%} 20 | 21 | {%- block additional_info_table_head -%} 22 | 23 | 24 | 25 | 26 | 27 | 28 | {%- endblock -%} 29 | 30 | {%- block resource_last_updated -%} 31 | 32 | 33 | 34 | 35 | {%- endblock -%} 36 | {%- block resource_metadata_last_updated -%} 37 | 38 | 39 | 40 | 41 | {%- endblock -%} 42 | {%- block resource_created -%} 43 | 44 | 45 | 46 | 47 | {%- endblock -%} 48 | {%- block resource_format -%} 49 | 50 | 51 | 52 | 53 | {%- endblock -%} 54 | {%- block resource_license -%} 55 | 56 | 57 | 58 | 59 | {%- endblock -%} 60 | {%- block resource_fields -%} 61 | {%- for field in schema.resource_fields -%} 62 | {%- if field.field_name not in exclude_fields 63 | and field.display_snippet is not none -%} 64 | 65 | 68 | 73 | 74 | {%- endif -%} 75 | {%- endfor -%} 76 | {%- endblock -%} 77 | {%- block resource_more_items -%} 78 | {% for key, value in h.format_resource_items(res.items()) %} 79 | {% if key not in ('created', 'metadata modified', 'last modified', 'format') %} 80 | 81 | {% endif %} 82 | {% endfor %} 83 | {%- endblock -%} 84 | 85 |
    {{ _('Field') }}{{ _('Value') }}
    {{ _('Data last updated') }}{{ h.render_datetime(res.last_modified) or h.render_datetime(res.created) or _('unknown') }}
    {{ _('Metadata last updated') }}{{ h.render_datetime(res.metadata_modified) or h.render_datetime(res.created) or _('unknown') }}
    {{ _('Created') }}{{ h.render_datetime(res.created) or _('unknown') }}
    {{ _('Format') }}{{ res.format or res.mimetype_inner or res.mimetype or _('unknown') }}
    {{ _('License') }}{% snippet "snippets/license.html", pkg_dict=pkg, text_only=True %}
    66 | {{- h.scheming_language_text(field.label) -}} 67 | 69 | {%- snippet 'scheming/snippets/display_field.html', 70 | field=field, data=res, entity_type='dataset', 71 | object_type=dataset_type -%} 72 |
    {{ key | capitalize }}{{ value }}
    86 | 87 | 88 |
    89 | {% endblock %} 90 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/package/snippets/additional_info.html: -------------------------------------------------------------------------------- 1 | {% extends "package/snippets/additional_info.html" %} 2 | 3 | {%- set exclude_fields = [ 4 | 'id', 5 | 'title', 6 | 'name', 7 | 'notes', 8 | 'tag_string', 9 | 'license_id', 10 | 'owner_org', 11 | ] -%} 12 | 13 | {% block package_additional_info %} 14 | {%- for field in schema.dataset_fields -%} 15 | {%- if field.field_name not in exclude_fields 16 | and field.display_snippet is not none -%} 17 | 18 | {{ 19 | h.scheming_language_text(field.label) }} 20 | {%- snippet 'scheming/snippets/display_field.html', 23 | field=field, data=pkg_dict, schema=schema -%} 24 | 25 | {%- endif -%} 26 | {%- endfor -%} 27 | {% if h.check_access('package_update',{'id':pkg_dict.id}) %} 28 | 29 | {{ _("State") }} 30 | {{ _(pkg_dict.state) }} 31 | 32 | {% endif %} 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/package/snippets/package_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'package/new_package_form.html' %} 2 | 3 | {% block stages %} 4 | {%- set pages = h.scheming_get_dataset_form_pages(dataset_type) -%} 5 | {%- if pages -%} 6 | {%- set active_page = data.get('_form_page', 1) | int -%} 7 | {%- set draft_missing_required = h.scheming_missing_required_fields(pages, data) -%} 8 |
      9 | {%- for p in pages -%} 10 |
    1. 14 | {% if loop.index < active_page 15 | or (form_style == 'edit' and loop.index != active_page) 16 | %}{{ h.scheming_language_text(p.title) }}{% 23 | else %}{{ h.scheming_language_text(p.title) }}{% endif %} 24 | {%- set mreq = draft_missing_required[loop.index0] -%} 25 | {% if mreq %} 26 | 31 | 32 | 33 | {% endif %} 34 | 35 |
    2. 36 | {%- endfor -%} 37 | {%- if form_style != 'edit' -%} 38 |
    3. 39 | {% if s2 != 'complete' %} 40 | {{ _('Add data') }} 41 | {% else %} 42 | {% if s1 == 'active' %} 43 | {# stage 1 #} 44 | 45 | {% else %} 46 | {% link_for _('Add data'), named_route='dataset.new', class_="highlight" %} 47 | {% endif %} 48 | {% endif %} 49 |
    4. 50 | {%- endif -%} 51 |
    52 | {%- else -%} 53 | {{ super() }} 54 | {%- endif -%} 55 | {% endblock %} 56 | 57 | {% block errors %} 58 | {%- if errors -%} 59 | {%- set schema = h.scheming_get_dataset_schema(dataset_type) -%} 60 | {%- snippet 'scheming/snippets/errors.html', 61 | errors=errors, fields=schema.dataset_fields, 62 | entity_type='dataset', object_type=dataset_type -%} 63 | {%- endif -%} 64 | {% endblock %} 65 | 66 | {% block basic_fields %} 67 | {%- if not dataset_type -%} 68 |

    69 | dataset_type not passed to template. your version of CKAN 70 | might not be compatible with ckanext-scheming 71 |

    72 | {%- endif -%} 73 | 74 | {%- set schema = h.scheming_get_dataset_schema(dataset_type) -%} 75 | {%- set pages = h.scheming_get_dataset_form_pages(dataset_type) -%} 76 | {%- if pages -%} 77 | {%- set active_page = data.get('_form_page', 1) | int -%} 78 | {%- set fields = pages[active_page - 1]['fields'] -%} 79 | {%- else -%} 80 | {%- set fields = schema.dataset_fields -%} 81 | {%- endif -%} 82 | {%- for field in fields -%} 83 | {%- if field.form_snippet is not none -%} 84 | {%- if field.field_name not in data %} 85 | {# Set the field default value before rendering but only if 86 | it doesn't already exist in data which would mean the form 87 | has been submitted. #} 88 | {% if field.default_jinja2 %} 89 | {% do data.__setitem__( 90 | field.field_name, 91 | h.scheming_render_from_string(field.default_jinja2)) %} 92 | {% elif field.default %} 93 | {% do data.__setitem__(field.field_name, field.default) %} 94 | {% endif %} 95 | {% endif -%} 96 | {%- snippet 'scheming/snippets/form_field.html', 97 | field=field, 98 | data=data, 99 | errors=errors, 100 | licenses=c.licenses, 101 | entity_type='dataset', 102 | object_type=dataset_type 103 | -%} 104 | {%- endif -%} 105 | {%- endfor -%} 106 | 107 | {%- if pages -%} 108 | 109 | {%- elif 'resource_fields' not in schema -%} 110 | 111 | 112 | {%- endif -%} 113 | 114 | {% endblock %} 115 | 116 | {% block metadata_fields %} 117 | {% endblock %} 118 | 119 | {% block save_button_text %} 120 | {%- set pages = h.scheming_get_dataset_form_pages(dataset_type) -%} 121 | {%- if pages and form_style == 'edit' -%} 122 | {%- set active_page = data.get('_form_page', 1) | int -%} 123 | {{ _('Update {page}').format(page=h.scheming_language_text(pages[active_page-1].title)) }} 124 | {%- elif pages -%} 125 | {{ _('Next') }} 126 | {%- else -%} 127 | {{ super() }} 128 | {%- endif -%} 129 | {% endblock %} 130 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/package/snippets/resource_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'package/snippets/resource_form.html' %} 2 | 3 | {% block stages %} 4 | {%- set pages = h.scheming_get_dataset_form_pages(dataset_type) -%} 5 | {%- set draft_missing_required = h.scheming_missing_required_fields(pages, package_id=pkg_name) -%} 6 | {%- if pages and stage -%} 7 |
      8 | {%- for p in pages -%} 9 |
    1. 12 | {{ h.scheming_language_text(p.title) }} 18 | {%- set mreq = draft_missing_required[loop.index0] -%} 19 | {% if mreq %} 20 | 25 | 26 | 27 | {% endif %} 28 | 29 |
    2. 30 | {%- endfor -%} 31 |
    3. 32 | {{ _('Add data') }} 33 |
    4. 34 |
    35 | {%- else -%} 36 | {{ super() }} 37 | {%- endif -%} 38 | {% endblock %} 39 | 40 | {% block errors %} 41 | {%- if errors -%} 42 | {%- set schema = h.scheming_get_dataset_schema(dataset_type) -%} 43 | {%- snippet 'scheming/snippets/errors.html', 44 | errors=errors, fields=schema.resource_fields, 45 | entity_type='dataset', object_type=dataset_type -%} 46 | {%- else -%} 47 | {# Resource CreateView You must add at least one data resource #} 48 | {{ super() }} 49 | {%- endif -%} 50 | {% endblock %} 51 | 52 | {% block basic_fields %} 53 | {%- if not dataset_type -%} 54 |

    55 | dataset_type not passed to template. your version of CKAN 56 | might not be compatible with ckanext-scheming 57 |

    58 | {%- endif -%} 59 | 60 | {%- set schema = h.scheming_get_dataset_schema(dataset_type) -%} 61 | {%- for field in schema.resource_fields -%} 62 | {%- if field.form_snippet is not none -%} 63 | {%- if field.field_name not in data %} 64 | {# Set the field default value before rendering but only if 65 | it doesn't already exist in data which would mean the form 66 | has been submitted. #} 67 | {% if field.default_jinja2 %} 68 | {% do data.__setitem__( 69 | field.field_name, 70 | h.scheming_render_from_string(field.default_jinja2)) %} 71 | {% elif field.default %} 72 | {% do data.__setitem__(field.field_name, field.default) %} 73 | {% endif %} 74 | {% endif -%} 75 | {# We pass pkg_name as the package_id because that's the only 76 | variable available in this snippet #} 77 | {%- snippet 'scheming/snippets/form_field.html', 78 | field=field, 79 | data=data, 80 | errors=errors, 81 | licenses=c.licenses, 82 | entity_type='dataset', 83 | object_type=dataset_type, 84 | package_id=pkg_name 85 | -%} 86 | {%- endif -%} 87 | {%- endfor -%} 88 | 89 | {% endblock %} 90 | 91 | 92 | {% block metadata_fields %} 93 | {% endblock %} 94 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/snippets/display_field.html: -------------------------------------------------------------------------------- 1 | {#- master snippet for all scheming display fields -#} 2 | {#- render the field the user requested, or use a default field -#} 3 | {%- set display_snippet = field.display_snippet -%} 4 | 5 | {%- if not display_snippet -%} 6 | {%- if field.repeating_subfields -%} 7 | {%- set display_snippet = 'repeating_subfields.html' -%} 8 | {%- elif h.scheming_field_choices(field) -%} 9 | {%- set display_snippet = 'select.html' -%} 10 | {%- else -%} 11 | {%- set display_snippet = 'text.html' -%} 12 | {%- endif -%} 13 | {%- endif -%} 14 | 15 | {%- if '/' not in display_snippet -%} 16 | {%- set display_snippet = 'scheming/display_snippets/' + display_snippet -%} 17 | {%- endif -%} 18 | 19 | {%- if field.field_name in data -%} 20 | {%- snippet display_snippet, 21 | field=field, 22 | data=data, 23 | errors=errors, 24 | entity_type=entity_type, 25 | object_type=object_type 26 | -%} 27 | {%- endif -%} 28 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/snippets/errors.html: -------------------------------------------------------------------------------- 1 | {# shallow copy errors so we can remove processed keys #} 2 | {%- set unprocessed = errors.copy() -%} 3 | 4 | {% block errors_list %} 5 |
    6 |

    {{ _('The form contains invalid entries:') }}

    7 |
      8 | {% block all_errors %} 9 | {%- for field in fields -%} 10 | {%- if 'error_snippet' in field -%} 11 | {%- set error_snippet = field.error_snippet -%} 12 | 13 | {%- if '/' not in error_snippet -%} 14 | {%- set error_snippet = 'scheming/error_snippets/' + 15 | error_snippet -%} 16 | {%- endif -%} 17 | 18 | {%- snippet error_snippet, unprocessed=unprocessed, 19 | field=field, fields=fields, 20 | entity_type=entity_type, object_type=object_type -%} 21 | {%- endif -%} 22 | 23 | {%- if field.field_name in unprocessed -%} 24 | {%- set errors = unprocessed.pop(field.field_name) -%} 25 | {%- if 'repeating_subfields' in field %} 26 | {%- for se in errors -%} 27 | {%- if se -%} 28 |
    • {{ 29 | h.scheming_language_text(field.repeating_label or field.label) }} {{ loop.index }}: 30 |
        31 | {%- for sf in field.repeating_subfields -%} 32 | {%- set se_unprocessed = se.copy() -%} 33 | 34 | {%- if 'error_snippet' in sf -%} 35 | {%- set sfe_snippet = sf.error_snippet -%} 36 | 37 | {%- if '/' not in sfe_snippet -%} 38 | {%- set sfe_snippet = 'scheming/error_snippets/' + 39 | sfe_snippet -%} 40 | {%- endif -%} 41 | 42 | {%- snippet sfe_snippet, unprocessed=se_unprocessed, 43 | field=sf, fields=field.repeating_subfileds, 44 | entity_type=entity_type, object_type=object_type -%} 45 | {%- endif -%} 46 | 47 | {%- if sf.field_name in se_unprocessed -%} 48 |
      • {{ 49 | h.scheming_language_text(sf.label) }}: 50 | {{ se_unprocessed[sf.field_name][0] }}
      • 51 | {%- endif -%} 52 | {%- endfor -%} 53 |
      54 |
    • 55 | {%- endif -%} 56 | {%- endfor -%} 57 | {%- else -%} 58 |
    • {{ 59 | h.scheming_language_text(field.label) }}: 60 | {{ errors[0] }}
    • 61 | {%- endif -%} 62 | {%- endif -%} 63 | {%- endfor -%} 64 | 65 | {%- for key, errors in unprocessed.items() | sort -%} 66 |
    • {{ _(key) }}: {{ errors[0] }}
    • 67 | {%- endfor -%} 68 | {% endblock %} 69 |
    70 |
    71 | {% endblock %} 72 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/snippets/form_field.html: -------------------------------------------------------------------------------- 1 | {#- master snippet for all scheming form fields -#} 2 | {#- render the field the user requested, or use a default field -#} 3 | {%- set form_snippet = field.form_snippet|default( 4 | 'repeating_subfields.html' if field.repeating_subfields else 'text.html') -%} 5 | 6 | {%- if '/' not in form_snippet -%} 7 | {%- set form_snippet = 'scheming/form_snippets/' + form_snippet -%} 8 | {%- endif -%} 9 | 10 | {%- snippet form_snippet, 11 | field=field, 12 | data=data, 13 | errors=errors, 14 | licenses=licenses, 15 | entity_type=entity_type, 16 | object_type=object_type, 17 | package_id=package_id 18 | -%} 19 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/snippets/multiple_text_asset.html: -------------------------------------------------------------------------------- 1 | {% asset 'ckanext-scheming/multiple_text' %} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/snippets/scheming_asset.html: -------------------------------------------------------------------------------- 1 | {% asset 'ckanext-scheming/scheming_css' %} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/templates/scheming/snippets/subfields_asset.html: -------------------------------------------------------------------------------- 1 | {% asset 'ckanext-scheming/subfields' %} 2 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckan/ckanext-scheming/4a4bf3366389902bc53ab654ec5caf1e6dd67a22/ckanext/scheming/tests/__init__.py -------------------------------------------------------------------------------- /ckanext/scheming/tests/plugins.py: -------------------------------------------------------------------------------- 1 | import ckan.plugins as p 2 | 3 | from ckanext.scheming.plugins import SchemingDatasetsPlugin 4 | 5 | class SchemingTestSubclass(SchemingDatasetsPlugin): 6 | pass 7 | 8 | 9 | class SchemingTestValidationPlugin(p.SingletonPlugin): 10 | p.implements(p.IValidators) 11 | 12 | def get_validators(self): 13 | return { 14 | 'scheming_test_args': lambda *a: a, 15 | } 16 | 17 | 18 | class SchemingTestSchemaPlugin(p.SingletonPlugin): 19 | p.implements(p.ITemplateHelpers) 20 | 21 | def get_helpers(self): 22 | return { 23 | 'scheming_test_schema_choices': schema_choices_helper, 24 | } 25 | 26 | 27 | def schema_choices_helper(field): 28 | """ 29 | Test custom choices helper 30 | """ 31 | return [ 32 | { 33 | "value": "friendly", 34 | "label": "Often friendly" 35 | }, 36 | { 37 | "value": "jealous", 38 | "label": "Jealous of others" 39 | }, 40 | { 41 | "value": "spits", 42 | "label": "Tends to spit" 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_dataset_display.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import six 3 | from ckan.tests.factories import Sysadmin, Dataset 4 | 5 | 6 | @pytest.mark.usefixtures("clean_db") 7 | class TestDatasetDisplay(object): 8 | def test_dataset_displays_custom_fields(self, app): 9 | user = Sysadmin() 10 | Dataset( 11 | user=user, 12 | type="test-schema", 13 | name="set-one", 14 | humps=3, 15 | resources=[ 16 | {"url": "http://example.com/camel.txt", "camels_in_photo": 2} 17 | ], 18 | ) 19 | 20 | response = app.get("/dataset/set-one") 21 | assert "Humps" in response.body 22 | 23 | def test_resource_displays_custom_fields(self, app): 24 | user = Sysadmin() 25 | d = Dataset( 26 | user=user, 27 | type="test-schema", 28 | name="set-two", 29 | humps=3, 30 | resources=[ 31 | { 32 | "url": "http://example.com/camel.txt", 33 | "camels_in_photo": 2, 34 | "date": "2015-01-01", 35 | } 36 | ], 37 | ) 38 | 39 | response = app.get( 40 | "/dataset/set-two/resource/" + d["resources"][0]["id"] 41 | ) 42 | assert "Camels in Photo" in response.body 43 | assert "Date" in response.body 44 | 45 | def test_choice_field_shows_labels(self, app): 46 | user = Sysadmin() 47 | Dataset( 48 | user=user, 49 | type="test-schema", 50 | name="with-choice", 51 | category="hybrid", 52 | ) 53 | response = app.get("/dataset/with-choice") 54 | assert "Hybrid Camel" in response.body 55 | 56 | def test_notes_field_displayed(self, app): 57 | user = Sysadmin() 58 | Dataset( 59 | user=user, 60 | type="dataset", 61 | name="plain-jane", 62 | notes="# styled notes", 63 | ) 64 | 65 | response = app.get("/dataset/plain-jane") 66 | assert "

    styled notes" in response.body 67 | 68 | def test_choice_field_shows_list_if_multiple_options(self, app): 69 | user = Sysadmin() 70 | Dataset( 71 | user=user, 72 | type="test-schema", 73 | name="with-multiple-choice-n", 74 | personality=["friendly", "spits"], 75 | ) 76 | 77 | response = app.get("/dataset/with-multiple-choice-n") 78 | 79 | assert ( 80 | "
    • Often friendly
    • Tends to spit
    " 81 | in response.body 82 | ) 83 | 84 | def test_choice_field_does_not_show_list_if_one_options(self, app): 85 | user = Sysadmin() 86 | Dataset( 87 | user=user, 88 | type="test-schema", 89 | name="with-multiple-choice-one", 90 | personality=["friendly"], 91 | ) 92 | 93 | response = app.get("/dataset/with-multiple-choice-one") 94 | 95 | assert "Often friendly" in response.body 96 | assert "
    • Often friendly
    " not in response.body 97 | 98 | def test_json_field_displayed(self, app): 99 | user = Sysadmin() 100 | Dataset( 101 | user=user, 102 | type="test-schema", 103 | name="plain-json", 104 | a_json_field={"a": "1", "b": "2"}, 105 | ) 106 | response = app.get("/dataset/plain-json") 107 | 108 | if six.PY3: 109 | expected = """{\n "a": "1",\n "b": "2"\n}""" 110 | else: 111 | expected = """{\n "a": "1", \n "b": "2"\n}""" 112 | expected = expected.replace( 113 | '"', """ 114 | ) # Ask webhelpers 115 | 116 | assert expected in response.body 117 | assert "Example JSON" in response.body 118 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_dataset_logic.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ckanapi import LocalCKAN, NotFound 4 | 5 | 6 | class TestDatasetSchemaLists(object): 7 | def test_dataset_schema_list(self): 8 | lc = LocalCKAN("visitor") 9 | dataset_schemas = lc.action.scheming_dataset_schema_list() 10 | assert "test-schema" in dataset_schemas 11 | 12 | def test_dataset_schema_show(self): 13 | lc = LocalCKAN("visitor") 14 | schema = lc.action.scheming_dataset_schema_show(type="test-schema") 15 | assert schema["dataset_fields"][2]["label"] == "Humps" 16 | 17 | def test_dataset_schema_not_found(self): 18 | lc = LocalCKAN("visitor") 19 | with pytest.raises(NotFound): 20 | lc.action.scheming_dataset_schema_show(type="ernie") 21 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_datastore_choices.json: -------------------------------------------------------------------------------- 1 | { 2 | "dataset_type": "test-datastore-choices", 3 | "about_url": "http://github.com/ckan/ckanext-scheming", 4 | "dataset_fields": [ 5 | { 6 | "field_name": "title", 7 | "label": "Title", 8 | "preset": "title", 9 | "form_placeholder": "eg. Larry, Peter, Susan" 10 | }, 11 | { 12 | "field_name": "name", 13 | "label": "URL", 14 | "preset": "dataset_slug", 15 | "form_placeholder": "eg. camel-no-5" 16 | }, 17 | { 18 | "field_name": "category", 19 | "label": "Category", 20 | "help_text": "Make and model", 21 | "help_inline": true, 22 | "preset": "select", 23 | "choices_helper": "scheming_datastore_choices", 24 | "datastore_choices_resource": "category-choices" 25 | }, 26 | { 27 | "field_name": "personality", 28 | "label": "Personality", 29 | "preset": "multiple_checkbox", 30 | "choices_helper": "scheming_datastore_choices", 31 | "datastore_choices_resource": "personality-choices", 32 | "datastore_choices_columns": { 33 | "value": "valcol", 34 | "label": "labelcol" 35 | } 36 | } 37 | ], 38 | "resource_fields": [ 39 | { 40 | "field_name": "url", 41 | "label": "Photo", 42 | "preset": "resource_url_upload", 43 | "form_placeholder": "http://example.com/my-camel-photo.jpg", 44 | "upload_label": "Photo" 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_form.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from bs4 import BeautifulSoup 5 | 6 | from ckan.plugins.toolkit import check_ckan_version, h 7 | 8 | from ckan.tests.factories import Dataset 9 | from ckan.tests.helpers import call_action 10 | 11 | 12 | @pytest.fixture 13 | def sysadmin_env(): 14 | try: 15 | from ckan.tests.factories import SysadminWithToken 16 | user = SysadminWithToken() 17 | return {'Authorization': user['token']} 18 | except ImportError: 19 | # ckan <= 2.9 20 | from ckan.tests.factories import Sysadmin 21 | user = Sysadmin() 22 | return {"REMOTE_USER": user["name"].encode("ascii")} 23 | 24 | 25 | def _get_package_new_page(app, env, type_='test-schema'): 26 | if check_ckan_version(min_version="2.10.0"): 27 | return app.get(url="/{0}/new".format(type_), headers=env) 28 | else: 29 | return app.get(url="/{0}/new".format(type_), extra_environ=env) 30 | 31 | 32 | def _get_package_update_page(app, id, env): 33 | 34 | if check_ckan_version(min_version="2.10.0"): 35 | return app.get(url="/test-schema/edit/{}".format(id), headers=env) 36 | else: 37 | return app.get(url="/test-schema/edit/{}".format(id), extra_environ=env) 38 | 39 | 40 | def _get_resource_new_page(app, id, env): 41 | url = '/dataset/{}/resource/new'.format(id) 42 | 43 | if check_ckan_version(min_version="2.10.0"): 44 | return app.get(url, headers=env) 45 | else: 46 | return app.get(url, extra_environ=env) 47 | 48 | 49 | def _get_resource_update_page(app, id, resource_id, env): 50 | url = '/dataset/{}/resource/{}/edit'.format(id, resource_id) 51 | 52 | if check_ckan_version(min_version="2.10.0"): 53 | return app.get(url, headers=env) 54 | else: 55 | return app.get(url, extra_environ=env) 56 | 57 | 58 | def _get_organization_new_page(app, env, type_="organization"): 59 | 60 | if check_ckan_version(min_version="2.10.0"): 61 | return app.get(url="/{0}/new".format(type_), headers=env) 62 | else: 63 | return app.get(url="/{0}/new".format(type_), extra_environ=env) 64 | 65 | 66 | def _get_group_new_page(app, env, type_="group"): 67 | 68 | if check_ckan_version(min_version="2.10.0"): 69 | return app.get(url="/{0}/new".format(type_), headers=env) 70 | else: 71 | return app.get(url="/{0}/new".format(type_), extra_environ=env) 72 | 73 | 74 | def _get_organization_form(html): 75 | # FIXME: add an id to this form 76 | if check_ckan_version(min_version="2.11.0a0"): 77 | form = BeautifulSoup(html).select("form")[2] 78 | else: 79 | form = BeautifulSoup(html).select("form")[1] 80 | return form 81 | 82 | 83 | def _get_group_form(html): 84 | return _get_organization_form(html) 85 | 86 | 87 | def _post_data(app, url, data, env): 88 | try: 89 | if check_ckan_version(min_version="2.11.0a0"): 90 | return app.post(url, headers=env, data=data, follow_redirects=False) 91 | else: 92 | return app.post( 93 | url, environ_overrides=env, data=data, follow_redirects=False 94 | ) 95 | except TypeError: 96 | return app.post(url.encode('ascii'), params=data, extra_environ=sysadmin_env) 97 | 98 | 99 | @pytest.mark.usefixtures("clean_db") 100 | class TestDatasetFormNew(object): 101 | def test_dataset_form_includes_custom_fields(self, app, sysadmin_env): 102 | response = _get_package_new_page(app, sysadmin_env) 103 | form = BeautifulSoup(response.body).select_one("#dataset-edit") 104 | assert form.select("input[name=humps]") 105 | 106 | def test_dataset_form_slug_says_dataset(self, app, sysadmin_env): 107 | """The default prefix shouldn't be /packages?id=""" 108 | 109 | response = _get_package_new_page(app, sysadmin_env) 110 | assert "packages?id=" not in response.body 111 | assert "/test-schema/" in response.body 112 | 113 | def test_resource_form_includes_custom_fields(self, app, sysadmin_env): 114 | dataset = Dataset(type="test-schema", name="resource-includes-custom") 115 | 116 | response = _get_resource_new_page(app, dataset["id"], sysadmin_env) 117 | 118 | form = BeautifulSoup(response.body).select_one("#resource-edit") 119 | assert form.select("input[name=camels_in_photo]") 120 | 121 | def test_dataset_form_includes_licenses(self, app, sysadmin_env): 122 | """Starting from CKAN v2.9, licenses are not available as template 123 | variable and we are extendisn 124 | `DefaultDatasetForm::setup_template_variables` in order to change 125 | it. 126 | """ 127 | response = _get_package_new_page(app, sysadmin_env, type_="dataset") 128 | page = BeautifulSoup(response.body) 129 | licenses = page.select('#field-license_id option') 130 | assert licenses 131 | 132 | 133 | @pytest.mark.usefixtures("clean_db") 134 | class TestOrganizationFormNew(object): 135 | def test_organization_form_includes_custom_field(self, app, sysadmin_env): 136 | 137 | response = _get_organization_new_page(app, sysadmin_env) 138 | 139 | form = _get_organization_form(response.body) 140 | 141 | # FIXME: generate the form for orgs (this is currently missing) 142 | assert form.select("input[name=department_id]") 143 | 144 | def test_organization_form_slug_says_organization(self, app, sysadmin_env): 145 | """The default prefix shouldn't be /packages?id=""" 146 | 147 | response = _get_organization_new_page(app, sysadmin_env) 148 | # Commenting until ckan/ckan#4208 is fixed 149 | # assert_true('packages?id=' not in response.body) 150 | assert "/organization/" in response.body 151 | 152 | 153 | @pytest.mark.usefixtures("clean_db") 154 | class TestGroupFormNew(object): 155 | def test_group_form_includes_custom_field(self, app, sysadmin_env): 156 | 157 | response = _get_group_new_page(app, sysadmin_env) 158 | form = _get_organization_form(response.body) 159 | 160 | assert form.select("input[name=bookface]") 161 | 162 | def test_group_form_slug_says_group(self, app, sysadmin_env): 163 | """The default prefix shouldn't be /packages?id=""" 164 | 165 | response = _get_group_new_page(app, sysadmin_env) 166 | # Commenting until ckan/ckan#4208 is fixed 167 | # assert_true('packages?id=' not in response.body) 168 | assert "/group/" in response.body 169 | 170 | 171 | @pytest.mark.usefixtures("clean_db") 172 | class TestCustomGroupFormNew(object): 173 | def test_group_form_includes_custom_field(self, app, sysadmin_env): 174 | response = _get_group_new_page(app, sysadmin_env, "theme") 175 | 176 | form = _get_group_form(response.body) 177 | 178 | assert form.select("input[name=status]") 179 | 180 | def test_group_form_slug_uses_custom_type(self, app, sysadmin_env): 181 | 182 | response = _get_group_new_page(app, sysadmin_env, "theme") 183 | 184 | assert "/theme/" in response.body 185 | 186 | 187 | @pytest.mark.usefixtures("clean_db") 188 | class TestCustomOrgFormNew(object): 189 | def test_org_form_includes_custom_field(self, app, sysadmin_env): 190 | response = _get_organization_new_page( 191 | app, sysadmin_env, "publisher" 192 | ) 193 | 194 | form = _get_organization_form(response.body) 195 | assert form.select("input[name=address]") 196 | 197 | def test_org_form_slug_uses_custom_type(self, app, sysadmin_env): 198 | response = _get_organization_new_page( 199 | app, sysadmin_env, "publisher" 200 | ) 201 | 202 | assert "/publisher/" in response.body 203 | 204 | 205 | @pytest.mark.usefixtures("clean_db") 206 | class TestJSONDatasetForm(object): 207 | def test_dataset_form_includes_json_fields(self, app, sysadmin_env): 208 | response = _get_package_new_page(app, sysadmin_env) 209 | form = BeautifulSoup(response.body).select("#dataset-edit")[0] 210 | assert form.select("textarea[name=a_json_field]") 211 | 212 | def test_dataset_form_create(self, app, sysadmin_env): 213 | data = {"save": "", "_ckan_phase": 1} 214 | value = {"a": 1, "b": 2} 215 | json_value = json.dumps(value) 216 | 217 | data["name"] = "json_dataset_1" 218 | data["a_json_field"] = json_value 219 | 220 | url = '/test-schema/new' 221 | 222 | _post_data(app, url, data, sysadmin_env) 223 | 224 | dataset = call_action("package_show", id="json_dataset_1") 225 | assert dataset["a_json_field"] == value 226 | 227 | def test_dataset_form_update(self, app, sysadmin_env): 228 | value = {"a": 1, "b": 2} 229 | dataset = Dataset(type="test-schema", a_json_field=value) 230 | 231 | response = _get_package_update_page( 232 | app, dataset["id"], sysadmin_env 233 | ) 234 | form = BeautifulSoup(response.body).select_one("#dataset-edit") 235 | assert form.select_one( 236 | "textarea[name=a_json_field]" 237 | ).text == json.dumps(value, indent=2) 238 | 239 | value = {"a": 1, "b": 2, "c": 3} 240 | json_value = json.dumps(value) 241 | 242 | data = { 243 | "save": "", 244 | "a_json_field": json_value, 245 | "name": dataset["name"], 246 | } 247 | 248 | url = '/dataset/edit/' + dataset["id"] 249 | 250 | _post_data(app, url, data, sysadmin_env) 251 | 252 | dataset = call_action("package_show", id=dataset["id"]) 253 | 254 | assert dataset["a_json_field"] == value 255 | 256 | 257 | @pytest.mark.usefixtures("clean_db") 258 | class TestJSONResourceForm(object): 259 | def test_resource_form_includes_json_fields(self, app, sysadmin_env): 260 | dataset = Dataset(type="test-schema") 261 | 262 | response = _get_resource_new_page(app, dataset["id"], sysadmin_env) 263 | form = BeautifulSoup(response.body).select_one("#resource-edit") 264 | assert form.select("textarea[name=a_resource_json_field]") 265 | 266 | def test_resource_form_create(self, app, sysadmin_env): 267 | dataset = Dataset(type="test-schema") 268 | 269 | response = _get_resource_new_page(app, dataset["id"], sysadmin_env) 270 | 271 | url = h.url_for( 272 | "test-schema_resource.new", id=dataset["id"] 273 | ) 274 | if not url.startswith('/'): # ckan < 2.9 275 | url = '/dataset/new_resource/' + dataset["id"] 276 | 277 | value = {"a": 1, "b": 2} 278 | json_value = json.dumps(value) 279 | 280 | data = { 281 | "id": "", 282 | "save": "", 283 | "url": "http://example.com/data.csv", 284 | "a_resource_json_field": json_value, 285 | "name": dataset["name"], 286 | } 287 | 288 | _post_data(app, url, data, sysadmin_env) 289 | 290 | dataset = call_action("package_show", id=dataset["id"]) 291 | 292 | assert dataset["resources"][0]["a_resource_json_field"] == value 293 | 294 | def test_resource_form_update(self, app, sysadmin_env): 295 | value = {"a": 1, "b": 2} 296 | dataset = Dataset( 297 | type="test-schema", 298 | resources=[ 299 | { 300 | "url": "http://example.com/data.csv", 301 | "a_resource_json_field": value, 302 | } 303 | ], 304 | ) 305 | 306 | response = _get_resource_update_page( 307 | app, dataset["id"], dataset["resources"][0]["id"], sysadmin_env 308 | ) 309 | form = BeautifulSoup(response.body).select_one("#resource-edit") 310 | assert form.select_one( 311 | "textarea[name=a_resource_json_field]" 312 | ).text == json.dumps(value, indent=2) 313 | 314 | url = h.url_for( 315 | "test-schema_resource.edit", 316 | id=dataset["id"], 317 | resource_id=dataset["resources"][0]["id"], 318 | ) 319 | if not url.startswith('/'): # ckan < 2.9 320 | url = '/dataset/{ds}/resource_edit/{rs}'.format( 321 | ds=dataset["id"], 322 | rs=dataset["resources"][0]["id"] 323 | ) 324 | 325 | value = {"a": 1, "b": 2, "c": 3} 326 | json_value = json.dumps(value) 327 | 328 | data = { 329 | "id": dataset["resources"][0]["id"], 330 | "save": "", 331 | "a_resource_json_field": json_value, 332 | "name": dataset["name"], 333 | } 334 | 335 | _post_data(app, url, data, sysadmin_env) 336 | 337 | dataset = call_action("package_show", id=dataset["id"]) 338 | 339 | assert dataset["resources"][0]["a_resource_json_field"] == value 340 | 341 | 342 | @pytest.mark.usefixtures("clean_db") 343 | class TestSubfieldDatasetForm(object): 344 | def test_dataset_form_includes_subfields(self, app, sysadmin_env): 345 | response = _get_package_new_page(app, sysadmin_env, 'test-subfields') 346 | form = BeautifulSoup(response.body).select("#dataset-edit")[0] 347 | assert form.select("fieldset[name=scheming-repeating-subfields]") 348 | 349 | def test_dataset_form_create(self, app, sysadmin_env): 350 | data = {"save": "", "_ckan_phase": 1} 351 | 352 | data["name"] = "subfield_dataset_1" 353 | data["citation-0-originator"] = ['mei', 'ahmed'] 354 | data["contact_address-0-address"] = 'anyplace' 355 | 356 | url = '/test-subfields/new' 357 | 358 | _post_data(app, url, data, sysadmin_env) 359 | 360 | dataset = call_action("package_show", id="subfield_dataset_1") 361 | assert dataset["citation"] == [{'originator': ['mei', 'ahmed']}] 362 | assert dataset["contact_address"] == [{'address': 'anyplace'}] 363 | 364 | def test_dataset_form_update(self, app, sysadmin_env): 365 | dataset = Dataset( 366 | type="test-subfields", 367 | citation=[{'originator': ['mei']}, {'originator': ['ahmed']}], 368 | contact_address=[{'address': 'anyplace'}]) 369 | 370 | response = _get_package_update_page( 371 | app, dataset["id"], sysadmin_env 372 | ) 373 | form = BeautifulSoup(response.body).select_one("#dataset-edit") 374 | assert form.select_one( 375 | "input[name=citation-1-originator]" 376 | ).attrs['value'] == 'ahmed' 377 | 378 | data = {"save": ""} 379 | data["citation-0-originator"] = ['ling'] 380 | data["citation-1-originator"] = ['umet'] 381 | data["contact_address-0-address"] = 'home' 382 | data["name"] = dataset["name"] 383 | 384 | url = '/test-subfields/edit/' + dataset["id"] 385 | 386 | _post_data(app, url, data, sysadmin_env) 387 | 388 | dataset = call_action("package_show", id=dataset["id"]) 389 | 390 | assert dataset["citation"] == [{'originator': ['ling']}, {'originator': ['umet']}] 391 | assert dataset["contact_address"] == [{'address': 'home'}] 392 | 393 | 394 | 395 | @pytest.mark.usefixtures("clean_db") 396 | class TestSubfieldResourceForm(object): 397 | def test_resource_form_includes_subfields(self, app, sysadmin_env): 398 | dataset = Dataset(type="test-subfields", citation=[{'originator': 'na'}]) 399 | 400 | response = _get_resource_new_page(app, dataset["id"], sysadmin_env) 401 | form = BeautifulSoup(response.body).select_one("#resource-edit") 402 | assert form.select("fieldset[name=scheming-repeating-subfields]") 403 | 404 | def test_resource_form_create(self, app, sysadmin_env): 405 | dataset = Dataset(type="test-subfields", citation=[{'originator': 'na'}]) 406 | 407 | response = _get_resource_new_page(app, dataset["id"], sysadmin_env) 408 | 409 | url = h.url_for( 410 | "test-subfields_resource.new", id=dataset["id"] 411 | ) 412 | if not url.startswith('/'): # ckan < 2.9 413 | url = '/dataset/new_resource/' + dataset["id"] 414 | 415 | data = {"id": "", "save": ""} 416 | data["schedule-0-impact"] = "P" 417 | 418 | _post_data(app, url, data, sysadmin_env) 419 | 420 | dataset = call_action("package_show", id=dataset["id"]) 421 | 422 | assert dataset["resources"][0]["schedule"] == [{"impact": "P"}] 423 | 424 | def test_resource_form_update(self, app, sysadmin_env): 425 | dataset = Dataset( 426 | type="test-subfields", 427 | citation=[{'originator': 'na'}], 428 | resources=[ 429 | { 430 | "url": "http://example.com/data.csv", 431 | "schedule": [ 432 | {"impact": "A", "frequency": "1m"}, 433 | {"impact": "P", "frequency": "7d"}, 434 | ] 435 | } 436 | ], 437 | ) 438 | 439 | response = _get_resource_update_page( 440 | app, dataset["id"], dataset["resources"][0]["id"], sysadmin_env 441 | ) 442 | form = BeautifulSoup(response.body).select_one("#resource-edit") 443 | opt7d = form.find_all('option', {'value': '7d'}) 444 | assert 'selected' not in opt7d[0].attrs 445 | assert 'selected' in opt7d[1].attrs 446 | assert 'selected' not in opt7d[2].attrs # blank subfields 447 | 448 | url = h.url_for( 449 | "test-schema_resource.edit", 450 | id=dataset["id"], 451 | resource_id=dataset["resources"][0]["id"], 452 | ) 453 | if not url.startswith('/'): # ckan < 2.9 454 | url = '/dataset/{ds}/resource_edit/{rs}'.format( 455 | ds=dataset["id"], 456 | rs=dataset["resources"][0]["id"] 457 | ) 458 | 459 | data = {"id": dataset["resources"][0]["id"], "save": ""} 460 | data["schedule-0-frequency"] = '1y' 461 | data["schedule-0-impact"] = 'A' 462 | data["schedule-1-frequency"] = '1m' 463 | data["schedule-1-impact"] = 'P' 464 | 465 | _post_data(app, url, data, sysadmin_env) 466 | 467 | dataset = call_action("package_show", id=dataset["id"]) 468 | 469 | assert dataset["resources"][0]["schedule"] == [ 470 | {"frequency": '1y', "impact": 'A'}, 471 | {"frequency": '1m', "impact": 'P'}, 472 | ] 473 | 474 | def test_resource_form_create_with_datetime_tz(self, app, sysadmin_env): 475 | dataset = Dataset(type="test-schema") 476 | 477 | url = h.url_for("test-schema_resource.new", id=dataset["id"]) 478 | if not url.startswith("/"): # ckan < 2.9 479 | url = "/dataset/new_resource/" + dataset["id"] 480 | 481 | date = "2001-12-12" 482 | time = "12:12" 483 | tz = "UTC" 484 | 485 | data = { 486 | "id": "", 487 | "save": "", 488 | "datetime_tz_date": date, 489 | "datetime_tz_time": time, 490 | "datetime_tz_tz": tz, 491 | } 492 | 493 | _post_data(app, url, data, sysadmin_env) 494 | 495 | dataset = call_action("package_show", id=dataset["id"]) 496 | 497 | assert dataset["resources"][0]["datetime_tz"] == f"{date}T{time}:00" 498 | 499 | 500 | @pytest.mark.usefixtures("with_plugins", "clean_db") 501 | class TestDatasetFormPages(object): 502 | def test_dataset_form_pages_new(self, app, sysadmin_env): 503 | response = _get_package_new_page(app, sysadmin_env, 'test-formpages') 504 | form = BeautifulSoup(response.body).select_one("#dataset-edit") 505 | assert form.select("input[name=test_first]") 506 | assert not form.select("input[name=test_second]") 507 | assert 'First Page' == form.select_one('ol.stages li.first.active').text.strip() 508 | 509 | response = _post_data(app, '/test-formpages/new', {'name': 'fp'}, sysadmin_env) 510 | assert response.location.endswith('/test-formpages/new/fp/2') 511 | 512 | response = app.get('/test-formpages/new/fp/2', headers=sysadmin_env) 513 | form = BeautifulSoup(response.body).select_one("#dataset-edit") 514 | assert not form.select("input[name=test_first]") 515 | assert form.select("input[name=test_second]") 516 | assert 'Second Page' == form.select_one('ol.stages li.active').text.strip() 517 | 518 | response = _post_data(app, '/test-formpages/new/fp/2', {}, sysadmin_env) 519 | assert response.location.endswith('/test-formpages/fp/resource/new') 520 | 521 | response = app.get('/test-formpages/fp/resource/new', headers=sysadmin_env) 522 | form = BeautifulSoup(response.body).select_one("#resource-edit") 523 | assert 'Add data' == form.select_one('ol.stages li.active').text.strip() 524 | assert 'First Page' == form.select_one('ol.stages li.first').text.strip() 525 | 526 | def test_dataset_form_pages_draft_new(self, app, sysadmin_env): 527 | response = _get_package_new_page(app, sysadmin_env, 'test-formpages-draft') 528 | form = BeautifulSoup(response.body).select_one("#dataset-edit") 529 | assert 'Missing required field: Description' == form.select_one('ol.stages li.first a[data-bs-toggle=tooltip]')['title'] 530 | assert 'Missing required field: Version' == form.select_one('ol.stages li:nth-of-type(2) a[data-bs-toggle=tooltip]')['title'] 531 | 532 | _post_data(app, '/test-formpages-draft/new', {'name': 'fpd'}, sysadmin_env) 533 | 534 | response = app.get('/test-formpages-draft/new/fpd/2', headers=sysadmin_env) 535 | form = BeautifulSoup(response.body).select_one("#dataset-edit") 536 | assert 'Missing required field: Description' == form.select_one('ol.stages li.first a[data-bs-toggle=tooltip]')['title'] 537 | assert 'Missing required field: Version' == form.select_one('ol.stages li:nth-of-type(2) a[data-bs-toggle=tooltip]')['title'] 538 | 539 | _post_data(app, '/test-formpages-draft/new/fpd/2', {}, sysadmin_env) 540 | 541 | response = app.get('/test-formpages-draft/fpd/resource/new', headers=sysadmin_env) 542 | form = BeautifulSoup(response.body).select_one("#resource-edit") 543 | assert 'Missing required field: Description' == form.select_one('ol.stages li.first a[data-bs-toggle=tooltip]')['title'] 544 | assert 'Missing required field: Version' == form.select_one('ol.stages li:nth-of-type(2) a[data-bs-toggle=tooltip]')['title'] 545 | 546 | if check_ckan_version(min_version="2.12.0a0"): 547 | # requires https://github.com/ckan/ckan/pull/8309 548 | # or ckan raises an uncaught ValidationError 549 | response = _post_data(app, '/test-formpages-draft/fpd/resource/new', {'url':'http://example.com', 'save':'go-metadata', 'id': ''}, sysadmin_env) 550 | form = BeautifulSoup(response.body).select_one("#resource-edit") 551 | assert 'Name:' in form.select_one('div.error-explanation').text 552 | 553 | response = _post_data(app, '/test-formpages-draft/fpd/resource/new', {'url':'http://example.com', 'name': 'example', 'save':'go-metadata', 'id': ''}, sysadmin_env) 554 | form = BeautifulSoup(response.body).select_one("#resource-edit") 555 | errors = form.select_one('div.error-explanation').text 556 | assert 'Notes: Missing value' in errors 557 | assert 'Version: Missing value' in errors 558 | assert 'Resources: Package resource(s) invalid' in errors 559 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_form_snippets.py: -------------------------------------------------------------------------------- 1 | import six 2 | import pytest 3 | import bs4 4 | from ckan.lib.base import render_snippet 5 | try: 6 | from jinja2.utils import markupsafe 7 | Markup = markupsafe.Markup 8 | except ImportError: 9 | # old way 10 | from jinja2 import Markup 11 | 12 | 13 | def render_form_snippet(name, data=None, extra_args=None, errors=None, **kwargs): 14 | field = {"field_name": "test", "label": "Test"} 15 | field.update(kwargs) 16 | return render_snippet( 17 | "scheming/form_snippets/" + name, 18 | field=field, 19 | data=data or {}, 20 | errors=errors or {}, 21 | **(extra_args or {}) 22 | ) 23 | 24 | 25 | @pytest.mark.usefixtures("with_request_context") 26 | class TestSelectFormSnippet(object): 27 | def test_choices_visible(self): 28 | html = render_form_snippet( 29 | "select.html", choices=[{"value": "one", "label": "One"}] 30 | ) 31 | assert '' in html 32 | 33 | def test_blank_choice_shown(self): 34 | html = render_form_snippet( 35 | "select.html", choices=[{"value": "two", "label": "Two"}] 36 | ) 37 | assert '".format( 104 | orgid=organization["id"], 105 | selected=" selected" if selected_org else "", 106 | display_name=organization["display_name"], 107 | ) 108 | ) 109 | 110 | 111 | @pytest.mark.usefixtures("with_request_context") 112 | class TestOrganizationFormSnippet(object): 113 | # It's hard to unit test 'form_snippets/organization.html' because it 114 | # fetches lists of orgs for the current user, so here we're just testing 115 | # the org selection part. 116 | # XXX: Add functional testing in test_form.py to cover that 117 | 118 | def test_organizations_visible(self): 119 | html = render_form_snippet( 120 | "_organization_select.html", 121 | extra_args={ 122 | "organizations_available": [ 123 | {"id": "1", "display_name": "One"} 124 | ], 125 | "organization_option_tag": organization_option_tag, 126 | "org_required": False, 127 | }, 128 | ) 129 | assert '' in html 130 | 131 | def test_no_organization_shown(self): 132 | html = render_form_snippet( 133 | "_organization_select.html", 134 | extra_args={ 135 | "organizations_available": [ 136 | {"id": "1", "display_name": "One"} 137 | ], 138 | "organization_option_tag": organization_option_tag, 139 | "org_required": False, 140 | }, 141 | ) 142 | assert '' in html 170 | 171 | 172 | @pytest.mark.usefixtures("with_request_context") 173 | class TestLicenseFormSnippet(object): 174 | def test_license_choices_visible(self): 175 | html = render_form_snippet( 176 | "license.html", 177 | extra_args={"licenses": [("Aaa", "aa"), ("Bbb", "bb")]}, 178 | ) 179 | assert '' in html 180 | assert '' in html 181 | 182 | def test_license_sorted_by_default(self): 183 | html = render_form_snippet( 184 | "license.html", 185 | extra_args={"licenses": [("Zzz", "zz"), ("Bbb", "bb")]}, 186 | ) 187 | assert '' in html 208 | 209 | 210 | @pytest.mark.usefixtures("with_request_context") 211 | class TestJSONFormSnippet(object): 212 | def test_json_value(self): 213 | html = render_form_snippet( 214 | "json.html", 215 | field_name="a_json_field", 216 | data={"a_json_field": {"a": "1", "b": "2"}}, 217 | ) 218 | # It may seem unexpected, but JSONEncoder in Py2 adds 219 | # whitespace after comma for better readability. A lot if 220 | # editors/IDE strips leading whitespace in a line so it's 221 | # better to explicitly write expected result using escape 222 | # sequence. 223 | if six.PY3: 224 | expected = """{\n "a": "1",\n "b": "2"\n}""" 225 | else: 226 | expected = """{\n "a": "1", \n "b": "2"\n}""" 227 | 228 | expected = expected.replace('"', """) # Ask webhelpers 229 | 230 | assert expected in html 231 | 232 | def test_json_value_no_indent(self): 233 | html = render_form_snippet( 234 | "json.html", 235 | field_name="a_json_field", 236 | data={"a_json_field": {"a": "1", "b": "2"}}, 237 | indent=None, 238 | ) 239 | expected = """{"a": "1", "b": "2"}""".replace('"', """) 240 | 241 | assert expected in html 242 | 243 | def test_json_value_is_empty_with_no_value(self): 244 | html = render_form_snippet( 245 | "json.html", field_name="a_json_field", data={"a_json_field": ""} 246 | ) 247 | expected = ">" 248 | 249 | assert expected in html 250 | 251 | def test_json_value_is_displayed_correctly_if_string(self): 252 | value = '{"a": 1, "b": 2}' 253 | html = render_form_snippet( 254 | "json.html", 255 | field_name="a_json_field", 256 | data={"a_json_field": value}, 257 | ) 258 | expected = value.replace('"', """) 259 | 260 | assert expected in html 261 | 262 | 263 | @pytest.mark.usefixtures("with_request_context") 264 | class TestRepeatingSubfieldsFormSnippet(object): 265 | def test_form_attrs_on_fieldset(self): 266 | html = render_form_snippet( 267 | "repeating_subfields.html", 268 | field_name="repeating", 269 | repeating_subfields=[{"field_name": "x"}], 270 | form_attrs={"data-module": "test-attrs"}, 271 | ) 272 | snippet = bs4.BeautifulSoup(html) 273 | attr_holder = snippet.select_one(".controls").div 274 | assert attr_holder['data-module'] == 'test-attrs' 275 | 276 | 277 | @pytest.mark.usefixtures("with_request_context") 278 | class TestRadioFormSnippet(object): 279 | def test_radio_choices(self): 280 | html = render_form_snippet( 281 | "radio.html", 282 | field_name="radio-group", 283 | choices=[ 284 | {"value": "one", "label": "One"} 285 | ], 286 | ) 287 | snippet = bs4.BeautifulSoup(html) 288 | attr_holder = snippet.select_one(".controls").label 289 | assert attr_holder.text.strip() == 'One' \ 290 | and attr_holder.input["value"].strip() == 'one' 291 | 292 | def test_radio_checked(self): 293 | html = render_form_snippet( 294 | "radio.html", 295 | field_name="radio-group", 296 | data={"radio-group": "one"}, 297 | choices=[ 298 | {"value": "one", "label": "One"} 299 | ], 300 | ) 301 | snippet = bs4.BeautifulSoup(html) 302 | attr_holder = snippet.select_one(".controls").input 303 | assert attr_holder.has_attr('checked') 304 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_formpages.yaml: -------------------------------------------------------------------------------- 1 | dataset_type: test-formpages 2 | about: The default CKAN dataset schema with form split across multiple pages 3 | about_url: https://github.com/ckan/ckanext-scheming 4 | 5 | 6 | dataset_fields: 7 | 8 | - start_form_page: 9 | title: First Page 10 | description: Required and core dataset fields 11 | 12 | field_name: title 13 | label: Title 14 | preset: title 15 | form_placeholder: eg. A descriptive title 16 | 17 | - field_name: name 18 | label: URL 19 | preset: dataset_slug 20 | form_placeholder: eg. my-dataset 21 | 22 | - field_name: notes 23 | label: Description 24 | form_snippet: markdown.html 25 | form_placeholder: eg. Some useful notes about the data 26 | 27 | - field_name: owner_org 28 | label: Organization 29 | preset: dataset_organization 30 | 31 | - field_name: test_first 32 | label: Test First 33 | 34 | - start_form_page: 35 | title: Second Page 36 | description: 37 | These fields improve search and give users important links 38 | 39 | field_name: test_second 40 | label: Test Second 41 | 42 | - field_name: tag_string 43 | label: Tags 44 | preset: tag_string_autocomplete 45 | form_placeholder: eg. economy, mental health, government 46 | 47 | - field_name: license_id 48 | label: License 49 | form_snippet: license.html 50 | help_text: License definitions and additional information can be found at http://opendefinition.org/ 51 | 52 | - field_name: url 53 | label: Source 54 | form_placeholder: http://example.com/dataset.json 55 | display_property: foaf:homepage 56 | display_snippet: link.html 57 | 58 | - field_name: version 59 | label: Version 60 | validators: ignore_missing unicode_safe package_version_validator 61 | form_placeholder: '1.0' 62 | 63 | resource_fields: 64 | 65 | - field_name: url 66 | label: URL 67 | preset: resource_url_upload 68 | 69 | - field_name: name 70 | label: Name 71 | form_placeholder: eg. January 2011 Gold Prices 72 | 73 | - field_name: description 74 | label: Description 75 | form_snippet: markdown.html 76 | form_placeholder: Some useful notes about the data 77 | 78 | - field_name: format 79 | label: Format 80 | preset: resource_format_autocomplete 81 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_formpages_draft.yaml: -------------------------------------------------------------------------------- 1 | dataset_type: test-formpages-draft 2 | about: The default CKAN dataset schema with form split across multiple pages 3 | with required fields only enforced when publishing. See 4 | https://github.com/ckan/ckanext-scheming?tab=readme-ov-file#draft_fields_required 5 | about_url: https://github.com/ckan/ckanext-scheming 6 | 7 | draft_fields_required: false 8 | 9 | dataset_fields: 10 | 11 | - start_form_page: 12 | title: First Page 13 | description: Required and core dataset fields 14 | 15 | field_name: title 16 | label: Title 17 | preset: title 18 | form_placeholder: eg. A descriptive title 19 | 20 | - field_name: name 21 | label: URL 22 | preset: dataset_slug 23 | form_placeholder: eg. my-dataset 24 | 25 | - field_name: notes 26 | label: Description 27 | form_snippet: markdown.html 28 | form_placeholder: eg. Some useful notes about the data 29 | required: true 30 | validators: scheming_required unicode_safe 31 | 32 | - field_name: owner_org 33 | label: Organization 34 | preset: dataset_organization 35 | 36 | - field_name: test_first 37 | label: Test First 38 | 39 | - start_form_page: 40 | title: Second Page 41 | description: 42 | These fields improve search and give users important links 43 | 44 | field_name: test_second 45 | label: Test Second 46 | 47 | - field_name: tag_string 48 | label: Tags 49 | preset: tag_string_autocomplete 50 | form_placeholder: eg. economy, mental health, government 51 | 52 | - field_name: license_id 53 | label: License 54 | form_snippet: license.html 55 | help_text: License definitions and additional information can be found at http://opendefinition.org/ 56 | 57 | - field_name: url 58 | label: Source 59 | form_placeholder: http://example.com/dataset.json 60 | display_property: foaf:homepage 61 | display_snippet: link.html 62 | 63 | - field_name: version 64 | label: Version 65 | validators: ignore_missing unicode_safe package_version_validator 66 | form_placeholder: '1.0' 67 | required: true 68 | validators: scheming_required unicode_safe package_version_validator 69 | 70 | resource_fields: 71 | 72 | - field_name: url 73 | label: URL 74 | preset: resource_url_upload 75 | 76 | - field_name: name 77 | label: Name 78 | form_placeholder: eg. January 2011 Gold Prices 79 | required: true 80 | validators: scheming_required unicode_safe 81 | 82 | - field_name: description 83 | label: Description 84 | form_snippet: markdown.html 85 | form_placeholder: Some useful notes about the data 86 | 87 | - field_name: format 88 | label: Format 89 | preset: resource_format_autocomplete 90 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_group_display.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from ckan.tests.factories import Sysadmin, Organization, Group 3 | 4 | 5 | @pytest.mark.usefixtures("clean_db") 6 | class TestOrganizationDisplay(object): 7 | def test_organization_displays_custom_fields(self, app): 8 | user = Sysadmin() 9 | Organization(user=user, name="org-one", department_id="3008") 10 | 11 | response = app.get("/organization/about/org-one") 12 | assert "Department ID" in response.body 13 | 14 | 15 | @pytest.mark.usefixtures("clean_db") 16 | class TestGroupDisplay(object): 17 | def test_group_displays_custom_fields(self, app): 18 | user = Sysadmin() 19 | Group(user=user, name="group-one", bookface="theoneandonly") 20 | 21 | response = app.get("/group/about/group-one") 22 | assert "Bookface" in response.body 23 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_group_logic.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from ckanapi import LocalCKAN, NotFound 3 | import ckan.plugins.toolkit as tk 4 | 5 | 6 | @pytest.mark.usefixtures("with_plugins") 7 | class TestGroupSchemaLists(object): 8 | def test_group_schema_list(self): 9 | lc = LocalCKAN("visitor") 10 | group_schemas = lc.action.scheming_group_schema_list() 11 | assert sorted(group_schemas) == ["group", "theme"] 12 | 13 | def test_group_schema_show(self): 14 | lc = LocalCKAN("visitor") 15 | schema = lc.action.scheming_group_schema_show(type="group") 16 | assert schema["fields"][4]["label"] == "Bookface" 17 | 18 | def test_group_schema_not_found(self): 19 | lc = LocalCKAN("visitor") 20 | with pytest.raises(NotFound): 21 | lc.action.scheming_group_schema_show(type="bert") 22 | 23 | def test_organization_schema_list(self): 24 | lc = LocalCKAN("visitor") 25 | org_schemas = lc.action.scheming_organization_schema_list() 26 | assert sorted(org_schemas) == ["organization", "publisher"] 27 | 28 | def test_organization_schema_show(self): 29 | lc = LocalCKAN("visitor") 30 | schema = lc.action.scheming_organization_schema_show( 31 | type="organization" 32 | ) 33 | assert schema["fields"][4]["label"] == "Department ID" 34 | 35 | def test_organization_schema_not_found(self): 36 | lc = LocalCKAN("visitor") 37 | with pytest.raises(NotFound): 38 | lc.action.scheming_organization_schema_show(type="elmo") 39 | 40 | @pytest.mark.usefixtures("clean_db") 41 | def test_is_organization_flag_set_via_web_form( 42 | self, 43 | faker, 44 | app, 45 | user, 46 | api_token_factory, 47 | ): 48 | lc = LocalCKAN("visitor") 49 | token = api_token_factory(user=user["name"]) 50 | 51 | group_name = faker.slug() 52 | app.post(tk.url_for("theme.new"), data={ 53 | "name": group_name, 54 | }, headers={ 55 | "Authorization": token["token"], 56 | }) 57 | 58 | group = lc.action.group_show(id=group_name) 59 | assert not group["is_organization"] 60 | 61 | org_name = faker.slug() 62 | app.post(tk.url_for("publisher.new"), data={ 63 | "name": org_name, 64 | }, headers={ 65 | "Authorization": token["token"], 66 | }) 67 | 68 | org = lc.action.organization_show(id=org_name) 69 | assert org["is_organization"] 70 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf-8 3 | try: 4 | from unittest.mock import patch, Mock 5 | except ImportError: 6 | from mock import patch, Mock 7 | 8 | import datetime 9 | import six 10 | 11 | from ckanext.scheming.helpers import ( 12 | scheming_language_text, 13 | scheming_field_required, 14 | scheming_get_preset, 15 | scheming_get_presets, 16 | scheming_datastore_choices, 17 | scheming_display_json_value, 18 | ) 19 | 20 | from ckanapi import NotFound 21 | 22 | 23 | class TestLanguageText(object): 24 | @patch("ckanext.scheming.helpers._") 25 | def test_pass_through_gettext(self, _): 26 | _.side_effect = lambda x: x + "1" 27 | assert "hello1" == scheming_language_text("hello") 28 | 29 | def test_only_one_language(self): 30 | assert "hello" == scheming_language_text( 31 | {"zh": "hello"}, prefer_lang="en" 32 | ) 33 | 34 | def test_matching_language(self): 35 | assert "hello" == scheming_language_text( 36 | {"en": "hello", "aa": "aaaa"}, prefer_lang="en" 37 | ) 38 | 39 | def test_first_when_no_matching_language(self): 40 | assert "hello" == scheming_language_text( 41 | {"aa": "hello", "bb": "no"}, prefer_lang="en" 42 | ) 43 | 44 | def test_decodes_utf8(self): 45 | assert u"\xa1Hola!" == scheming_language_text(six.b("\xc2\xa1Hola!")) 46 | 47 | @patch("ckanext.scheming.helpers.lang") 48 | def test_no_user_lang(self, lang): 49 | lang.side_effect = TypeError() 50 | assert "hello" == scheming_language_text({"en": "hello", "aa": "aaaa"}) 51 | 52 | 53 | class TestFieldRequired(object): 54 | def test_explicit_required_true(self): 55 | assert scheming_field_required({"required": True}) 56 | 57 | def test_explicit_required_false(self): 58 | assert not scheming_field_required({"required": False}) 59 | 60 | def test_not_empty_in_validators(self): 61 | assert scheming_field_required({"validators": "not_empty unicode_safe"}) 62 | 63 | def test_not_empty_not_in_validators(self): 64 | assert not scheming_field_required({"validators": "maybe_not_empty"}) 65 | 66 | 67 | class TestGetPreset(object): 68 | def test_scheming_get_presets(self): 69 | presets = scheming_get_presets() 70 | assert sorted( 71 | ( 72 | u'title', 73 | u'tag_string_autocomplete', 74 | u'select', 75 | u'resource_url_upload', 76 | u'organization_url_upload', 77 | u'resource_format_autocomplete', 78 | u'multiple_select', 79 | u'multiple_checkbox', 80 | u'multiple_text', 81 | u'date', 82 | u'datetime', 83 | u'datetime_tz', 84 | u'dataset_slug', 85 | u'dataset_organization', 86 | u'json_object', 87 | u'markdown', 88 | u'radio', 89 | ) 90 | ) == sorted(presets.keys()) 91 | 92 | def test_scheming_get_preset(self): 93 | preset = scheming_get_preset(u"date") 94 | assert sorted( 95 | ( 96 | (u"display_snippet", u"date.html"), 97 | (u"form_snippet", u"date.html"), 98 | ( 99 | u"validators", 100 | u"scheming_required isodate convert_to_json_if_date", 101 | ), 102 | ) 103 | ) == sorted(preset.items()) 104 | 105 | 106 | class TestDatastoreChoices(object): 107 | @patch("ckanext.scheming.helpers.LocalCKAN") 108 | def test_no_choices_on_not_found(self, LocalCKAN): 109 | lc = Mock() 110 | lc.action.datastore_search.side_effect = NotFound() 111 | LocalCKAN.return_value = lc 112 | assert ( 113 | scheming_datastore_choices( 114 | {"datastore_choices_resource": "not-found"} 115 | ) 116 | == [] 117 | ) 118 | lc.action.datastore_search.assert_called_once() 119 | 120 | @patch("ckanext.scheming.helpers.LocalCKAN") 121 | def test_no_choices_on_not_authorized(self, LocalCKAN): 122 | lc = Mock() 123 | lc.action.datastore_search.side_effect = NotFound() 124 | LocalCKAN.return_value = lc 125 | assert ( 126 | scheming_datastore_choices( 127 | {"datastore_choices_resource": "not-allowed"} 128 | ) 129 | == [] 130 | ) 131 | lc.action.datastore_search.assert_called_once() 132 | 133 | @patch("ckanext.scheming.helpers.LocalCKAN") 134 | def test_no_choices_on_not_authorized(self, LocalCKAN): 135 | lc = Mock() 136 | lc.action.datastore_search.side_effect = NotFound() 137 | LocalCKAN.return_value = lc 138 | assert ( 139 | scheming_datastore_choices( 140 | {"datastore_choices_resource": "not-allowed"} 141 | ) 142 | == [] 143 | ) 144 | lc.action.datastore_search.assert_called_once() 145 | 146 | @patch("ckanext.scheming.helpers.LocalCKAN") 147 | def test_simple_call_with_defaults(self, LocalCKAN): 148 | lc = Mock() 149 | lc.action.datastore_search.return_value = { 150 | "fields": [{"id": "_id"}, {"id": "a"}, {"id": "b"}], 151 | "records": [{"a": "one", "b": "two"}, {"a": "three", "b": "four"}], 152 | } 153 | LocalCKAN.return_value = lc 154 | assert scheming_datastore_choices( 155 | {"datastore_choices_resource": "simple-one"} 156 | ) == [ 157 | {"value": "one", "label": "two"}, 158 | {"value": "three", "label": "four"}, 159 | ] 160 | 161 | LocalCKAN.asset_called_once_with(username="") 162 | lc.action.datastore_search.assert_called_once_with( 163 | resource_id="simple-one", limit=1000, fields=None 164 | ) 165 | 166 | @patch("ckanext.scheming.helpers.LocalCKAN") 167 | def test_call_with_all_params(self, LocalCKAN): 168 | lc = Mock() 169 | lc.action.datastore_search.return_value = { 170 | "records": [{"a": "one", "b": "two"}, {"a": "three", "b": "four"}] 171 | } 172 | LocalCKAN.return_value = lc 173 | assert scheming_datastore_choices( 174 | { 175 | "datastore_choices_resource": "all-params", 176 | "datastore_choices_limit": 5, 177 | "datastore_choices_columns": {"value": "a", "label": "b"}, 178 | "datastore_additional_choices": 179 | [{"value": "none", "label": "None"}, 180 | {"value": "na", "label": "N/A"}] 181 | } 182 | ) == [ 183 | {"value": "none", "label": "None"}, 184 | {"value": "na", "label": "N/A"}, 185 | {"value": "one", "label": "two"}, 186 | {"value": "three", "label": "four"}, 187 | ] 188 | 189 | LocalCKAN.asset_called_once_with(username="") 190 | lc.action.datastore_search.assert_called_once_with( 191 | resource_id="all-params", limit=5, fields=["a", "b"] 192 | ) 193 | 194 | 195 | class TestJSONHelpers(object): 196 | def test_display_json_value_default(self): 197 | 198 | value = {"a": "b"} 199 | 200 | assert scheming_display_json_value(value) == '{\n "a": "b"\n}' 201 | 202 | def test_display_json_value_indent(self): 203 | 204 | value = {"a": "b"} 205 | 206 | assert ( 207 | scheming_display_json_value(value, indent=4) 208 | == '{\n "a": "b"\n}' 209 | ) 210 | 211 | def test_display_json_value_no_indent(self): 212 | 213 | value = {"a": "b"} 214 | 215 | assert scheming_display_json_value(value, indent=None) == '{"a": "b"}' 216 | 217 | def test_display_json_value_keys_are_sorted(self): 218 | 219 | value = {"c": "d", "a": "b"} 220 | if six.PY3: 221 | expected = '{\n "a": "b",\n "c": "d"\n}' 222 | else: 223 | expected = '{\n "a": "b", \n "c": "d"\n}' 224 | 225 | assert ( 226 | scheming_display_json_value(value, indent=4) == expected 227 | ) 228 | 229 | def test_display_json_value_json_error(self): 230 | 231 | date = datetime.datetime.now() 232 | value = ("a", date) 233 | 234 | assert scheming_display_json_value(value) == ("a", date) 235 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_load.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ckanext.scheming.plugins import _load_schema 4 | from ckanext.scheming.errors import SchemingException 5 | 6 | 7 | class TestLoadSchema(object): 8 | def test_invalid_module(self): 9 | with pytest.raises(SchemingException): 10 | _load_schema("verybad.nogood:schema") 11 | 12 | def test_invalid_format(self): 13 | with pytest.raises(ValueError): 14 | _load_schema("ckanext.scheming:__init__.py") 15 | 16 | def test_url_to_schema(self): 17 | assert ( 18 | _load_schema( 19 | "https://raw.githubusercontent.com/ckan/ckanext-scheming/" 20 | "master/ckanext/scheming/camel_photos.yaml" 21 | )["dataset_type"] 22 | == "camel-photos" 23 | ) 24 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "dataset_type": "test-schema", 3 | "about_url": "http://github.com/ckan/ckanext-scheming", 4 | "dataset_fields": [ 5 | { 6 | "field_name": "title", 7 | "label": "Title", 8 | "preset": "title", 9 | "form_placeholder": "eg. Larry, Peter, Susan" 10 | }, 11 | { 12 | "field_name": "name", 13 | "label": "URL", 14 | "preset": "dataset_slug", 15 | "form_placeholder": "eg. camel-no-5" 16 | }, 17 | { 18 | "field_name": "humps", 19 | "label": "Humps", 20 | "validators": "ignore_missing int_validator", 21 | "form_placeholder": "eg. 2" 22 | }, 23 | { 24 | "field_name": "category", 25 | "label": "Category", 26 | "help_text": "Make and model", 27 | "help_inline": true, 28 | "preset": "select", 29 | "choices": [ 30 | { 31 | "value": "bactrian", 32 | "label": "Bactrian Camel" 33 | }, 34 | { 35 | "value": "hybrid", 36 | "label": "Hybrid Camel" 37 | }, 38 | { 39 | "value": "f2hybrid", 40 | "label": "F2 Hybrid Camel" 41 | }, 42 | { 43 | "value": "snowwhite", 44 | "label": "Snow-white Dromedary" 45 | }, 46 | { 47 | "value": "black", 48 | "label": "Black Camel" 49 | } 50 | ] 51 | }, 52 | { 53 | "field_name": "personality", 54 | "label": "Personality", 55 | "preset": "multiple_checkbox", 56 | "choices_helper": "scheming_test_schema_choices" 57 | }, 58 | { 59 | "field_name": "a_relevant_date", 60 | "label": "A relevant date", 61 | "preset": "date" 62 | }, 63 | { 64 | "field_name": "a_relevant_datetime", 65 | "label": "Date+Time", 66 | "label_time": "Time (combined with above)", 67 | "preset": "datetime" 68 | }, 69 | { 70 | "field_name": "a_relevant_datetime_tz", 71 | "label": "Date+Time+Tz", 72 | "label_time": "Time (for above)", 73 | "label_tz": "Timezone (for above)", 74 | "preset": "datetime_tz" 75 | }, 76 | { 77 | "field_name": "other", 78 | "label": {"en": "Other information"}, 79 | "output_validators": "ignore_missing" 80 | }, 81 | { 82 | "label": "Example JSON", 83 | "field_name": "a_json_field", 84 | "preset": "json_object" 85 | } 86 | ], 87 | "resource_fields": [ 88 | { 89 | "field_name": "url", 90 | "label": "Photo", 91 | "preset": "resource_url_upload", 92 | "form_placeholder": "http://example.com/my-camel-photo.jpg", 93 | "upload_label": "Photo" 94 | }, 95 | { 96 | "field_name": "camels_in_photo", 97 | "label": "Camels in Photo", 98 | "validators": "ignore_missing int_validator", 99 | "form_placeholder": "eg. 2" 100 | }, 101 | { 102 | "field_name": "others_in_photo", 103 | "label": "Other Thing in Photo", 104 | "output_validators": "ignore_missing" 105 | }, 106 | { 107 | "field_name": "date", 108 | "label": "Date", 109 | "preset": "date" 110 | }, 111 | { 112 | "field_name": "datetime", 113 | "label": "Date Taken", 114 | "label_time": "Time Taken", 115 | "preset": "datetime" 116 | }, 117 | { 118 | "field_name": "datetime_tz", 119 | "label": "Date Taken", 120 | "label_time": "Time Taken", 121 | "preset": "datetime_tz" 122 | }, 123 | { 124 | "label": "Example JSON Resource", 125 | "field_name": "a_resource_json_field", 126 | "preset": "json_object" 127 | } 128 | ] 129 | } 130 | -------------------------------------------------------------------------------- /ckanext/scheming/tests/test_subfields.yaml: -------------------------------------------------------------------------------- 1 | dataset_type: test-subfields 2 | about: Example dataset schema with simple and repeating subfields 3 | about_url: https://github.com/ckan/ckanext-scheming 4 | 5 | 6 | dataset_fields: 7 | 8 | - field_name: title 9 | label: Title 10 | preset: title 11 | form_placeholder: eg. A descriptive title 12 | required: True 13 | 14 | - field_name: name 15 | label: URL 16 | preset: dataset_slug 17 | form_placeholder: eg. my-dataset 18 | 19 | - field_name: citation 20 | label: Citation 21 | repeating_subfields: 22 | - field_name: originator 23 | label: Originator 24 | preset: multiple_text 25 | form_blanks: 3 26 | required: true 27 | - field_name: publication_date 28 | label: Publication Date 29 | preset: date 30 | 31 | - field_name: contact_address 32 | label: Contact Address 33 | repeating_subfields: 34 | - field_name: address 35 | label: Address 36 | required: true 37 | - field_name: city 38 | label: City 39 | - field_name: state 40 | label: State 41 | - field_name: postal_code 42 | label: Postal Code 43 | - field_name: country 44 | label: Country 45 | 46 | 47 | resource_fields: 48 | 49 | - field_name: url 50 | label: URL 51 | preset: resource_url_upload 52 | 53 | - field_name: name 54 | label: Title 55 | form_placeholder: Descriptive name of the resource. 56 | 57 | - field_name: description 58 | label: Description 59 | form_snippet: markdown.html 60 | form_placeholder: Summary explanation of file contents, purpose, origination, methods and usage guidance. 61 | 62 | - field_name: schedule 63 | label: Schedule 64 | repeating_subfields: 65 | - field_name: impact 66 | label: Impact 67 | preset: select 68 | choices: 69 | - label: All 70 | value: A 71 | - label: Partial 72 | value: P 73 | - label: Corrections 74 | value: C 75 | required: true 76 | - field_name: frequency 77 | label: Frequency 78 | preset: select 79 | choices: 80 | - label: Daily 81 | value: 1d 82 | - label: Weekly 83 | value: 7d 84 | - label: Monthly 85 | value: 1m 86 | - label: Quarterly 87 | value: 3m 88 | - label: Semiannual 89 | value: 6m 90 | - label: Annual 91 | value: 1y 92 | - label: Decennial 93 | value: 10y 94 | 95 | - field_name: format 96 | label: Format 97 | preset: resource_format_autocomplete 98 | -------------------------------------------------------------------------------- /ckanext/scheming/validation.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import json 3 | import datetime 4 | from collections import defaultdict 5 | import itertools 6 | 7 | import pytz 8 | import six 9 | 10 | import ckan.lib.helpers as h 11 | from ckan.lib.navl.dictization_functions import convert 12 | from ckan.plugins.toolkit import ( 13 | get_validator, 14 | UnknownValidator, 15 | missing, 16 | Invalid, 17 | StopOnError, 18 | _, 19 | ) 20 | 21 | import ckanext.scheming.helpers as sh 22 | from ckanext.scheming.errors import SchemingException 23 | 24 | OneOf = get_validator('OneOf') 25 | ignore_missing = get_validator('ignore_missing') 26 | not_empty = get_validator('not_empty') 27 | unicode_safe = get_validator('unicode_safe') 28 | 29 | all_validators = {} 30 | 31 | 32 | def register_validator(fn): 33 | """ 34 | collect validator functions into ckanext.scheming.all_helpers dict 35 | """ 36 | all_validators[fn.__name__] = fn 37 | return fn 38 | 39 | 40 | def scheming_validator(fn): 41 | """ 42 | Decorate a validator that needs to have the scheming fields 43 | passed with this function. When generating navl validator lists 44 | the function decorated will be called passing the field 45 | and complete schema to produce the actual validator for each field. 46 | """ 47 | fn.is_a_scheming_validator = True 48 | return fn 49 | 50 | 51 | register_validator(unicode_safe) 52 | 53 | 54 | @register_validator 55 | def strip_value(value): 56 | ''' 57 | **starting from CKAN 2.10 this is included in CKAN core** 58 | ''' 59 | return value.strip() 60 | 61 | 62 | @scheming_validator 63 | @register_validator 64 | def scheming_choices(field, schema): 65 | """ 66 | Require that one of the field choices values is passed. 67 | """ 68 | OneOf = get_validator('OneOf') 69 | if 'choices' in field: 70 | return OneOf([c['value'] for c in field['choices']]) 71 | 72 | def validator(value): 73 | if value is missing or not value: 74 | return value 75 | choices = sh.scheming_field_choices(field) 76 | for choice in choices: 77 | if value == choice['value']: 78 | return value 79 | raise Invalid(_('unexpected choice "%s"') % value) 80 | 81 | return validator 82 | 83 | 84 | @scheming_validator 85 | @register_validator 86 | def scheming_required(field, schema): 87 | """ 88 | return a validator based on field['required'] 89 | and schema['draft_fields_required'] setting 90 | """ 91 | if not field.get('required'): 92 | return get_validator('ignore_missing') 93 | if not schema.get('draft_fields_required', True): 94 | return get_validator('scheming_draft_fields_not_required') 95 | return get_validator('not_empty') 96 | 97 | 98 | @register_validator 99 | def scheming_draft_fields_not_required(key, data, errors, context): 100 | """ 101 | call ignore_missing if state is draft, otherwise not_empty 102 | """ 103 | state = data.get(('state',), missing) 104 | if state is missing or state.startswith('draft'): 105 | v = get_validator('ignore_missing') 106 | else: 107 | v = get_validator('not_empty') 108 | v(key, data, errors, context) 109 | 110 | 111 | @scheming_validator 112 | @register_validator 113 | def scheming_multiple_choice(field, schema): 114 | """ 115 | Accept zero or more values from a list of choices and convert 116 | to a json list for storage: 117 | 118 | 1. a list of strings, eg.: 119 | 120 | ["choice-a", "choice-b"] 121 | 122 | 2. a single string for single item selection in form submissions: 123 | 124 | "choice-a" 125 | """ 126 | static_choice_values = None 127 | if 'choices' in field: 128 | static_choice_order = [c['value'] for c in field['choices']] 129 | static_choice_values = set(static_choice_order) 130 | 131 | def validator(key, data, errors, context): 132 | # if there was an error before calling our validator 133 | # don't bother with our validation 134 | if errors[key]: 135 | return 136 | 137 | value = data[key] 138 | if value is not missing: 139 | if isinstance(value, six.string_types): 140 | value = [value] 141 | elif not isinstance(value, list): 142 | errors[key].append(_('expecting list of strings')) 143 | return 144 | else: 145 | value = [] 146 | 147 | choice_values = static_choice_values 148 | if not choice_values: 149 | choice_order = [ 150 | choice['value'] 151 | for choice in sh.scheming_field_choices(field) 152 | ] 153 | choice_values = set(choice_order) 154 | 155 | selected = set() 156 | for element in value: 157 | if element in choice_values: 158 | selected.add(element) 159 | continue 160 | errors[key].append(_('unexpected choice "%s"') % element) 161 | 162 | if not errors[key]: 163 | data[key] = json.dumps([ 164 | v for v in 165 | (static_choice_order if static_choice_values else choice_order) 166 | if v in selected 167 | ]) 168 | 169 | if field.get('required') and not selected: 170 | errors[key].append(_('Select at least one')) 171 | 172 | return validator 173 | 174 | 175 | def validate_date_inputs(field, key, data, extras, errors, context): 176 | date_error = _('Date format incorrect') 177 | time_error = _('Time format incorrect') 178 | 179 | date = None 180 | 181 | def get_input(suffix): 182 | inpt = key[0] + '_' + suffix 183 | new_key = (inpt,) + tuple(x for x in key if x != key[0]) 184 | key_value = extras.get(inpt) 185 | data[new_key] = key_value 186 | errors[new_key] = [] 187 | 188 | if key_value: 189 | del extras[inpt] 190 | 191 | if field.get('required'): 192 | not_empty(new_key, data, errors, context) 193 | 194 | return new_key, key_value 195 | 196 | date_key, value = get_input('date') 197 | value_full = '' 198 | 199 | if value: 200 | try: 201 | value_full = value 202 | date = h.date_str_to_datetime(value) 203 | except (TypeError, ValueError) as e: 204 | errors[date_key].append(date_error) 205 | 206 | time_key, value = get_input('time') 207 | if value: 208 | if not value_full: 209 | errors[date_key].append( 210 | _('Date is required when a time is provided')) 211 | else: 212 | try: 213 | value_full += ' ' + value 214 | date = h.date_str_to_datetime(value_full) 215 | except (TypeError, ValueError) as e: 216 | errors[time_key].append(time_error) 217 | 218 | tz_key, value = get_input('tz') 219 | if value: 220 | if value not in pytz.all_timezones: 221 | errors[tz_key].append('Invalid timezone') 222 | else: 223 | if isinstance(date, datetime.datetime): 224 | date = pytz.timezone(value).localize(date) 225 | 226 | return date 227 | 228 | 229 | @scheming_validator 230 | @register_validator 231 | def scheming_isodatetime(field, schema): 232 | def validator(key, data, errors, context): 233 | value = data[key] 234 | date = None 235 | 236 | if value: 237 | if isinstance(value, datetime.datetime): 238 | return value 239 | else: 240 | try: 241 | date = h.date_str_to_datetime(value) 242 | except (TypeError, ValueError) as e: 243 | raise Invalid(_('Date format incorrect')) 244 | else: 245 | extras = data.get(('__extras',)) 246 | if not extras or (key[0] + '_date' not in extras and 247 | key[0] + '_time' not in extras): 248 | if field.get('required'): 249 | not_empty(key, data, errors, context) 250 | else: 251 | date = validate_date_inputs( 252 | field, key, data, extras, errors, context) 253 | 254 | data[key] = date 255 | 256 | return validator 257 | 258 | 259 | @scheming_validator 260 | @register_validator 261 | def scheming_isodatetime_tz(field, schema): 262 | def validator(key, data, errors, context): 263 | value = data[key] 264 | date = None 265 | 266 | if value: 267 | if isinstance(value, datetime.datetime): 268 | date = sh.scheming_datetime_to_utc(value) 269 | else: 270 | try: 271 | date = sh.date_tz_str_to_datetime(value) 272 | except (TypeError, ValueError) as e: 273 | raise Invalid(_('Date format incorrect')) 274 | else: 275 | if 'resources' in key and len(key) > 1: 276 | # when a resource is edited, extras will be under a different key in the data 277 | extras = data.get((('resources', key[1], '__extras'))) 278 | # the key for the current field also looks different for a resource, 279 | # for example, a dataset might have the key ('start_timestamp') 280 | # for a resource this might look like ('resources', 3, 'start_timestamp') 281 | # however, we need to pass on a tuple with just the field name 282 | field_name_index_in_key = 2 283 | 284 | else: 285 | extras = data.get(('__extras',)) 286 | field_name_index_in_key = 0 287 | 288 | if not extras or ( 289 | ( 290 | key[field_name_index_in_key] + '_date' not in extras 291 | and key[field_name_index_in_key] + '_time' not in extras 292 | ) 293 | ): 294 | if field.get('required'): 295 | not_empty(key, data, errors, context) 296 | else: 297 | date = validate_date_inputs( 298 | field=field, 299 | key=(key[field_name_index_in_key],), 300 | data=data, 301 | extras=extras, 302 | errors=errors, 303 | context=context, 304 | ) 305 | if isinstance(date, datetime.datetime): 306 | date = sh.scheming_datetime_to_utc(date) 307 | 308 | data[key] = date 309 | 310 | return validator 311 | 312 | 313 | @register_validator 314 | def scheming_valid_json_object(value, context): 315 | """Store a JSON object as a serialized JSON string 316 | 317 | It accepts two types of inputs: 318 | 1. A valid serialized JSON string (it must be an object or a list) 319 | 2. An object that can be serialized to JSON 320 | 321 | """ 322 | if not value: 323 | return 324 | elif isinstance(value, six.string_types): 325 | try: 326 | loaded = json.loads(value) 327 | 328 | if not isinstance(loaded, dict): 329 | raise Invalid( 330 | _('Unsupported value for JSON field: {}').format(value) 331 | ) 332 | 333 | return value 334 | except (ValueError, TypeError) as e: 335 | raise Invalid(_('Invalid JSON string: {}').format(e)) 336 | 337 | elif isinstance(value, dict): 338 | try: 339 | return json.dumps(value) 340 | except (ValueError, TypeError) as e: 341 | raise Invalid(_('Invalid JSON object: {}').format(e)) 342 | else: 343 | raise Invalid( 344 | _('Unsupported type for JSON field: {}').format(type(value)) 345 | ) 346 | 347 | 348 | @register_validator 349 | def scheming_load_json(value, context): 350 | if isinstance(value, six.string_types): 351 | try: 352 | return json.loads(value) 353 | except ValueError: 354 | return value 355 | return value 356 | 357 | 358 | @register_validator 359 | def scheming_multiple_choice_output(value): 360 | """ 361 | return stored json as a proper list 362 | """ 363 | if isinstance(value, list): 364 | return value 365 | try: 366 | return json.loads(value) 367 | except ValueError: 368 | return [value] 369 | 370 | 371 | def validators_from_string(s, field, schema): 372 | """ 373 | convert a schema validators string to a list of validators 374 | 375 | e.g. "if_empty_same_as(name) unicode_safe" becomes: 376 | [if_empty_same_as("name"), unicode_safe] 377 | """ 378 | out = [] 379 | parts = s.split() 380 | for p in parts: 381 | if '(' in p and p[-1] == ')': 382 | name, args = p.split('(', 1) 383 | args = args[:-1] # trim trailing ')' 384 | try: 385 | parsed_args = ast.literal_eval(args) 386 | if not isinstance(parsed_args, tuple) or not parsed_args: 387 | # it's a signle argument. `not parsed_args` means that this single 388 | # argument is an empty tuple, for example: "default(())" 389 | parsed_args = (parsed_args,) 390 | 391 | except (ValueError, TypeError, SyntaxError, MemoryError): 392 | parsed_args = args.split(',') 393 | 394 | v = get_validator_or_converter(name)(*parsed_args) 395 | else: 396 | v = get_validator_or_converter(p) 397 | if getattr(v, 'is_a_scheming_validator', False): 398 | v = v(field, schema) 399 | out.append(v) 400 | return out 401 | 402 | 403 | def get_validator_or_converter(name): 404 | """ 405 | Get a validator or converter by name 406 | """ 407 | if name == 'unicode': 408 | return six.text_type 409 | try: 410 | v = get_validator(name) 411 | return v 412 | except UnknownValidator: 413 | pass 414 | raise SchemingException('validator/converter not found: %r' % name) 415 | 416 | 417 | def convert_from_extras_group(key, data, errors, context): 418 | """Converts values from extras, tailored for groups.""" 419 | 420 | def remove_from_extras(data, key): 421 | to_remove = [] 422 | for data_key, data_value in data.items(): 423 | if (data_key[0] == 'extras' 424 | and data_key[1] == key): 425 | to_remove.append(data_key) 426 | for item in to_remove: 427 | del data[item] 428 | 429 | for data_key, data_value in data.items(): 430 | if (data_key[0] == 'extras' 431 | and 'key' in data_value 432 | and data_value['key'] == key[-1]): 433 | data[key] = data_value['value'] 434 | break 435 | else: 436 | return 437 | remove_from_extras(data, data_key[1]) 438 | 439 | 440 | @register_validator 441 | def convert_to_json_if_date(date, context): 442 | if isinstance(date, datetime.datetime): 443 | return date.date().isoformat() 444 | elif isinstance(date, datetime.date): 445 | return date.isoformat() 446 | else: 447 | return date 448 | 449 | 450 | @register_validator 451 | def convert_to_json_if_datetime(date, context): 452 | if isinstance(date, datetime.datetime): 453 | return date.isoformat() 454 | 455 | return date 456 | 457 | 458 | @scheming_validator 459 | @register_validator 460 | def scheming_multiple_text(field, schema): 461 | """ 462 | Accept repeating text input in the following forms and convert to a json list 463 | for storage. Also act like scheming_required to check for at least one non-empty 464 | string when required is true: 465 | 466 | 1. a list of strings, eg. 467 | 468 | ["Person One", "Person Two"] 469 | 470 | 2. a single string value to allow single text fields to be 471 | migrated to repeating text 472 | 473 | "Person One" 474 | """ 475 | def _scheming_multiple_text(key, data, errors, context): 476 | # just in case there was an error before our validator, 477 | # bail out here because our errors won't be useful 478 | if errors[key]: 479 | return 480 | 481 | value = data[key] 482 | # 1. list of strings or 2. single string 483 | if value is not missing: 484 | if isinstance(value, six.string_types): 485 | value = [value] 486 | if not isinstance(value, list): 487 | errors[key].append(_('expecting list of strings')) 488 | raise StopOnError 489 | 490 | out = [] 491 | for element in value: 492 | if not element: 493 | continue 494 | 495 | if not isinstance(element, six.string_types): 496 | errors[key].append(_('invalid type for repeating text: %r') 497 | % element) 498 | continue 499 | if isinstance(element, six.binary_type): 500 | try: 501 | element = element.decode('utf-8') 502 | except UnicodeDecodeError: 503 | errors[key]. append(_('invalid encoding for "%s" value') 504 | % element) 505 | continue 506 | 507 | out.append(element) 508 | 509 | if errors[key]: 510 | raise StopOnError 511 | 512 | data[key] = json.dumps(out) 513 | 514 | if (data[key] is missing or data[key] == '[]'): 515 | if field.get('required'): 516 | errors[key].append(_('Missing value')) 517 | raise StopOnError 518 | data[key] = '[]' 519 | 520 | return _scheming_multiple_text 521 | 522 | 523 | @register_validator 524 | def repeating_text_output(value): 525 | """ 526 | Return stored json representation as a list, if 527 | value is already a list just pass it through. 528 | """ 529 | if isinstance(value, list): 530 | return value 531 | if value is None: 532 | return [] 533 | try: 534 | return json.loads(value) 535 | except ValueError: 536 | return [value] 537 | -------------------------------------------------------------------------------- /ckanext/scheming/views.py: -------------------------------------------------------------------------------- 1 | 2 | from flask import Response, Blueprint 3 | from flask.views import MethodView 4 | from werkzeug.datastructures import MultiDict 5 | 6 | from ckan.plugins.toolkit import ( 7 | h, request, get_action, abort, _, ObjectNotFound, NotAuthorized, 8 | ValidationError, 9 | ) 10 | 11 | try: 12 | from ckan.views.dataset import CreateView, EditView, _tag_string_to_list, _form_save_redirect 13 | 14 | # FIXME these not available from toolkit 15 | from ckan.lib.navl.dictization_functions import unflatten, DataError 16 | from ckan.logic import clean_dict, tuplize_dict, parse_params 17 | except ImportError: 18 | # older ckan, just don't fail at import time 19 | CreateView = MethodView 20 | EditView = MethodView 21 | 22 | 23 | def _clean_page(package_type, page): 24 | '''return int page or raise ValueError if invalid for package_type''' 25 | page = int(page) 26 | if page < 1 or len(h.scheming_get_dataset_form_pages(package_type)) < page: 27 | raise ValueError('page number out of range') 28 | return page 29 | 30 | 31 | class SchemingCreateView(CreateView): 32 | ''' 33 | View for creating datasets with form pages 34 | ''' 35 | def post(self, package_type): 36 | rval = super(SchemingCreateView, self).post(package_type) 37 | if getattr(rval, 'status_code', None) == 302: 38 | # successful create, send to page 2 instead of resource new page 39 | return h.redirect_to( 40 | '{}.scheming_new_page'.format(package_type), 41 | id=request.form['name'], 42 | page=2, 43 | ) 44 | return rval 45 | 46 | 47 | class SchemingCreatePageView(CreateView): 48 | ''' 49 | Handle dataset form pages using package_patch 50 | ''' 51 | def get(self, package_type, id, page): 52 | try: 53 | page = _clean_page(package_type, page) 54 | except ValueError: 55 | return abort(404, _('Page not found')) 56 | try: 57 | data = get_action('package_show')(None, {'id': id}) 58 | except (NotAuthorized, ObjectNotFound): 59 | return abort(404, _('Dataset not found')) 60 | 61 | data['_form_page'] = page 62 | return super(SchemingCreatePageView, self).get(package_type, data) 63 | 64 | def post(self, package_type, id, page): 65 | try: 66 | page = _clean_page(package_type, page) 67 | except ValueError: 68 | return abort(404, _('Page not found')) 69 | try: 70 | data = get_action('package_show')(None, {'id': id}) 71 | except (NotAuthorized, ObjectNotFound): 72 | return abort(404, _('Dataset not found')) 73 | 74 | # BEGIN: roughly copied from ckan/views/dataset.py 75 | try: 76 | data_dict = clean_dict( 77 | unflatten(tuplize_dict(parse_params(request.form))) 78 | ) 79 | except DataError: 80 | return abort(400, _(u'Integrity Error')) 81 | if u'tag_string' in data_dict: 82 | data_dict[u'tags'] = _tag_string_to_list( 83 | data_dict[u'tag_string'] 84 | ) 85 | data_dict.pop('pkg_name', None) 86 | data_dict['state'] = 'draft' 87 | # END: roughly copied from ckan/views/dataset.py 88 | 89 | data_dict['id'] = id 90 | try: 91 | complete_data = get_action('package_patch')(None, data_dict) 92 | except ObjectNotFound: 93 | return abort(404, _('Dataset not found')) 94 | except NotAuthorized: 95 | return abort(403, _(u'Unauthorized to update a dataset')) 96 | except ValidationError as e: 97 | # BEGIN: roughly copied from ckan/views/dataset.py 98 | errors = e.error_dict 99 | error_summary = e.error_summary 100 | data_dict[u'state'] = data[u'state'] 101 | data_dict['_form_page'] = page 102 | 103 | return EditView().get( 104 | package_type, 105 | id, 106 | data_dict, 107 | errors, 108 | error_summary 109 | ) 110 | # END: roughly copied from ckan/views/dataset.py 111 | 112 | if page == len(h.scheming_get_dataset_form_pages(package_type)): 113 | # BEGIN: roughly copied from ckan/views/dataset.py 114 | if 'resource_fields' in h.scheming_get_dataset_schema(package_type): 115 | return h.redirect_to( 116 | '{}_resource.new'.format(package_type), 117 | id=data['name'], 118 | ) 119 | return h.redirect_to( 120 | '{}.read'.format(package_type), 121 | id=data['name'], 122 | ) 123 | # END: roughly copied from ckan/views/dataset.py 124 | 125 | return h.redirect_to( 126 | '{}.scheming_new_page'.format(package_type), 127 | id=complete_data['name'], 128 | page=page + 1, 129 | ) 130 | 131 | 132 | def edit(package_type, id): 133 | if h.scheming_get_dataset_form_pages(package_type): 134 | return h.redirect_to( 135 | '{}.scheming_edit_page'.format(package_type), 136 | id=id, 137 | page=1 138 | ) 139 | return EditView().get(package_type, id) 140 | 141 | 142 | class SchemingEditPageView(EditView): 143 | ''' 144 | Handle dataset form pages using package_patch 145 | ''' 146 | def get(self, package_type, id, page): 147 | try: 148 | page = _clean_page(package_type, page) 149 | except ValueError: 150 | return abort(404, _('Page not found')) 151 | try: 152 | data = get_action('package_show')(None, {'id': id}) 153 | except (NotAuthorized, ObjectNotFound): 154 | return abort(404, _('Dataset not found')) 155 | 156 | return super(SchemingEditPageView, self).get(package_type, id, {'_form_page':page}) 157 | 158 | def post(self, package_type, id, page): 159 | try: 160 | page = _clean_page(package_type, page) 161 | except ValueError: 162 | return abort(404, _('Page not found')) 163 | try: 164 | data = get_action('package_show')(None, {'id': id}) 165 | except (NotAuthorized, ObjectNotFound): 166 | return abort(404, _('Dataset not found')) 167 | 168 | # BEGIN: roughly copied from ckan/views/dataset.py 169 | try: 170 | data_dict = clean_dict( 171 | unflatten(tuplize_dict(parse_params(request.form))) 172 | ) 173 | except DataError: 174 | return abort(400, _(u'Integrity Error')) 175 | if u'tag_string' in data_dict: 176 | data_dict[u'tags'] = _tag_string_to_list( 177 | data_dict[u'tag_string'] 178 | ) 179 | data_dict.pop('pkg_name', None) 180 | # END: roughly copied from ckan/views/dataset.py 181 | 182 | data_dict['id'] = id 183 | try: 184 | complete_data = get_action('package_patch')(None, data_dict) 185 | except ObjectNotFound: 186 | return abort(404, _('Dataset not found')) 187 | except NotAuthorized: 188 | return abort(403, _(u'Unauthorized to update a dataset')) 189 | except ValidationError as e: 190 | # BEGIN: roughly copied from ckan/views/dataset.py 191 | errors = e.error_dict 192 | error_summary = e.error_summary 193 | data_dict[u'state'] = data[u'state'] 194 | data_dict['_form_page'] = page 195 | 196 | return EditView().get( 197 | package_type, 198 | id, 199 | data_dict, 200 | errors, 201 | error_summary 202 | ) 203 | 204 | return _form_save_redirect( 205 | complete_data['name'], 'edit', package_type=package_type 206 | ) 207 | # END: roughly copied from ckan/views/dataset.py 208 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | 3 | filterwarnings = 4 | ignore::sqlalchemy.exc.SADeprecationWarning 5 | ignore::sqlalchemy.exc.SAWarning 6 | ignore::DeprecationWarning 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | version = '3.1.0' 4 | 5 | setup( 6 | name='ckanext-scheming', 7 | version=version, 8 | description="Easy, sharable custom CKAN schemas", 9 | long_description=""" 10 | This CKAN extension provides a way to configure and share metadata schemas using a 11 | YAML or JSON schema description. Custom validation and template snippets for editing 12 | and display are supported. 13 | 14 | Originally developed for the Government of Canada's custom metadata schema, part of 15 | https://github.com/open-data/ckanext-canada 16 | """, 17 | classifiers=[], 18 | keywords='ckan', 19 | author='Ian Ward', 20 | author_email='ian@excess.org', 21 | url='https://github.com/ckan/ckanext-scheming', 22 | license='MIT', 23 | packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), 24 | namespace_packages=['ckanext'], 25 | include_package_data=True, 26 | zip_safe=False, 27 | install_requires=[ 28 | 'pyyaml', 29 | 'ckanapi', 30 | 'pytz', 31 | 'six', 32 | ], 33 | entry_points=\ 34 | """ 35 | [ckan.plugins] 36 | scheming_datasets=ckanext.scheming.plugins:SchemingDatasetsPlugin 37 | scheming_groups=ckanext.scheming.plugins:SchemingGroupsPlugin 38 | scheming_organizations=ckanext.scheming.plugins:SchemingOrganizationsPlugin 39 | scheming_nerf_index=ckanext.scheming.plugins:SchemingNerfIndexPlugin 40 | scheming_test_subclass=ckanext.scheming.tests.plugins:SchemingTestSubclass 41 | scheming_test_plugin=ckanext.scheming.tests.plugins:SchemingTestSchemaPlugin 42 | scheming_test_validation=ckanext.scheming.tests.plugins:SchemingTestValidationPlugin 43 | """, 44 | ) 45 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4 2 | factory-boy 3 | pytest 4 | pytest-ckan 5 | pytest-cov 6 | -------------------------------------------------------------------------------- /test.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | debug = false 3 | smtp_server = localhost 4 | error_email_from = paste@localhost 5 | 6 | [server:main] 7 | use = egg:Paste#http 8 | host = 0.0.0.0 9 | port = 5000 10 | 11 | [app:main] 12 | use = config:../../src/ckan/test-core.ini 13 | 14 | ckan.plugins = scheming_datasets scheming_groups scheming_organizations 15 | scheming_test_plugin scheming_nerf_index 16 | scheming.dataset_schemas = ckanext.scheming:ckan_dataset.yaml 17 | ckanext.scheming.tests:test_schema.json 18 | ckanext.scheming.tests:test_subfields.yaml 19 | ckanext.scheming.tests:test_datastore_choices.json 20 | ckanext.scheming.tests:test_formpages.yaml 21 | ckanext.scheming.tests:test_formpages_draft.yaml 22 | scheming.organization_schemas = ckanext.scheming:org_with_dept_id.json 23 | ckanext.scheming:custom_org_with_address.json 24 | scheming.group_schemas = ckanext.scheming:group_with_bookface.json 25 | ckanext.scheming:custom_group_with_status.json 26 | 27 | ckan.site_logo = /img/logo_64px_wide.png 28 | ckan.favicon = /images/icons/ckan.ico 29 | ckan.gravatar_default = identicon 30 | 31 | ckan.legacy_templates = no 32 | 33 | 34 | # Logging configuration 35 | [loggers] 36 | keys = root, ckan, sqlalchemy 37 | 38 | [handlers] 39 | keys = console 40 | 41 | [formatters] 42 | keys = generic 43 | 44 | [logger_root] 45 | level = WARN 46 | handlers = console 47 | 48 | [logger_ckan] 49 | qualname = ckan 50 | handlers = 51 | level = INFO 52 | 53 | [logger_sqlalchemy] 54 | handlers = 55 | qualname = sqlalchemy.engine 56 | level = WARN 57 | 58 | [handler_console] 59 | class = StreamHandler 60 | args = (sys.stdout,) 61 | level = NOTSET 62 | formatter = generic 63 | 64 | [formatter_generic] 65 | format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s 66 | -------------------------------------------------------------------------------- /test_subclass.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | debug = true 3 | smtp_server = localhost 4 | error_email_from = paste@localhost 5 | 6 | [server:main] 7 | use = egg:Paste#http 8 | host = 0.0.0.0 9 | port = 5000 10 | 11 | [app:main] 12 | use = config:../ckan/test-core.ini 13 | 14 | ckan.plugins = scheming_test_subclass 15 | scheming_test_plugin 16 | scheming.dataset_schemas = ckanext.scheming:ckan_dataset.yaml 17 | ckanext.scheming.tests:test_schema.json 18 | ckanext.scheming.tests:test_datastore_choices.json 19 | 20 | ckan.site_logo = /img/logo_64px_wide.png 21 | ckan.favicon = /images/icons/ckan.ico 22 | ckan.gravatar_default = identicon 23 | 24 | ckan.legacy_templates = no 25 | 26 | 27 | # Logging configuration 28 | [loggers] 29 | keys = root, ckan, sqlalchemy 30 | 31 | [handlers] 32 | keys = console 33 | 34 | [formatters] 35 | keys = generic 36 | 37 | [logger_root] 38 | level = WARN 39 | handlers = console 40 | 41 | [logger_ckan] 42 | qualname = ckan 43 | handlers = 44 | level = INFO 45 | 46 | [logger_sqlalchemy] 47 | handlers = 48 | qualname = sqlalchemy.engine 49 | level = WARN 50 | 51 | [handler_console] 52 | class = StreamHandler 53 | args = (sys.stdout,) 54 | level = NOTSET 55 | formatter = generic 56 | 57 | [formatter_generic] 58 | format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s 59 | --------------------------------------------------------------------------------