diff --git a/.travis.yml b/.travis.yml index debf9925c02..c6503aca704 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,8 @@ python: env: - PGVERSION=9.1 - PGVERSION=8.4 -script: ./bin/travis-build +install: ./bin/travis-install-dependencies +script: ./bin/travis-run-tests notifications: irc: channels: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b97798a4522..1b827257416 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,10 +12,34 @@ v2.2 API changes and deprecations: +* The Solr schema file is now always named ``schema.xml`` regardless of the + CKAN version. Old schema files have been kept for backwards compatibility + but users are encouraged to point to the new unified one. +* The `ckan.api_url` has been completely removed and it can no longer be used * The edit() and after_update() methods of IPackageController plugins are now called when updating a resource using the web frontend or the resource_update API action [#1052] +* package_search now returns results with custom schemas applied like + package_show, a use_default_schema parameter was added to request the + old behaviour, this change may affect customized search result templates + (#1255) +v2.1.1 2013-11-8 +================ + +Bug fixes: + * Fix errors on preview on non-root locations (#960) + * Fix place-holder images on non-root locations (#1309) + * Don't accept invalid URLs in resource proxy (#1106) + * Make sure came_from url is local (#1039) + * Fix logout redirect in non-root locations (#1025) + * Wrong auth checks for sysadmins on package_create (#1184) + * Don't return private datasets on package_list (#1295) + * Stop tracking failing when no lang/encoding headers (#1192) + * Fix for paster db clean command getting frozen + * Fix organization not set when editing a dataset (#1199) + * Fix PDF previews (#1194) + * Fix preview failing on private datastore resources (#1221) v2.1 2013-08-13 =============== @@ -96,6 +120,18 @@ Known issues: * Under certain authorization setups the frontend for the groups functionality may not work as expected (See #1176 #1175). +v2.0.3 2013-11-8 +================ + +Bug fixes: + * Fix errors on preview on non-root locations (#960) + * Don't accept invalid URLs in resource proxy (#1106) + * Make sure came_from url is local (#1039) + * Fix logout redirect in non-root locations (#1025) + * Don't return private datasets on package_list (#1295) + * Stop tracking failing when no lang/encoding headers (#1192) + * Fix for paster db clean command getting frozen + v2.0.2 2013-08-13 ================= @@ -139,7 +175,7 @@ v2.0 2013-05-10 to GitHub issues. For example: * #3020 is http://trac.ckan.org/ticket/3020 - * #271 is https://github.com/okfn/ckan/issues/271 + * #271 is https://github.com/ckan/ckan/issues/271 Some GitHub issues URLs will redirect to GitHub pull request pages. @@ -164,7 +200,7 @@ Organizations based authorization (see :doc:`authorization`): * New authorization ini file options -New frontend (see :doc:`theming`): +New frontend (see :doc:`theming/index`): CKAN's frontend has been completely redesigned, inside and out. There is a new default theme and the template engine has moved from Genshi to Jinja2. Any custom templates using Genshi will need to be updated, although @@ -242,7 +278,7 @@ API: Other highlights: * CKAN now has continuous integration testing at - https://travis-ci.org/okfn/ckan/ + https://travis-ci.org/ckan/ckan/ * Dataset pages now have `_. - -.. toctree:: - :hidden: - - i18n - -.. _coding standards: - ----------------- -Coding Standards ----------------- - -When writing code for CKAN, try to respect our coding standards: - -.. toctree:: - :hidden: - - ckan-coding-standards - python-coding-standards - html-coding-standards - css-coding-standards - javascript-coding-standards - testing-coding-standards - -* `CKAN coding standards `_ -* `Python coding standards `_ -* `HTML coding standards `_ -* `CSS coding standards `_ -* `JavaScript coding standards `_ -* `Testing coding standards `_ - - ---------------- -Commit Messages ---------------- - -Generally, follow the `commit guidelines from the Pro Git book`_: - -- Try to make each commit a logically separate, digestible changeset. - -- The first line of the commit message should concisely summarise the - changeset. - -- Optionally, follow with a blank line and then a more detailed explanation of - the changeset. - -- Use the imperative present tense as if you were giving commands to the - codebase to change its behaviour, e.g. *Add tests for...*, *make xyzzy do - frotz...*, this helps to make the commit message easy to read. - -.. _commit guidelines from the Pro Git book: http://git-scm.com/book/en/Distributed-Git-Contributing-to-a-Project#Commit-Guidelines - -If your commit has an issue in the `CKAN issue tracker`_ put the issue number -at the start of the first line of the commit message like this: ``[#123]``. -This makes the CKAN release manager's job much easier! - -Here's an example of a good CKAN commit message:: - - [#2505] Update source install instructions - - Following feedback from markw (see #2406). - - - - -------------------------------- -Frontend Development Guidelines -------------------------------- - -.. toctree:: - :hidden: - - frontend-development - templating - resources - template-tutorial - template-blocks - javascript-module-tutorial - -* `Frontend Development `_ -* `Templating `_ -* `Resources `_ -* `Template Tutorial `_ -* `Template Blocks `_ -* `JavaScript Module Tutorial `_ - - ---------------------- -Writing Documentation ---------------------- - -The quickest and easiest way to contribute documentation to CKAN is to sign up -for a free GitHub account and simply edit the `CKAN Wiki `_. -Docs started on the wiki can make it onto `docs.ckan.org`_ later. - -**Tip**: Use the reStructuredText markup format when creating a wiki page, -since reStructuredText is the format that docs.ckan.org uses, this will make -moving the documentation from the wiki into docs.ckan.org later easier. - -For how to contribute to the offical CKAN documentation at docs.ckan.org, see -the `documentation guidelines `_. - -.. toctree:: - :hidden: - - documentation-guidelines - - -.. _making a pull request: - ---------------------- -Making a Pull Request ---------------------- - -Once you've written some CKAN code or documentation, you can submit it for -review and merge into the central CKAN git repository by making a pull request. -This section will walk you through the steps for making a pull request. - - -#. Create a git branch - - Each logically separate piece of work (e.g. a new feature, a bug fix, a new - docs page, or a set of improvements to a docs page) should be developed on - its own branch forked from the master branch. - - The name of the branch should include the issue number (if this work has an - issue in the `CKAN issue tracker`_), and a brief one-line synopsis of the work, - for example:: - - 2298-add-sort-by-controls-to-search-page - - -#. Fork CKAN on GitHub - - Sign up for a free account on GitHub and - `fork CKAN `_, so that you - have somewhere to publish your work. - - Add your CKAN fork to your local CKAN git repo as a git remote. Replace - ``USERNAME`` with your GitHub username:: - - git remote add my_fork https://github.com/USERNAME/ckan - - -#. Commit and push your changes - - Commit your changes on your feature branch, and push your branch to GitHub. - For example, make sure you're currently on your feature branch then run - these commands:: - - git add doc/my_new_feature.rst - git commit -m "Add docs for my new feature" - git push my_fork my_branch - - When writing your git commit messages, try to follow the `Commit Messages`_ - guidelines. - - -#. Send a pull request - - Once your work on a branch is complete and is ready to be merged into the - master branch, `create a pull request on GitHub`_. A member of the CKAN - team will review your work and provide feedback on the pull request page. - The reviewer may ask you to make some changes. Once your pull request has - passed the review, the reviewer will merge your code into the master branch - and it will become part of CKAN! - - When submitting a pull request: - - - Your branch should contain one logically separate piece of work, and not - any unrelated changes. - - - You should have good commit messages, see `Commit Messages`_. - - - Your branch should contain new or changed tests for any new or changed - code, and all the CKAN tests should pass on your branch, see - `Testing CKAN `_. - - - Your branch should contain new or updated documentation for any new or - updated code, see `Writing Documentation`_. - - - Your branch should be up to date with the master branch of the central - CKAN repo, so pull the central master branch into your feature branch - before submitting your pull request. - - For long-running feature branches, it's a good idea to pull master into - the feature branch periodically so that the two branches don't diverge too - much. - -.. _create a pull request on GitHub: https://help.github.com/articles/creating-a-pull-request - - -Merging a Pull Request -====================== - -If you're reviewing a pull request for CKAN, when merging a branch into master: - -- Use the ``--no-ff`` option in the ``git merge`` command, +http://docs.ckan.org/en/latest/contributing diff --git a/MANIFEST.in b/MANIFEST.in index a0f2eb1d374..4043246a3df 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,9 +5,13 @@ recursive-include ckan/config *.xml recursive-include ckan/i18n * recursive-include ckan/templates * recursive-include ckan *.ini -recursive-include ckanext/**/public * -recursive-include ckanext/**/templates * -recursive-include ckanext/**/i18n * + +recursive-include ckanext/*/i18n * +recursive-include ckanext/*/public * +recursive-include ckanext/*/templates * +recursive-include ckanext/*/theme/public * +recursive-include ckanext/*/theme/templates * + prune .git include CHANGELOG.txt include ckan/migration/migrate.cfg diff --git a/README.rst b/README.rst index 9c810209231..abdea7faff5 100644 --- a/README.rst +++ b/README.rst @@ -1,19 +1,15 @@ CKAN: The Open Source Data Portal Software ========================================== -.. image:: https://secure.travis-ci.org/okfn/ckan.png?branch=master - :target: http://travis-ci.org/okfn/ckan +.. image:: https://secure.travis-ci.org/ckan/ckan.png?branch=master + :target: http://travis-ci.org/ckan/ckan :alt: Build Status -.. image:: https://coveralls.io/repos/okfn/ckan/badge.png - :target: https://coveralls.io/r/okfn/ckan - :alt: Test coverage - **CKAN is the world’s leading open-source data portal platform**. CKAN makes it easy to publish, share and work with data. It's a data management system that provides a powerful platform for cataloging, storing and accessing datasets with a rich front-end, full API (for both data and catalog), visualization -tools and more. Read more at `ckan.org `_. +tools and more. Read more at `ckan.org `_. Installation @@ -36,14 +32,14 @@ searching first to see if there's already an issue for your bug). .. _CKAN tag on Stack Overflow: http://stackoverflow.com/questions/tagged/ckan .. _ckan-discuss: http://lists.okfn.org/mailman/listinfo/ckan-discuss -.. _GitHub Issues: https://github.com/okfn/ckan/issues +.. _GitHub Issues: https://github.com/ckan/ckan/issues Contributing to CKAN -------------------- For contributing to CKAN or its documentation, see -`CONTRIBUTING `_. +`CONTRIBUTING `_. If you want to talk about CKAN development say hi to the CKAN developers on the `ckan-dev`_ mailing list or in the `#ckan`_ IRC channel on irc.freenode.net. @@ -54,7 +50,7 @@ others, make a new page on the `CKAN wiki`_, and tell us about it on .. _ckan-dev: http://lists.okfn.org/mailman/listinfo/ckan-dev .. _#ckan: http://webchat.freenode.net/?channels=ckan -.. _CKAN Wiki: https://github.com/okfn/ckan/wiki +.. _CKAN Wiki: https://github.com/ckan/ckan/wiki Copying and License diff --git a/bin/travis-build b/bin/travis-build deleted file mode 100755 index 63b4f50e339..00000000000 --- a/bin/travis-build +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/sh - -# Drop Travis' postgres cluster if we're building using a different pg version -TRAVIS_PGVERSION='9.1' -if [ $PGVERSION != $TRAVIS_PGVERSION ] -then - sudo -u postgres pg_dropcluster --stop $TRAVIS_PGVERSION main - # Make psql use $PGVERSION - export PGCLUSTER=$PGVERSION/main -fi - -# Install postgres and solr -# We need this ppa so we can install postgres-8.4 -sudo add-apt-repository -yy ppa:pitti/postgresql -sudo apt-get update -qq -sudo apt-get install solr-jetty postgresql-$PGVERSION - -sudo service postgresql reload - -# Setup postgres' users and databases -sudo -u postgres psql -c "CREATE USER ckan_default WITH PASSWORD 'pass';" -sudo -u postgres psql -c "CREATE USER datastore_default WITH PASSWORD 'pass';" -sudo -u postgres psql -c 'CREATE DATABASE ckan_test WITH OWNER ckan_default;' -sudo -u postgres psql -c 'CREATE DATABASE datastore_test WITH OWNER ckan_default;' - -export PIP_USE_MIRRORS=true -pip install -r requirements.txt -pip install -r dev-requirements.txt - -python setup.py develop - -# Install npm dpes for mocha -npm install -g mocha-phantomjs phantomjs - -# Configure Solr -echo "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty -# FIXME the solr schema cannot be hardcoded as it is dependent on the ckan version -sudo cp ckan/config/solr/schema-2.0.xml /etc/solr/conf/schema.xml -sudo service jetty restart - -paster db init -c test-core.ini - -# If Postgres >= 9.0, we don't need to use datastore's legacy mode. -if [ $PGVERSION != '8.4' ] -then - psql -c 'CREATE USER datastore_default;' -U postgres - sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/datastore_default@\/datastore_test/' test-core.ini - paster datastore set-permissions postgres -c test-core.ini -else - sed -i -e 's/.*datastore.read_url.*//' test-core.ini -fi - -cat test-core.ini - -# Run mocha front-end tests -# We need ckan to be running for some tests -paster serve test-core.ini & -sleep 5 # Make sure the server has fully started -mocha-phantomjs http://localhost:5000/base/test/index.html -# Did an error occur? -MOCHA_ERROR=$? -# We are done so kill ckan -killall paster - -# And finally, run the nosetests -nosetests --ckan --with-pylons=test-core.ini --nologcapture ckan ckanext --with-coverage --cover-package=ckanext --cover-package=ckan -# Did an error occur? -NOSE_ERROR=$? - -[ "0" -ne "$MOCHA_ERROR" ] && echo MOCKA tests have failed -[ "0" -ne "$NOSE_ERROR" ] && echo NOSE tests have failed - -# If an error occurred in our tests make sure travis knows -exit `expr $MOCHA_ERROR + $NOSE_ERROR` diff --git a/bin/travis-install-dependencies b/bin/travis-install-dependencies new file mode 100755 index 00000000000..e8cdeb89525 --- /dev/null +++ b/bin/travis-install-dependencies @@ -0,0 +1,53 @@ +#!/bin/bash + +# Exit immediately if any command fails +set -e + +# Drop Travis' postgres cluster if we're building using a different pg version +TRAVIS_PGVERSION='9.1' +if [ $PGVERSION != $TRAVIS_PGVERSION ] +then + sudo -u postgres pg_dropcluster --stop $TRAVIS_PGVERSION main + # Make psql use $PGVERSION + export PGCLUSTER=$PGVERSION/main +fi + +# Install postgres and solr +sudo apt-get update -qq +sudo apt-get install postgresql-$PGVERSION solr-jetty libcommons-fileupload-java:amd64=1.2.2-1 + +if [ $PGVERSION == '8.4' ] +then + # force postgres to use 5432 as it's port + sudo sed -i -e 's/port = 5433/port = 5432/g' /etc/postgresql/8.4/main/postgresql.conf +fi + +sudo service postgresql restart + +# Setup postgres' users and databases +sudo -u postgres psql -c "CREATE USER ckan_default WITH PASSWORD 'pass';" +sudo -u postgres psql -c "CREATE USER datastore_default WITH PASSWORD 'pass';" +sudo -u postgres psql -c 'CREATE DATABASE ckan_test WITH OWNER ckan_default;' +sudo -u postgres psql -c 'CREATE DATABASE datastore_test WITH OWNER ckan_default;' + +export PIP_USE_MIRRORS=true +pip install -r requirements.txt +pip install -r dev-requirements.txt + +python setup.py develop + +# Install npm dpes for mocha +npm install -g mocha-phantomjs phantomjs + +paster db init -c test-core.ini + +# If Postgres >= 9.0, we don't need to use datastore's legacy mode. +if [ $PGVERSION != '8.4' ] +then + sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/datastore_default:pass@\/datastore_test/' test-core.ini + paster datastore set-permissions postgres -c test-core.ini +else + sed -i -e 's/.*datastore.read_url.*//' test-core.ini +fi + +cat test-core.ini diff --git a/bin/travis-run-tests b/bin/travis-run-tests new file mode 100755 index 00000000000..5d60ea0646d --- /dev/null +++ b/bin/travis-run-tests @@ -0,0 +1,27 @@ +#!/bin/sh + +# Configure Solr +echo "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty +sudo cp ckan/config/solr/schema.xml /etc/solr/conf/schema.xml +sudo service jetty restart + +# Run mocha front-end tests +# We need ckan to be running for some tests +paster serve test-core.ini & +sleep 5 # Make sure the server has fully started +mocha-phantomjs http://localhost:5000/base/test/index.html +# Did an error occur? +MOCHA_ERROR=$? +# We are done so kill ckan +killall paster + +# And finally, run the nosetests +nosetests --ckan --reset-db --with-pylons=test-core.ini --nologcapture ckan ckanext +# Did an error occur? +NOSE_ERROR=$? + +[ "0" -ne "$MOCHA_ERROR" ] && echo MOCKA tests have failed +[ "0" -ne "$NOSE_ERROR" ] && echo NOSE tests have failed + +# If an error occurred in our tests make sure travis knows +exit `expr $MOCHA_ERROR + $NOSE_ERROR` diff --git a/ckan/ckan_nose_plugin.py b/ckan/ckan_nose_plugin.py index 2ec4d0988b9..877589a2204 100644 --- a/ckan/ckan_nose_plugin.py +++ b/ckan/ckan_nose_plugin.py @@ -19,21 +19,26 @@ def startContext(self, ctx): if 'new_tests' in repr(ctx): # We don't want to do the stuff below for new-style tests. + if not CkanNose.settings.reset_database: + model.repo.tables_created_and_initialised = True return if isclass(ctx): if hasattr(ctx, "no_db") and ctx.no_db: return - if self.is_first_test or CkanNose.settings.ckan_migration: + if (not CkanNose.settings.reset_database + and not CkanNose.settings.ckan_migration): + model.Session.close_all() + model.repo.tables_created_and_initialised = True + model.repo.rebuild_db() + self.is_first_test = False + elif self.is_first_test or CkanNose.settings.ckan_migration: model.Session.close_all() model.repo.clean_db() self.is_first_test = False if CkanNose.settings.ckan_migration: model.Session.close_all() model.repo.upgrade_db() - # init_db is run at the start of every class because - # when you use an in-memory sqlite db, it appears that - # the db is destroyed after every test when you Session.Remove(). ## This is to make sure the configuration is run again. ## Plugins use configure to make their own tables and they @@ -43,6 +48,9 @@ def startContext(self, ctx): for plugin in PluginImplementations(IConfigurable): plugin.configure(config) + # init_db is run at the start of every class because + # when you use an in-memory sqlite db, it appears that + # the db is destroyed after every test when you Session.Remove(). model.repo.init_db() def options(self, parser, env): @@ -66,6 +74,11 @@ def options(self, parser, env): dest='segments', help='A string containing a hex digits that represent which of' 'the 16 test segments to run. i.e 15af will run segments 1,5,a,f') + parser.add_option( + '--reset-db', + action='store_true', + dest='reset_database', + help='drop database and reinitialize before tests are run') def wantClass(self, cls): name = cls.__name__ diff --git a/ckan/config/deployment.ini_tmpl b/ckan/config/deployment.ini_tmpl index 67d369af28d..b15d1c5657b 100644 --- a/ckan/config/deployment.ini_tmpl +++ b/ckan/config/deployment.ini_tmpl @@ -44,8 +44,6 @@ who.log_file = %(cache_dir)s/who_log.ini ## Database Settings sqlalchemy.url = postgresql://ckan_default:pass@localhost/ckan_default -#sqlalchemy.url = sqlite:/// -#sqlalchemy.url = sqlite:///%(here)s/somedb.db #ckan.datastore.write_url = postgresql://ckan_default:pass@localhost/datastore_default #ckan.datastore.read_url = postgresql://datastore_default:pass@localhost/datastore_default @@ -66,6 +64,8 @@ ckan.auth.user_create_organizations = true ckan.auth.user_delete_groups = true ckan.auth.user_delete_organizations = true ckan.auth.create_user_via_api = false +ckan.auth.create_user_via_web = true +ckan.auth.roles_that_cascade_to_sub_groups = admin ## Search Settings @@ -120,35 +120,16 @@ ckan.feeds.author_link = ## Storage Settings -# Local file storage: -#ofs.impl = pairtree -#ofs.storage_dir = /var/lib/ckan/default - -# Google cloud storage: -#ofs.impl = google -#ofs.gs_access_key_id = -#ofs.gs_secret_access_key = - -# S3 cloud storage: -#ofs.impl = s3 -#ofs.aws_access_key_id = .... -#ofs.aws_secret_access_key = .... - -# 'Bucket' to use for file storage -#ckan.storage.bucket = default - -# Prefix for uploaded files (only used for pairtree) -#ckan.storage.key_prefix = file/ - -# The maximum content size, in bytes, for uploads -#ckan.storage.max_content_length = 50000000 +#ckan.storage_path = /var/lib/ckan +#ckan.max_resource_size = 10 +#ckan.max_image_size = 2 ## Datapusher settings # Make sure you have set up the DataStore -ckan.datapusher.formats = csv -ckan.datapusher.url = http://datapusher.ckan.org/ +#ckan.datapusher.formats = +#ckan.datapusher.url = http://127.0.0.1:8800/ ## Activity Streams Settings diff --git a/ckan/config/environment.py b/ckan/config/environment.py index 409f77435a4..0a1269c64df 100644 --- a/ckan/config/environment.py +++ b/ckan/config/environment.py @@ -367,8 +367,10 @@ def template_loaded(template): # Here we create the site user if they are not already in the database try: logic.get_action('get_site_user')({'ignore_auth': True}, None) - except sqlalchemy.exc.ProgrammingError: - # The database is not initialised. This is a bit dirty. + except (sqlalchemy.exc.ProgrammingError, sqlalchemy.exc.OperationalError): + # (ProgrammingError for Postgres, OperationalError for SQLite) + # The database is not initialised. This is a bit dirty. This occurs + # when running tests. pass except sqlalchemy.exc.InternalError: # The database is not initialised. Travis hits this diff --git a/ckan/config/middleware.py b/ckan/config/middleware.py index 4df0aa84cb0..ba3f1f15d2e 100644 --- a/ckan/config/middleware.py +++ b/ckan/config/middleware.py @@ -4,6 +4,7 @@ import logging import json import hashlib +import os import sqlalchemy as sa from beaker.middleware import CacheMiddleware, SessionMiddleware @@ -17,11 +18,13 @@ from routes.middleware import RoutesMiddleware from repoze.who.config import WhoConfig from repoze.who.middleware import PluggableAuthenticationMiddleware +from repoze.who.plugins.auth_tkt import make_plugin as auth_tkt_make_plugin from fanstatic import Fanstatic from ckan.plugins import PluginImplementations from ckan.plugins.interfaces import IMiddleware from ckan.lib.i18n import get_locales_from_config +import ckan.lib.uploader as uploader from ckan.config.environment import load_environment import ckan.lib.app_globals as app_globals @@ -147,6 +150,20 @@ def make_app(conf, full_stack=True, static_files=True, **app_conf): cache_max_age=static_max_age) static_parsers = [static_app, app] + storage_directory = uploader.get_storage_path() + if storage_directory: + path = os.path.join(storage_directory, 'storage') + try: + os.makedirs(path) + except OSError, e: + ## errno 17 is file already exists + if e.errno != 17: + raise + + storage_app = StaticURLParser(path, + cache_max_age=static_max_age) + static_parsers.insert(0, storage_app) + # Configurable extra static file paths extra_static_parsers = [] for public_path in config.get('extra_public_paths', '').split(','): @@ -167,6 +184,11 @@ def make_app(conf, full_stack=True, static_files=True, **app_conf): return app +def ckan_auth_tkt_make_app(**kw): + if not len(kw.get('secret', '')) or kw.get('secret') == 'somesecret': + kw['secret'] = config['beaker.session.secret'] + return auth_tkt_make_plugin(**kw) + class I18nMiddleware(object): """I18n Middleware selects the language based on the url diff --git a/ckan/config/routing.py b/ckan/config/routing.py index 040bf172180..d74403d574d 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -229,6 +229,7 @@ def make_map(): 'history_ajax', 'follow', 'activity', + 'groups', 'unfollow', 'delete', 'api_data', @@ -240,6 +241,8 @@ def make_map(): m.connect('dataset_activity', '/dataset/activity/{id}', action='activity', ckan_icon='time') m.connect('/dataset/activity/{id}/{offset}', action='activity') + m.connect('dataset_groups', '/dataset/groups/{id}', + action='groups', ckan_icon='group') m.connect('/dataset/{id}.{format}', action='read') m.connect('dataset_resources', '/dataset/resources/{id}', action='resources', ckan_icon='reorder') @@ -253,6 +256,8 @@ def make_map(): action='resource_edit', ckan_icon='edit') m.connect('/dataset/{id}/resource/{resource_id}/download', action='resource_download') + m.connect('/dataset/{id}/resource/{resource_id}/download/{filename}', + action='resource_download') m.connect('/dataset/{id}/resource/{resource_id}/embed', action='resource_embedded_dataviewer') m.connect('/dataset/{id}/resource/{resource_id}/viewer', diff --git a/ckan/config/solr/CHANGELOG.txt b/ckan/config/solr/CHANGELOG.txt deleted file mode 100644 index 769c6b53735..00000000000 --- a/ckan/config/solr/CHANGELOG.txt +++ /dev/null @@ -1,29 +0,0 @@ -CKAN SOLR schemas changelog -=========================== - -v2.0 - (ckan>=2.0) --------------------- -* Add _version_ field to make it compatible with solr 4.0 -* Remove stopwords -* Add dataset_type field. -* Add *_date autofield. - -v1.4 - (ckan>=1.7) --------------------- -* Add Ascii folding filter to text fields. -* Add capacity field for public, private access. -* Add title_string so you can sort alphabetically on title. -* Fields related to analytics, access and view counts. -* Add data_dict field for the whole package_dict. -* Add vocab_* dynamic field so it is possible to facet by vocabulary tags -* Add copyField for text with source vocab_* - -v1.3 - (ckan>=1.5.1) --------------------- -* Use the index_id (hash of dataset id + site_id) as uniqueKey (#1430) -* Store extras (#1455) -* Store dataset creation and modification date (#191) - -v1.2 - (ckan<=1.5) --------------------- -* Original version diff --git a/ckan/config/solr/README.txt b/ckan/config/solr/README.txt index 9a3bccebfac..d8fcff40f42 100644 --- a/ckan/config/solr/README.txt +++ b/ckan/config/solr/README.txt @@ -1,30 +1,12 @@ -CKAN SOLR schemas -================= +CKAN Solr schema +================ -This folder contains the latest and previous versions of the SOLR XML -schema files used by CKAN. These can be use on the SOLR server to -override the default SOLR schema. Please note that not all schemas are -backwards compatible with old CKAN versions. Check the CHANGELOG.txt file -in this same folder to check which version of the schema should you use -depending on the CKAN version you are using. +This folder contains the Solr schema file used by CKAN (schema.xml). -Developers, when pushing changes to the SOLR schema: +Starting from 2.2 this is the only file that should be used by users and +modified by devs. The rest of files (schema-{version}.xml) are kept for +backwards compatibility purposes and should not be used, as they might be +removed in future versions. -* Note that updates on the schema are only release based, i.e. all changes - in the schema between releases will be part of the same new version of - the schema. - -* Name the new version of the file using the following convention:: - - schema-.xml - -* Update the `version` attribute of the `schema` tag in the new file:: - - - -* Update the SUPPORTED_SCHEMA_VERSIONS list in `ckan/lib/search/__init__.py` - Consider if the changes introduced are or are not compatible with - previous schema versions. - -* Update the CHANGELOG.txt file with the new version, the CKAN version - required and changes made to the schema. +When upgrading CKAN, always check the CHANGELOG on each release to see if +you need to update the schema file and reindex your datasets. diff --git a/ckan/config/solr/schema-1.2.xml b/ckan/config/solr/schema-1.2.xml index 2fb41dd96a1..4f9b11a580b 100644 --- a/ckan/config/solr/schema-1.2.xml +++ b/ckan/config/solr/schema-1.2.xml @@ -1,4 +1,12 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +index_id +text + + + + + + + + + + + + + + + + + + + + + diff --git a/ckan/config/who.ini b/ckan/config/who.ini index 54528f27c3b..5282e20d9b1 100644 --- a/ckan/config/who.ini +++ b/ckan/config/who.ini @@ -1,6 +1,7 @@ [plugin:auth_tkt] -use = repoze.who.plugins.auth_tkt:make_plugin -secret = somesecret +use = ckan.config.middleware:ckan_auth_tkt_make_app +# If no secret key is defined here, beaker.session.secret will be used +#secret = somesecret [plugin:friendlyform] use = repoze.who.plugins.friendlyform:FriendlyFormPlugin diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index bcff15841a5..08ba3a8e641 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -839,7 +839,9 @@ def make_unicode(entity): cls.log.debug('Retrieving request POST: %r' % request.POST) cls.log.debug('Retrieving request GET: %r' % request.GET) request_data = None - if request.POST: + if request.POST and request.content_type == 'multipart/form-data': + request_data = dict(request.POST) + elif request.POST: try: keys = request.POST.keys() # Parsing breaks if there is a = in the value, so for now @@ -873,7 +875,7 @@ def make_unicode(entity): raise ValueError(msg) else: request_data = {} - if request_data: + if request_data and request.content_type != 'multipart/form-data': try: request_data = h.json.loads(request_data, encoding='utf8') except ValueError, e: diff --git a/ckan/controllers/feed.py b/ckan/controllers/feed.py index 42626ef3585..3ba8b050947 100644 --- a/ckan/controllers/feed.py +++ b/ckan/controllers/feed.py @@ -21,7 +21,6 @@ # TODO fix imports import logging import urlparse -from urllib import urlencode import webhelpers.feedgenerator from pylons import config @@ -277,8 +276,6 @@ def custom(self): search_params[param] = value fq += ' %s:"%s"' % (param, value) - search_url_params = urlencode(search_params) - try: page = int(request.params.get('page', 1)) except ValueError: @@ -307,6 +304,9 @@ def custom(self): controller='feed', action='custom') + atom_url = h._url_with_params('/feeds/custom.atom', + search_params.items()) + alternate_url = self._alternate_url(request.params) return self.output_feed(results, @@ -315,8 +315,7 @@ def custom(self): ' datasets on %s. Custom query: \'%s\'' % (g.site_title, q), feed_link=alternate_url, - feed_guid=_create_atom_id - (u'/feeds/custom.atom?%s' % search_url_params), + feed_guid=_create_atom_id(atom_url), feed_url=feed_url, navigation_urls=navigation_urls) @@ -376,11 +375,7 @@ def _feed_url(self, query, controller, action, **kwargs): parameters. """ path = h.url_for(controller=controller, action=action, **kwargs) - query = [(k, v.encode('utf-8') if isinstance(v, basestring) - else str(v)) for k, v in query.items()] - - # a trailing '?' is valid. - return self.base_url + path + u'?' + urlencode(query) + return h._url_with_params(self.base_url + path, query.items()) def _navigation_urls(self, query, controller, action, item_count, limit, **kwargs): diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index 66bd533fd43..54f92cb7b7e 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -1,6 +1,8 @@ import re +import os import logging import genshi +import cgi import datetime from urllib import urlencode @@ -166,6 +168,9 @@ def index(self): def read(self, id, limit=20): group_type = self._get_group_type(id.split('@')[0]) + if group_type != self.group_type: + abort(404, _('Incorrect group type')) + context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'schema': self._db_to_form_schema(group_type=group_type), @@ -176,6 +181,9 @@ def read(self, id, limit=20): q = c.q = request.params.get('q', '') try: + # Do not query for the group datasets when dictizing, as they will + # be ignored and get requested on the controller anyway + context['include_datasets'] = False c.group_dict = self._action('group_show')(context, data_dict) c.group = context['group'] except NotFound: @@ -278,10 +286,11 @@ def pager_url(q=None, page=None): facets = OrderedDict() - default_facet_titles = {'groups': _('Groups'), + default_facet_titles = {'organization': _('Organizations'), + 'groups': _('Groups'), 'tags': _('Tags'), 'res_format': _('Formats'), - 'license_id': _('License')} + 'license_id': _('Licenses')} for facet in g.facets: if facet in default_facet_titles: @@ -314,7 +323,8 @@ def pager_url(q=None, page=None): 'extras': search_extras } - query = get_action('package_search')(context, data_dict) + context_ = dict((k, v) for (k, v) in context.items() if k != 'schema') + query = get_action('package_search')(context_, data_dict) c.page = h.Page( collection=query['results'], @@ -324,6 +334,7 @@ def pager_url(q=None, page=None): items_per_page=limit ) + c.group_dict['package_count'] = query['count'] c.facets = query['facets'] maintain.deprecate_context_item('facets', 'Use `c.search_facets` instead.') @@ -344,6 +355,9 @@ def pager_url(q=None, page=None): c.facets = {} c.page = h.Page(collection=[]) + self._setup_template_variables(context, {'id':id}, + group_type=group_type) + def bulk_process(self, id): ''' Allow bulk processing of datasets for an organization. Make private/public or delete. For organization admins.''' @@ -363,6 +377,9 @@ def bulk_process(self, id): data_dict = {'id': id} try: + # Do not query for the group datasets when dictizing, as they will + # be ignored and get requested on the controller anyway + context['include_datasets'] = False c.group_dict = self._action('group_show')(context, data_dict) c.group = context['group'] except NotFound: @@ -370,16 +387,28 @@ def bulk_process(self, id): except NotAuthorized: abort(401, _('Unauthorized to read group %s') % id) - # Search within group - action = request.params.get('bulk_action') + #use different form names so that ie7 can be detected + form_names = set(["bulk_action.public", "bulk_action.delete", + "bulk_action.private"]) + actions_in_form = set(request.params.keys()) + actions = form_names.intersection(actions_in_form) # If no action then just show the datasets - if not action: + if not actions: # unicode format (decoded from utf8) limit = 500 self._read(id, limit) c.packages = c.page.items return render(self._bulk_process_template(group_type)) + #ie7 puts all buttons in form params but puts submitted one twice + for key, value in dict(request.params.dict_of_lists()).items(): + if len(value) == 2: + action = key.split('.')[-1] + break + else: + #normal good browser form submission + action = actions.pop().split('.')[-1] + # process the action first find the datasets to perform the action on. # they are prefixed by dataset_ in the form data datasets = [] @@ -421,6 +450,9 @@ def new(self, data=None, errors=None, error_summary=None): return self._save_new(context, group_type) data = data or {} + if not data.get('image_url', '').startswith('http'): + data.pop('image_url', None) + errors = errors or {} error_summary = error_summary or {} vars = {'data': data, 'errors': errors, @@ -520,7 +552,6 @@ def _save_edit(self, id, context): data_dict['id'] = id context['allow_partial_update'] = True group = self._action('group_update')(context, data_dict) - if id != group['name']: self._force_reindex(group) @@ -635,7 +666,10 @@ def member_new(self, id): else: c.user_role = 'member' c.group_dict = self._action('group_show')(context, {'id': id}) - c.roles = self._action('member_roles_list')(context, {}) + group_type = 'organization' if c.group_dict['is_organization'] else 'group' + c.roles = self._action('member_roles_list')( + context, {'group_type': group_type} + ) except NotAuthorized: abort(401, _('Unauthorized to add member to group %s') % '') except NotFound: @@ -752,11 +786,8 @@ def activity(self, id, offset=0): context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'for_view': True} - data_dict = {'id': id} - try: - c.group_dict = get_action('group_show')(context, data_dict) - c.group = context['group'] + c.group_dict = self._get_group_dict(id) except NotFound: abort(404, _('Group not found')) except NotAuthorized: @@ -766,10 +797,9 @@ def activity(self, id, offset=0): # Add the group's activity stream (already rendered to HTML) to the # template context for the group/read.html template to retrieve later. - c.group_activity_stream = get_action('group_activity_list_html')( + c.group_activity_stream = self._action('group_activity_list_html')( context, {'id': c.group_dict['id'], 'offset': offset}) - #return render('group/activity_stream.html') return render(self._activity_template(c.group_dict['type'])) def follow(self, id): @@ -827,8 +857,13 @@ def admins(self, id): return render(self._admins_template(c.group_dict['type'])) def about(self, id): + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author} c.group_dict = self._get_group_dict(id) - return render(self._about_template(c.group_dict['type'])) + group_type = c.group_dict['type'] + self._setup_template_variables(context, {'id': id}, + group_type=group_type) + return render(self._about_template(group_type)) def _get_group_dict(self, id): ''' returns the result of group_show action or aborts if there is a @@ -837,7 +872,7 @@ def _get_group_dict(self, id): 'user': c.user or c.author, 'for_view': True} try: - return get_action('group_show')(context, {'id': id}) + return self._action('group_show')(context, {'id': id}) except NotFound: abort(404, _('Group not found')) except NotAuthorized: diff --git a/ckan/controllers/home.py b/ckan/controllers/home.py index da3d4b3901b..33d5df95016 100644 --- a/ckan/controllers/home.py +++ b/ckan/controllers/home.py @@ -67,10 +67,11 @@ def index(self): c.search_facets = query['search_facets'] c.facet_titles = { + 'organization': _('Organizations'), 'groups': _('Groups'), 'tags': _('Tags'), 'res_format': _('Formats'), - 'license': _('License'), + 'license': _('Licenses'), } data_dict = {'sort': 'packages', 'all_fields': 1} @@ -178,7 +179,6 @@ def db_to_form_schema(group_type=None): c.group_package_stuff = dirty_cached_group_stuff # END OF DIRTYNESS - return base.render('home/index.html', cache_force=True) def license(self): diff --git a/ckan/controllers/organization.py b/ckan/controllers/organization.py index b44d95e057f..3907143a187 100644 --- a/ckan/controllers/organization.py +++ b/ckan/controllers/organization.py @@ -15,46 +15,5 @@ class OrganizationController(group.GroupController): # this makes us use organization actions group_type = 'organization' - def _group_form(self, group_type=None): - return 'organization/new_organization_form.html' - - def _form_to_db_schema(self, group_type=None): - return group.lookup_group_plugin(group_type).form_to_db_schema() - - def _db_to_form_schema(self, group_type=None): - '''This is an interface to manipulate data from the database - into a format suitable for the form (optional)''' - pass - - def _setup_template_variables(self, context, data_dict, group_type=None): - pass - - def _new_template(self, group_type): - return 'organization/new.html' - - def _about_template(self, group_type): - return 'organization/about.html' - - def _index_template(self, group_type): - return 'organization/index.html' - - def _admins_template(self, group_type): - return 'organization/admins.html' - - def _bulk_process_template(self, group_type): - return 'organization/bulk_process.html' - - def _read_template(self, group_type): - return 'organization/read.html' - - def _history_template(self, group_type): - return group.lookup_group_plugin(group_type).history_template() - - def _edit_template(self, group_type): - return 'organization/edit.html' - - def _activity_template(self, group_type): - return 'organization/activity_stream.html' - def _guess_group_type(self, expecting_name=False): return 'organization' diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index 44576d711b1..46227a1b9fc 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -1,11 +1,15 @@ import logging from urllib import urlencode import datetime +import os +import mimetypes +import cgi from pylons import config from genshi.template import MarkupTemplate from genshi.template.text import NewTextTemplate from paste.deploy.converters import asbool +import paste.fileapp import ckan.logic as logic import ckan.lib.base as base @@ -18,6 +22,7 @@ import ckan.model as model import ckan.lib.datapreview as datapreview import ckan.lib.plugins +import ckan.lib.uploader as uploader import ckan.plugins as p import ckan.lib.render @@ -221,7 +226,7 @@ def pager_url(q=None, page=None): 'groups': _('Groups'), 'tags': _('Tags'), 'res_format': _('Formats'), - 'license_id': _('License'), + 'license_id': _('Licenses'), } for facet in g.facets: @@ -551,7 +556,7 @@ def resource_edit(self, id, resource_id, data=None, errors=None, del data['save'] context = {'model': model, 'session': model.Session, - 'api_version': 3, + 'api_version': 3, 'for_edit': True, 'user': c.user or c.author, 'auth_user_obj': c.userobj} data['package_id'] = id @@ -572,7 +577,7 @@ def resource_edit(self, id, resource_id, data=None, errors=None, id=id, resource_id=resource_id)) context = {'model': model, 'session': model.Session, - 'api_version': 3, + 'api_version': 3, 'for_edit': True, 'user': c.user or c.author, 'auth_user_obj': c.userobj} pkg_dict = get_action('package_show')(context, {'id': id}) if pkg_dict['state'].startswith('draft'): @@ -622,7 +627,8 @@ def new_resource(self, id, data=None, errors=None, error_summary=None): # see if we have any data that we are trying to save data_provided = False for key, value in data.iteritems(): - if value and key != 'resource_type': + if ((value or isinstance(value, cgi.FieldStorage)) + and key != 'resource_type'): data_provided = True break @@ -1210,10 +1216,10 @@ def resource_read(self, id, resource_id): return render('package/resource_read.html', extra_vars=vars) - def resource_download(self, id, resource_id): + def resource_download(self, id, resource_id, filename=None): """ - Provides a direct download by redirecting the user to the url stored - against this resource. + Provides a direct download by either redirecting the user to the url stored + or downloading an uploaded file directly. """ context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'auth_user_obj': c.userobj} @@ -1226,7 +1232,20 @@ def resource_download(self, id, resource_id): except NotAuthorized: abort(401, _('Unauthorized to read resource %s') % id) - if not 'url' in rsc: + if rsc.get('url_type') == 'upload': + upload = uploader.ResourceUpload(rsc) + filepath = upload.get_path(rsc['id']) + fileapp = paste.fileapp.FileApp(filepath) + try: + status, headers, app_iter = request.call_application(fileapp) + except OSError: + abort(404, _('Resource data not found')) + response.headers.update(dict(headers)) + content_type, content_enc = mimetypes.guess_type(rsc.get('url','')) + response.headers['Content-Type'] = content_type + response.status = status + return app_iter + elif not 'url' in rsc: abort(404, _('No download is available')) redirect(rsc['url']) @@ -1289,6 +1308,66 @@ def followers(self, id=None): return render('package/followers.html') + def groups(self, id): + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author, 'for_view': True, + 'auth_user_obj': c.userobj, 'use_cache': False} + data_dict = {'id': id} + try: + c.pkg_dict = get_action('package_show')(context, data_dict) + except NotFound: + abort(404, _('Dataset not found')) + except NotAuthorized: + abort(401, _('Unauthorized to read dataset %s') % id) + + if request.method == 'POST': + new_group = request.POST.get('group_added') + if new_group: + data_dict = {"id": new_group, + "object": id, + "object_type": 'package', + "capacity": 'public'} + try: + get_action('member_create')(context, data_dict) + except NotFound: + abort(404, _('Group not found')) + + removed_group = None + for param in request.POST: + if param.startswith('group_remove'): + removed_group = param.split('.')[-1] + break + if removed_group: + data_dict = {"id": removed_group, + "object": id, + "object_type": 'package'} + + try: + get_action('member_delete')(context, data_dict) + except NotFound: + abort(404, _('Group not found')) + redirect(h.url_for(controller='package', + action='groups', id=id)) + + + + context['is_member'] = True + users_groups = get_action('group_list_authz')(context, data_dict) + + pkg_group_ids = set(group['id'] for group + in c.pkg_dict.get('groups', [])) + user_group_ids = set(group['id'] for group + in users_groups) + + c.group_dropdown = [[group['id'], group['display_name']] + for group in users_groups if + group['id'] not in pkg_group_ids] + + for group in c.pkg_dict.get('groups', []): + group['user_member'] = (group['id'] in user_group_ids) + + return render('package/group_list.html') + def activity(self, id): '''Render this package's public activity stream page.''' diff --git a/ckan/controllers/related.py b/ckan/controllers/related.py index 8b9e88cfc1d..3bc6b82acb8 100644 --- a/ckan/controllers/related.py +++ b/ckan/controllers/related.py @@ -40,7 +40,7 @@ def dashboard(self): base.abort(400, ('"page" parameter must be an integer')) # Update ordering in the context - query = logic.get_action('related_list')(context, data_dict) + related_list = logic.get_action('related_list')(context, data_dict) def search_url(params): url = h.url_for(controller='related', action='dashboard') @@ -55,10 +55,10 @@ def pager_url(q=None, page=None): return search_url(params) c.page = h.Page( - collection=query.all(), + collection=related_list, page=page, url=pager_url, - item_count=query.count(), + item_count=len(related_list), items_per_page=9 ) diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index 1c6fefff344..d50ae617dbb 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -68,7 +68,7 @@ def _setup_template_variables(self, context, data_dict): try: user_dict = get_action('user_show')(context, data_dict) except NotFound: - h.redirect_to(controller='user', action='login', id=None) + abort(404, _('User not found')) except NotAuthorized: abort(401, _('Not authorized to see this page')) c.user_dict = user_dict @@ -117,10 +117,6 @@ def read(self, id=None): 'for_view': True} data_dict = {'id': id, 'user_obj': c.userobj} - try: - check_access('user_show', context, data_dict) - except NotAuthorized: - abort(401, _('Not authorized to see this page')) context['with_related'] = True @@ -224,10 +220,17 @@ def _save_new(self, context): # Redirect to a URL picked up by repoze.who which performs the # login login_url = self._get_repoze_handler('login_handler_path') - h.redirect_to('%s?login=%s&password=%s' % ( + + # We need to pass the logged in URL as came_from parameter + # otherwise we lose the language setting + came_from = h.url_for(controller='user', action='logged_in', + __ckan_no_root=True) + redirect_url = '{0}?login={1}&password={2}&came_from={3}' + h.redirect_to(redirect_url.format( login_url, str(data_dict['name']), - quote(data_dict['password1'].encode('utf-8')))) + quote(data_dict['password1'].encode('utf-8')), + came_from)) else: # #1799 User has managed to register whilst logged in - warn user # they are not re-logged in as new user. @@ -455,7 +458,7 @@ def perform_reset(self, id): # reuse of the url context = {'model': model, 'session': model.Session, 'user': id, - 'keep_sensitive_data': True} + 'keep_email': True} try: check_access('user_reset', context) @@ -466,10 +469,6 @@ def perform_reset(self, id): data_dict = {'id': id} user_dict = get_action('user_show')(context, data_dict) - # Be a little paranoid, and get rid of sensitive data that's - # not needed. - user_dict.pop('apikey', None) - user_dict.pop('reset_key', None) user_obj = context['user_obj'] except NotFound, e: abort(404, _('User not found')) diff --git a/ckan/lib/accept.py b/ckan/lib/accept.py index 0817e7d5b0c..447797b4033 100644 --- a/ckan/lib/accept.py +++ b/ckan/lib/accept.py @@ -12,7 +12,6 @@ # Name : ContentType, Is Markup?, Extension "text/html": ("text/html; charset=utf-8", True, 'html'), "text/n3": ("text/n3; charset=utf-8", False, 'n3'), - "text/plain": ("text/plain; charset=utf-8", False, 'txt'), "application/rdf+xml": ("application/rdf+xml; charset=utf-8", True, 'rdf'), } accept_by_extension = { diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index 38c777f7555..38138e87815 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -44,7 +44,6 @@ 'ckan.template_footer_end': {}, 'ckan.dumps_url': {}, 'ckan.dumps_format': {}, - 'ckan.api_url': {}, 'ofs.impl': {'name': 'ofs_impl'}, 'ckan.homepage_style': {'default': '1'}, diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index 3bb47faed2c..b496c7c40d1 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -11,6 +11,8 @@ import ckan.lib.fanstatic_resources as fanstatic_resources import ckan.plugins as p import sqlalchemy as sa +import urlparse +import routes import paste.script from paste.registry import Registry @@ -135,6 +137,12 @@ def _load_config(self): self.translator_obj = MockTranslator() self.registry.register(pylons.translator, self.translator_obj) + ## give routes enough information to run url_for + parsed = urlparse.urlparse(conf.get('ckan.site_url', 'http://0.0.0.0')) + request_config = routes.request_config() + request_config.host = parsed.netloc + parsed.path + request_config.protocol = parsed.scheme + def _setup_app(self): cmd = paste.script.appinstall.SetupCommand('setup-app') cmd.run([self.filename]) @@ -158,6 +166,7 @@ class ManageDb(CkanCommand): db load-only FILE_PATH - load a pg_dump from a file but don\'t do the schema upgrade or search indexing db create-from-model - create database from the model (indexes not made) + db migrate-filestore - migrate all uploaded data from the 2.1 filesore. ''' summary = __doc__.split('\n')[0] usage = __doc__ @@ -215,6 +224,8 @@ def command(self): print 'Creating DB: SUCCESS' elif cmd == 'send-rdf': self.send_rdf() + elif cmd == 'migrate-filestore': + self.migrate_filestore() else: print 'Command %s not recognized' % cmd sys.exit(1) @@ -347,6 +358,45 @@ def send_rdf(self): talis = ckan.lib.talis.Talis() return talis.send_rdf(talis_store, username, password) + def migrate_filestore(self): + from ckan.model import Session + import requests + from ckan.lib.uploader import ResourceUpload + results = Session.execute("select id, revision_id, url from resource " + "where resource_type = 'file.upload' " + "and (url_type <> 'upload' or url_type is null)" + "and url like '%storage%'") + for id, revision_id, url in results: + response = requests.get(url, stream=True) + if response.status_code != 200: + print "failed to fetch %s (code %s)" % (url, + response.status_code) + continue + resource_upload = ResourceUpload({'id': id}) + assert resource_upload.storage_path, "no storage configured aborting" + + directory = resource_upload.get_directory(id) + filepath = resource_upload.get_path(id) + try: + os.makedirs(directory) + except OSError, e: + ## errno 17 is file already exists + if e.errno != 17: + raise + + with open(filepath, 'wb+') as out: + for chunk in response.iter_content(1024): + if chunk: + out.write(chunk) + + Session.execute("update resource set url_type = 'upload'" + "where id = '%s'" % id) + Session.execute("update resource_revision set url_type = 'upload'" + "where id = '%s' and " + "revision_id = '%s'" % (id, revision_id)) + Session.commit() + print "Saved url %s" % url + def version(self): from ckan.model import Session print Session.execute('select version from migrate_version;').fetchall() @@ -683,7 +733,7 @@ class UserCmd(CkanCommand): ''' summary = __doc__.split('\n')[0] usage = __doc__ - max_args = 4 + max_args = None min_args = 0 def command(self): @@ -1308,7 +1358,7 @@ class CreateTestDataCommand(CkanCommand): translations of terms create-test-data vocabs - annakerenina, warandpeace, and some test vocabularies - + create-test-data hierarchy - hierarchy of groups ''' summary = __doc__.split('\n')[0] usage = __doc__ @@ -1318,6 +1368,7 @@ class CreateTestDataCommand(CkanCommand): def command(self): self._load_config() self._setup_app() + from ckan import plugins from create_test_data import CreateTestData if self.args: @@ -1342,6 +1393,8 @@ def command(self): CreateTestData.create_translations_test_data() elif cmd == 'vocabs': CreateTestData.create_vocabs_test_data() + elif cmd == 'hierarchy': + CreateTestData.create_group_hierarchy_test_data() else: print 'Command %s not recognized' % cmd raise NotImplementedError diff --git a/ckan/lib/create_test_data.py b/ckan/lib/create_test_data.py index 6c7a275c36f..324531ee70d 100644 --- a/ckan/lib/create_test_data.py +++ b/ckan/lib/create_test_data.py @@ -38,6 +38,12 @@ def create_family_test_data(cls, extra_users=[]): relationships=family_relationships, extra_user_names=extra_users) + @classmethod + def create_group_hierarchy_test_data(cls, extra_users=[]): + cls.create_users(group_hierarchy_users) + cls.create_groups(group_hierarchy_groups) + cls.create_arbitrary(group_hierarchy_datasets) + @classmethod def create_test_user(cls): tester = model.User.by_name(u'tester') @@ -215,18 +221,26 @@ def create_arbitrary(cls, package_dicts, relationships=[], group = model.Group.by_name(unicode(group_name)) if not group: if not group_name in new_groups: - group = model.Group(name=unicode(group_name)) + group = model.Group(name= + unicode(group_name)) model.Session.add(group) new_group_names.add(group_name) new_groups[group_name] = group else: - # If adding multiple packages with the same group name, - # model.Group.by_name will not find the group as the - # session has not yet been committed at this point. - # Fetch from the new_groups dict instead. + # If adding multiple packages with the same + # group name, model.Group.by_name will not + # find the group as the session has not yet + # been committed at this point. Fetch from + # the new_groups dict instead. group = new_groups[group_name] - member = model.Member(group=group, table_id=pkg.id, table_name='package') + capacity = 'organization' if group.is_organization\ + else 'public' + member = model.Member(group=group, table_id=pkg.id, + table_name='package', + capacity=capacity) model.Session.add(member) + if group.is_organization: + pkg.owner_org = group.id elif attr == 'license': pkg.license_id = val elif attr == 'license_id': @@ -315,21 +329,21 @@ def pkg(pkg_name): @classmethod def create_groups(cls, group_dicts, admin_user_name=None, auth_profile=""): '''A more featured interface for creating groups. - All group fields can be filled, packages added and they can - have an admin user.''' + All group fields can be filled, packages added, can have + an admin user and be a member of other groups.''' rev = model.repo.new_revision() - # same name as user we create below rev.author = cls.author if admin_user_name: admin_users = [model.User.by_name(admin_user_name)] else: admin_users = [] assert isinstance(group_dicts, (list, tuple)) - group_attributes = set(('name', 'title', 'description', 'parent_id')) + group_attributes = set(('name', 'title', 'description', 'parent_id', + 'type', 'is_organization')) for group_dict in group_dicts: - if model.Group.by_name(group_dict['name']): - log.warning('Cannot create group "%s" as it already exists.' % \ - (group_dict['name'])) + if model.Group.by_name(unicode(group_dict['name'])): + log.warning('Cannot create group "%s" as it already exists.' % + group_dict['name']) continue pkg_names = group_dict.pop('packages', []) group = model.Group(name=unicode(group_dict['name'])) @@ -337,16 +351,45 @@ def create_groups(cls, group_dicts, admin_user_name=None, auth_profile=""): for key in group_dict: if key in group_attributes: setattr(group, key, group_dict[key]) - else: + elif key not in ('admins', 'editors', 'parent'): group.extras[key] = group_dict[key] assert isinstance(pkg_names, (list, tuple)) for pkg_name in pkg_names: pkg = model.Package.by_name(unicode(pkg_name)) assert pkg, pkg_name - member = model.Member(group=group, table_id=pkg.id, table_name='package') + member = model.Member(group=group, table_id=pkg.id, + table_name='package') model.Session.add(member) model.Session.add(group) - model.setup_default_user_roles(group, admin_users) + admins = [model.User.by_name(user_name) + for user_name in group_dict.get('admins', [])] + \ + admin_users + for admin in admins: + member = model.Member(group=group, table_id=admin.id, + table_name='user', capacity='admin') + model.Session.add(member) + editors = [model.User.by_name(user_name) + for user_name in group_dict.get('editors', [])] + for editor in editors: + member = model.Member(group=group, table_id=editor.id, + table_name='user', capacity='editor') + model.Session.add(member) + # Need to commit the current Group for two reasons: + # 1. It might have a parent, and the Member will need the Group.id + # value allocated on commit. + # 2. The next Group created may have this Group as a parent so + # creation of the Member needs to refer to this one. + model.Session.commit() + rev = model.repo.new_revision() + rev.author = cls.author + # add it to a parent's group + if 'parent' in group_dict: + parent = model.Group.by_name(unicode(group_dict['parent'])) + assert parent, group_dict['parent'] + member = model.Member(group=group, table_id=parent.id, + table_name='group', capacity='parent') + model.Session.add(member) + #model.setup_default_user_roles(group, admin_users) cls.group_names.add(group_dict['name']) model.repo.commit_and_remove() @@ -362,7 +405,8 @@ def create(cls, auth_profile="", package_type=None): * Associated tags, etc etc ''' if auth_profile == "publisher": - organization_group = model.Group(name=u"organization_group", type="organization") + organization_group = model.Group(name=u"organization_group", + type="organization") cls.pkg_names = [u'annakarenina', u'warandpeace'] pkg1 = model.Package(name=cls.pkg_names[0], type=package_type) @@ -483,11 +527,12 @@ def create(cls, auth_profile="", package_type=None): roger = model.Group.by_name(u'roger') model.setup_default_user_roles(david, [russianfan]) model.setup_default_user_roles(roger, [russianfan]) - model.add_user_to_role(visitor, model.Role.ADMIN, roger) + # in new_authz you can't give a visitor permissions to a + # group it seems, so this is a bit meaningless + model.add_user_to_role(visitor, model.Role.ADMIN, roger) model.repo.commit_and_remove() - # method used in DGU and all good tests elsewhere @classmethod def create_users(cls, user_dicts): @@ -501,9 +546,11 @@ def create_users(cls, user_dicts): @classmethod def _create_user_without_commit(cls, name='', **user_dict): - if model.User.by_name(name) or (user_dict.get('open_id') and model.User.by_openid(user_dict.get('openid'))): - log.warning('Cannot create user "%s" as it already exists.' % \ - (name or user_dict['name'])) + if model.User.by_name(name) or \ + (user_dict.get('open_id') and + model.User.by_openid(user_dict.get('openid'))): + log.warning('Cannot create user "%s" as it already exists.' % + name or user_dict['name']) return # User objects are not revisioned so no need to create a revision user_ref = name or user_dict['openid'] @@ -826,6 +873,65 @@ def make_some_vocab_tags(cls): } ] +group_hierarchy_groups = [ + {'name': 'department-of-health', + 'title': 'Department of Health', + 'contact-email': 'contact@doh.gov.uk', + 'type': 'organization', + 'is_organization': True + }, + {'name': 'food-standards-agency', + 'title': 'Food Standards Agency', + 'contact-email': 'contact@fsa.gov.uk', + 'parent': 'department-of-health', + 'type': 'organization', + 'is_organization': True}, + {'name': 'national-health-service', + 'title': 'National Health Service', + 'contact-email': 'contact@nhs.gov.uk', + 'parent': 'department-of-health', + 'type': 'organization', + 'is_organization': True, + 'editors': ['nhseditor'], + 'admins': ['nhsadmin']}, + {'name': 'nhs-wirral-ccg', + 'title': 'NHS Wirral CCG', + 'contact-email': 'contact@wirral.nhs.gov.uk', + 'parent': 'national-health-service', + 'type': 'organization', + 'is_organization': True, + 'editors': ['wirraleditor'], + 'admins': ['wirraladmin']}, + {'name': 'nhs-southwark-ccg', + 'title': 'NHS Southwark CCG', + 'contact-email': 'contact@southwark.nhs.gov.uk', + 'parent': 'national-health-service', + 'type': 'organization', + 'is_organization': True}, + {'name': 'cabinet-office', + 'title': 'Cabinet Office', + 'contact-email': 'contact@cabinet-office.gov.uk', + 'type': 'organization', + 'is_organization': True}, + ] + +group_hierarchy_datasets = [ + {'name': 'doh-spend', 'title': 'Department of Health Spend Data', + 'groups': ['department-of-health']}, + {'name': 'nhs-spend', 'title': 'NHS Spend Data', + 'groups': ['national-health-service']}, + {'name': 'wirral-spend', 'title': 'Wirral Spend Data', + 'groups': ['nhs-wirral-ccg']}, + {'name': 'southwark-spend', 'title': 'Southwark Spend Data', + 'groups': ['nhs-southwark-ccg']}, + ] + +group_hierarchy_users = [{'name': 'nhsadmin', 'password': 'pass'}, + {'name': 'nhseditor', 'password': 'pass'}, + {'name': 'wirraladmin', 'password': 'pass'}, + {'name': 'wirraleditor', 'password': 'pass'}, + ] + # Some test terms and translations. terms = ('A Novel By Tolstoy', 'Index of the novel', diff --git a/ckan/lib/dictization/__init__.py b/ckan/lib/dictization/__init__.py index 6f726ad3436..5c9f77346d1 100644 --- a/ckan/lib/dictization/__init__.py +++ b/ckan/lib/dictization/__init__.py @@ -3,6 +3,12 @@ import sqlalchemy from pylons import config +try: + RowProxy = sqlalchemy.engine.result.RowProxy +except AttributeError: + RowProxy = sqlalchemy.engine.base.RowProxy + + # NOTE # The functions in this file contain very generic methods for dictizing objects # and saving dictized objects. If a specialised use is needed please do NOT extend @@ -17,7 +23,7 @@ def table_dictize(obj, context, **kw): model = context["model"] session = model.Session - if isinstance(obj, sqlalchemy.engine.base.RowProxy): + if isinstance(obj, RowProxy): fields = obj.keys() else: ModelClass = obj.__class__ diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index 79eb17e8ff4..9b167c70810 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -10,22 +10,23 @@ import ckan.lib.dictization as d import ckan.new_authz as new_authz import ckan.lib.search as search +import ckan.lib.munge as munge ## package save def group_list_dictize(obj_list, context, - sort_key=lambda x:x['display_name'], reverse=False): + sort_key=lambda x:x['display_name'], reverse=False, + with_package_counts=True): active = context.get('active', True) with_private = context.get('include_private_packages', False) - query = search.PackageSearchQuery() - - q = {'q': '+capacity:public' if not with_private else '*:*', - 'fl': 'groups', 'facet.field': ['groups', 'owner_org'], - 'facet.limit': -1, 'rows': 1} - - query.run(q) + if with_package_counts: + query = search.PackageSearchQuery() + q = {'q': '+capacity:public' if not with_private else '*:*', + 'fl': 'groups', 'facet.field': ['groups', 'owner_org'], + 'facet.limit': -1, 'rows': 1} + query.run(q) result_list = [] @@ -39,12 +40,26 @@ def group_list_dictize(obj_list, context, if active and obj.state not in ('active', 'pending'): continue - group_dict['display_name'] = obj.display_name - - if obj.is_organization: - group_dict['packages'] = query.facets['owner_org'].get(obj.id, 0) - else: - group_dict['packages'] = query.facets['groups'].get(obj.name, 0) + group_dict['display_name'] = (group_dict.get('title') or + group_dict.get('name')) + + image_url = group_dict.get('image_url') + group_dict['image_display_url'] = image_url + if image_url and not image_url.startswith('http'): + #munge here should not have an effect only doing it incase + #of potential vulnerability of dodgy api input + image_url = munge.munge_filename(image_url) + group_dict['image_display_url'] = h.url_for_static( + 'uploads/group/%s' % group_dict.get('image_url'), + qualified=True + ) + + if with_package_counts: + facets = query.facets + if obj.is_organization: + group_dict['packages'] = facets['owner_org'].get(obj.id, 0) + else: + group_dict['packages'] = facets['groups'].get(obj.name, 0) if context.get('for_view'): if group_dict['is_organization']: @@ -75,7 +90,8 @@ def related_list_dictize(related_list, context): for res in related_list: related_dict = related_dictize(res, context) result_list.append(related_dict) - + if context.get('sorted'): + return result_list return sorted(result_list, key=lambda x: x["created"], reverse=True) @@ -128,14 +144,29 @@ def _unified_resource_format(format_): return format_new def resource_dictize(res, context): + model = context['model'] resource = d.table_dictize(res, context) + resource_group_id = resource['resource_group_id'] extras = resource.pop("extras", None) if extras: resource.update(extras) resource['format'] = _unified_resource_format(res.format) # some urls do not have the protocol this adds http:// to these url = resource['url'] - if not urlparse.urlsplit(url).scheme: + ## for_edit is only called at the times when the dataset is to be edited + ## in the frontend. Without for_edit the whole qualified url is returned. + if resource.get('url_type') == 'upload' and not context.get('for_edit'): + resource_group = model.Session.query( + model.ResourceGroup).get(resource_group_id) + last_part = url.split('/')[-1] + cleaned_name = munge.munge_filename(last_part) + resource['url'] = h.url_for(controller='package', + action='resource_download', + id=resource_group.package_id, + resource_id=res.id, + filename=cleaned_name, + qualified=True) + elif not urlparse.urlsplit(url).scheme and not context.get('for_edit'): resource['url'] = u'http://' + url.lstrip('/') return resource @@ -248,7 +279,11 @@ def package_dictize(pkg, context): .where(member_rev.c.state == 'active') \ .where(group.c.is_organization == False) result = _execute_with_revision(q, member_rev, context) - result_dict["groups"] = d.obj_list_dictize(result, context) + context['with_capacity'] = False + ## no package counts as cannot fetch from search index at the same + ## time as indexing to it. + result_dict["groups"] = group_list_dictize(result, context, + with_package_counts=False) #owning organization group_rev = model.group_revision_table q = select([group_rev] @@ -314,7 +349,6 @@ def _get_members(context, group, member_type): def group_dictize(group, context): - model = context['model'] result_dict = d.table_dictize(group, context) result_dict['display_name'] = group.display_name @@ -322,19 +356,35 @@ def group_dictize(group, context): result_dict['extras'] = extras_dict_dictize( group._extras, context) - context['with_capacity'] = True + include_datasets = context.get('include_datasets', True) - result_dict['packages'] = d.obj_list_dictize( - _get_members(context, group, 'packages'), - context) + q = { + 'facet': 'false', + 'rows': 0, + } - query = search.PackageSearchQuery() if group.is_organization: - q = {'q': 'owner_org:"%s" +capacity:public' % group.id, 'rows': 1} + q['fq'] = 'owner_org:"{0}"'.format(group.id) else: - q = {'q': 'groups:"%s" +capacity:public' % group.name, 'rows': 1} - result_dict['package_count'] = query.run(q)['count'] + q['fq'] = 'groups:"{0}"'.format(group.name) + + is_group_member = (context.get('user') and + new_authz.has_user_permission_for_group_or_org(group.id, context.get('user'), 'read')) + if is_group_member: + context['ignore_capacity_check'] = True + + if include_datasets: + q['rows'] = 1000 # Only the first 1000 datasets are returned + + context_ = dict((k, v) for (k, v) in context.items() if k != 'schema') + search_results = logic.get_action('package_search')(context_, q) + + if include_datasets: + result_dict['packages'] = search_results['results'] + result_dict['package_count'] = search_results['count'] + + context['with_capacity'] = True result_dict['tags'] = tag_list_dictize( _get_members(context, group, 'tags'), context) @@ -357,6 +407,16 @@ def group_dictize(group, context): for item in plugins.PluginImplementations(plugin): result_dict = item.before_view(result_dict) + image_url = result_dict.get('image_url') + result_dict['image_display_url'] = image_url + if image_url and not image_url.startswith('http'): + #munge here should not have an effect only doing it incase + #of potential vulnerability of dodgy api input + image_url = munge.munge_filename(image_url) + result_dict['image_display_url'] = h.url_for_static( + 'uploads/group/%s' % result_dict.get('image_url'), + qualified = True + ) return result_dict def tag_list_dictize(tag_list, context): @@ -431,6 +491,9 @@ def user_list_dictize(obj_list, context, for obj in obj_list: user_dict = user_dictize(obj, context) + user_dict.pop('reset_key', None) + user_dict.pop('apikey', None) + user_dict.pop('email', None) result_list.append(user_dict) return sorted(result_list, key=sort_key, reverse=reverse) @@ -455,13 +518,24 @@ def user_dictize(user, context): requester = context.get('user') - if not (new_authz.is_sysadmin(requester) or - requester == user.name or - context.get('keep_sensitive_data', False)): - # If not sysadmin or the same user, strip sensible info - result_dict.pop('apikey', None) - result_dict.pop('reset_key', None) - result_dict.pop('email', None) + reset_key = result_dict.pop('reset_key', None) + apikey = result_dict.pop('apikey', None) + email = result_dict.pop('email', None) + + if context.get('keep_email', False): + result_dict['email'] = email + + if context.get('keep_apikey', False): + result_dict['apikey'] = apikey + + if requester == user.name: + result_dict['apikey'] = apikey + result_dict['email'] = email + + ## this should not really really be needed but tests need it + if new_authz.is_sysadmin(requester): + result_dict['apikey'] = apikey + result_dict['email'] = email model = context['model'] session = model.Session diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py index 5669b2053be..0ab9c44706b 100644 --- a/ckan/lib/dictization/model_save.py +++ b/ckan/lib/dictization/model_save.py @@ -41,8 +41,8 @@ def resource_dict_save(res_dict, context): # this is an internal field so ignore # FIXME This helps get the tests to pass but is a hack and should # be fixed properly. basically don't update the format if not needed - if (key == 'format' and value == obj.format - or value == d.model_dictize._unified_resource_format(obj.format)): + if (key == 'format' and (value == obj.format + or value == d.model_dictize._unified_resource_format(obj.format))): continue setattr(obj, key, value) else: @@ -342,7 +342,11 @@ def group_member_save(context, group_dict, member_table_name): entities = {} Member = model.Member - ModelClass = getattr(model, member_table_name[:-1].capitalize()) + classname = member_table_name[:-1].capitalize() + if classname == 'Organization': + # Organizations use the model.Group class + classname = 'Group' + ModelClass = getattr(model, classname) for entity_dict in entity_list: name_or_id = entity_dict.get('id') or entity_dict.get('name') diff --git a/ckan/lib/email_notifications.py b/ckan/lib/email_notifications.py index ba245872531..7f24b978285 100644 --- a/ckan/lib/email_notifications.py +++ b/ckan/lib/email_notifications.py @@ -221,7 +221,7 @@ def get_and_send_notifications_for_user(user): def get_and_send_notifications_for_all_users(): context = {'model': model, 'session': model.Session, 'ignore_auth': True, - 'keep_sensitive_data': True} + 'keep_email': True} users = logic.get_action('user_list')(context, {}) for user in users: get_and_send_notifications_for_user(user) diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index fee13f7e5d5..dd0ec4bfe22 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -37,6 +37,8 @@ import ckan.lib.maintain as maintain import ckan.lib.datapreview as datapreview import ckan.logic as logic +import ckan.lib.uploader as uploader +import ckan.new_authz as new_authz from ckan.common import ( _, ungettext, g, c, request, session, json, OrderedDict @@ -391,10 +393,10 @@ def _create_link_text(text, **kwargs): def nav_link(text, *args, **kwargs): ''' - params - class_: pass extra class(s) to add to the tag - icon: name of ckan icon to use within the link - condition: if False then no link is returned + :param class_: pass extra class(es) to add to the ```` tag + :param icon: name of ckan icon to use within the link + :param condition: if ``False`` then no link is returned + ''' if len(args) > 1: raise Exception('Too many unnamed parameters supplied') @@ -458,35 +460,37 @@ def build_nav_main(*args): def build_nav_icon(menu_item, title, **kw): - ''' build a navigation item used for example in user/read_base.html + '''Build a navigation item used for example in ``user/read_base.html``. - outputs
  • title
  • + Outputs ``
  • title
  • ``. :param menu_item: the name of the defined menu item defined in - config/routing as the named route of the same name + config/routing as the named route of the same name :type menu_item: string :param title: text used for the link :type title: string - :param **kw: additional keywords needed for creating url eg id=... + :param kw: additional keywords needed for creating url eg ``id=...`` :rtype: HTML literal + ''' return _make_menu_item(menu_item, title, **kw) def build_nav(menu_item, title, **kw): - ''' build a navigation item used for example breadcrumbs + '''Build a navigation item used for example breadcrumbs. - outputs
  • title
  • + Outputs ``
  • title
  • ``. :param menu_item: the name of the defined menu item defined in - config/routing as the named route of the same name + config/routing as the named route of the same name :type menu_item: string :param title: text used for the link :type title: string - :param **kw: additional keywords needed for creating url eg id=... + :param kw: additional keywords needed for creating url eg ``id=...`` :rtype: HTML literal + ''' return _make_menu_item(menu_item, title, icon=None, **kw) @@ -597,15 +601,19 @@ def get_facet_title(name): if config_title: return config_title - facet_titles = {'groups': _('Groups'), + facet_titles = {'organization': _('Organizations'), + 'groups': _('Groups'), 'tags': _('Tags'), 'res_format': _('Formats'), - 'license': _('License'), } + 'license': _('Licenses'), } return facet_titles.get(name, name.capitalize()) def get_param_int(name, default=10): - return int(request.params.get(name, default)) + try: + return int(request.params.get(name, default)) + except ValueError: + return default def _url_with_params(url, params): @@ -1206,9 +1214,9 @@ def add_url_param(alternative_url=None, controller=None, action=None, ''' Adds extra parameters to existing ones - controller action & extras (dict) are used to create the base url - via url_for(controller=controller, action=action, **extras) - controller & action default to the current ones + controller action & extras (dict) are used to create the base url via + :py:func:`~ckan.lib.helpers.url_for` controller & action default to the + current ones This can be overriden providing an alternative_url, which will be used instead. @@ -1236,11 +1244,12 @@ def remove_url_param(key, value=None, replace=None, controller=None, provided (or the only one provided if key is a string). controller action & extras (dict) are used to create the base url - via url_for(controller=controller, action=action, **extras) + via :py:func:`~ckan.lib.helpers.url_for` controller & action default to the current ones This can be overriden providing an alternative_url, which will be used instead. + ''' if isinstance(key, basestring): keys = [key] @@ -1416,10 +1425,14 @@ def dashboard_activity_stream(user_id, filter_type=None, filter_id=None, context, {'offset': offset}) -def recently_changed_packages_activity_stream(): +def recently_changed_packages_activity_stream(limit=None): + if limit: + data_dict = {'limit': limit} + else: + data_dict = {} context = {'model': model, 'session': model.Session, 'user': c.user} return logic.get_action('recently_changed_packages_activity_list_html')( - context, {}) + context, data_dict) def escape_js(str_to_escape): @@ -1719,6 +1732,10 @@ def new_activities(): action = logic.get_action('dashboard_new_activities_count') return action({}, {}) +def uploads_enabled(): + if uploader.get_storage_path(): + return True + return False def get_featured_organizations(count=1): '''Returns a list of favourite organization in the form @@ -1753,7 +1770,7 @@ def get_group(id): try: out = logic.get_action(get_action)(context, data_dict) - except logic.ObjectNotFound: + except logic.NotFound: return None return out @@ -1793,6 +1810,8 @@ def get_site_statistics(): return stats +def check_config_permission(permission): + return new_authz.check_config_permission(permission) # these are the functions that will end up in `h` template helpers __allowed_functions__ = [ @@ -1894,9 +1913,12 @@ def get_site_statistics(): 'radio', 'submit', 'asbool', + 'uploads_enabled', 'get_featured_organizations', 'get_featured_groups', 'get_site_statistics', 'get_allowed_view_types', 'urlencode', + 'check_config_permission', + 'view_resource_url', ] diff --git a/ckan/lib/munge.py b/ckan/lib/munge.py index c943e1b113f..a47c8c02258 100644 --- a/ckan/lib/munge.py +++ b/ckan/lib/munge.py @@ -105,6 +105,13 @@ def munge_tag(tag): tag = _munge_to_length(tag, model.MIN_TAG_LENGTH, model.MAX_TAG_LENGTH) return tag +def munge_filename(filename): + filename = substitute_ascii_equivalents(filename) + filename = filename.lower().strip() + filename = re.sub(r'[^a-zA-Z0-9. ]', '', filename).replace(' ', '-') + filename = _munge_to_length(filename, 3, 100) + return filename + def _munge_to_length(string, min_length, max_length): '''Pad/truncates a string''' if len(string) < min_length: diff --git a/ckan/lib/plugins.py b/ckan/lib/plugins.py index 97263dcf7f8..5e2aa9116c3 100644 --- a/ckan/lib/plugins.py +++ b/ckan/lib/plugins.py @@ -53,7 +53,8 @@ def lookup_group_plugin(group_type=None): """ if group_type is None: return _default_group_plugin - return _group_plugins.get(group_type, _default_group_plugin) + return _group_plugins.get(group_type, _default_organization_plugin + if group_type == 'organization' else _default_group_plugin) def register_package_plugins(map): @@ -114,7 +115,7 @@ def register_group_plugins(map): """ Register the various IGroupForm instances. - This method will setup the mappings between package types and the + This method will setup the mappings between group types and the registered IGroupForm instances. If it's called more than once an exception will be raised. """ @@ -159,7 +160,7 @@ def register_group_plugins(map): if group_type in _group_plugins: raise ValueError, "An existing IGroupForm is "\ - "already associated with the package type "\ + "already associated with the group type "\ "'%s'" % group_type _group_plugins[group_type] = plugin @@ -418,3 +419,39 @@ def setup_template_variables(self, context, data_dict): c.auth_for_change_state = True except logic.NotAuthorized: c.auth_for_change_state = False + + +class DefaultOrganizationForm(DefaultGroupForm): + def group_form(self): + return 'organization/new_organization_form.html' + + def setup_template_variables(self, context, data_dict): + pass + + def new_template(self): + return 'organization/new.html' + + def about_template(self): + return 'organization/about.html' + + def index_template(self): + return 'organization/index.html' + + def admins_template(self): + return 'organization/admins.html' + + def bulk_process_template(self): + return 'organization/bulk_process.html' + + def read_template(self): + return 'organization/read.html' + + # don't override history_template - use group template for history + + def edit_template(self): + return 'organization/edit.html' + + def activity_template(self): + return 'organization/activity_stream.html' + +_default_organization_plugin = DefaultOrganizationForm() diff --git a/ckan/lib/uploader.py b/ckan/lib/uploader.py new file mode 100644 index 00000000000..a9c843a0d0d --- /dev/null +++ b/ckan/lib/uploader.py @@ -0,0 +1,225 @@ +import os +import cgi +import pylons +import datetime +import ckan.lib.munge as munge +import logging +import ckan.logic as logic + + +config = pylons.config +log = logging.getLogger(__name__) + +_storage_path = None +_max_resource_size = None +_max_image_size = None + + +def get_storage_path(): + '''Function to cache storage path''' + global _storage_path + + #None means it has not been set. False means not in config. + if _storage_path is None: + storage_path = config.get('ckan.storage_path') + ofs_impl = config.get('ofs.impl') + ofs_storage_dir = config.get('ofs.storage_dir') + if storage_path: + _storage_path = storage_path + elif ofs_impl == 'pairtree' and ofs_storage_dir: + log.warn('''Please use config option ckan.storage_path instaed of + ofs.storage_path''') + _storage_path = ofs_storage_dir + return _storage_path + elif ofs_impl: + log.critical('''We only support local file storage form version 2.2 + of ckan please specify ckan.storage_path in your + config for your uploads''') + _storage_path = False + else: + log.critical('''Please specify a ckan.storage_path in your config + for your uploads''') + _storage_path = False + + return _storage_path + + +def get_max_image_size(): + global _max_image_size + if _max_image_size is None: + _max_image_size = int(config.get('ckan.max_image_size', 2)) + return _max_image_size + + +def get_max_resource_size(): + global _max_resource_size + if _max_resource_size is None: + _max_resource_size = int(config.get('ckan.max_resource_size', 10)) + return _max_resource_size + + +class Upload(object): + def __init__(self, object_type, old_filename=None): + ''' Setup upload by creating a subdirectory of the storage directory + of name object_type. old_filename is the name of the file in the url + field last time''' + + self.storage_path = None + self.filename = None + self.filepath = None + path = get_storage_path() + if not path: + return + self.storage_path = os.path.join(path, 'storage', + 'uploads', object_type) + try: + os.makedirs(self.storage_path) + except OSError, e: + ## errno 17 is file already exists + if e.errno != 17: + raise + self.object_type = object_type + self.old_filename = old_filename + if old_filename: + self.old_filepath = os.path.join(self.storage_path, old_filename) + + def update_data_dict(self, data_dict, url_field, file_field, clear_field): + ''' Manipulate data from the data_dict. url_field is the name of the + field where the upload is going to be. file_field is name of the key + where the FieldStorage is kept (i.e the field where the file data + actually is). clear_field is the name of a boolean field which + requests the upload to be deleted. This needs to be called before + it reaches any validators''' + + self.url = data_dict.get(url_field, '') + self.clear = data_dict.pop(clear_field, None) + self.file_field = file_field + self.upload_field_storage = data_dict.pop(file_field, None) + + if not self.storage_path: + return + + if isinstance(self.upload_field_storage, cgi.FieldStorage): + self.filename = self.upload_field_storage.filename + self.filename = str(datetime.datetime.utcnow()) + self.filename + self.filename = munge.munge_filename(self.filename) + self.filepath = os.path.join(self.storage_path, self.filename) + data_dict[url_field] = self.filename + self.upload_file = self.upload_field_storage.file + self.tmp_filepath = self.filepath + '~' + ### keep the file if there has been no change + elif self.old_filename and not self.old_filename.startswith('http'): + if not self.clear: + data_dict[url_field] = self.old_filename + if self.clear and self.url == self.old_filename: + data_dict[url_field] = '' + + def upload(self, max_size=2): + ''' Actually upload the file. + This should happen just before a commit but after the data has + been validated and flushed to the db. This is so we do not store + anything unless the request is actually good. + max_size is size in MB maximum of the file''' + + if self.filename: + output_file = open(self.tmp_filepath, 'wb') + self.upload_file.seek(0) + current_size = 0 + while True: + current_size = current_size + 1 + # MB chuncks + data = self.upload_file.read(2 ** 20) + if not data: + break + output_file.write(data) + if current_size > max_size: + os.remove(self.tmp_filepath) + raise logic.ValidationError( + {self.file_field: ['File upload too large']} + ) + output_file.close() + os.rename(self.tmp_filepath, self.filepath) + self.clear = True + + if (self.clear and self.old_filename + and not self.old_filename.startswith('http')): + try: + os.remove(self.old_filepath) + except OSError, e: + pass + + +class ResourceUpload(object): + def __init__(self, resource): + path = get_storage_path() + if not path: + self.storage_path = None + return + self.storage_path = os.path.join(path, 'resources') + try: + os.makedirs(self.storage_path) + except OSError, e: + ## errno 17 is file already exists + if e.errno != 17: + raise + self.filename = None + + url = resource.get('url') + upload_field_storage = resource.pop('upload', None) + self.clear = resource.pop('clear_upload', None) + + if isinstance(upload_field_storage, cgi.FieldStorage): + self.filename = upload_field_storage.filename + self.filename = munge.munge_filename(self.filename) + resource['url'] = self.filename + resource['url_type'] = 'upload' + self.upload_file = upload_field_storage.file + elif self.clear: + resource['url_type'] = '' + + def get_directory(self, id): + directory = os.path.join(self.storage_path, + id[0:3], id[3:6]) + return directory + + def get_path(self, id): + directory = self.get_directory(id) + filepath = os.path.join(directory, id[6:]) + return filepath + + def upload(self, id, max_size=10): + if not self.storage_path: + return + directory = self.get_directory(id) + filepath = self.get_path(id) + if self.filename: + try: + os.makedirs(directory) + except OSError, e: + ## errno 17 is file already exists + if e.errno != 17: + raise + tmp_filepath = filepath + '~' + output_file = open(tmp_filepath, 'wb+') + self.upload_file.seek(0) + current_size = 0 + while True: + current_size = current_size + 1 + #MB chunks + data = self.upload_file.read(2 ** 20) + if not data: + break + output_file.write(data) + if current_size > max_size: + os.remove(tmp_filepath) + raise logic.ValidationError( + {'upload': ['File upload too large']} + ) + output_file.close() + os.rename(tmp_filepath, filepath) + + if self.clear: + try: + os.remove(filepath) + except OSError, e: + pass diff --git a/ckan/logic/__init__.py b/ckan/logic/__init__.py index 4a474e26432..872cd7e4b7b 100644 --- a/ckan/logic/__init__.py +++ b/ckan/logic/__init__.py @@ -388,7 +388,7 @@ def get_action(action): resolved_action_plugins[name] ) ) - log.debug('Auth function %r was inserted', plugin.name) + log.debug('Action function {0} from plugin {1} was inserted'.format(name, plugin.name)) resolved_action_plugins[name] = plugin.name # Extensions are exempted from the auth audit for now # This needs to be resolved later @@ -402,7 +402,7 @@ def get_action(action): def make_wrapped(_action, action_name): def wrapped(context=None, data_dict=None, **kw): if kw: - log.critical('%s was pass extra keywords %r' + log.critical('%s was passed extra keywords %r' % (_action.__name__, kw)) context = _prepopulate_context(context) @@ -423,7 +423,9 @@ def wrapped(context=None, data_dict=None, **kw): if action_name not in new_authz.auth_functions_list(): log.debug('No auth function for %s' % action_name) elif not getattr(_action, 'auth_audit_exempt', False): - raise Exception('Action Auth Audit: %s' % action_name) + raise Exception( + 'Action function {0} did not call its auth function' + .format(action_name)) # remove from audit stack context['__auth_audit'].pop() except IndexError: @@ -502,6 +504,23 @@ def get_or_bust(data_dict, keys): return values[0] return tuple(values) +def validate(schema_func, can_skip_validator=False): + ''' A decorator that validates an action function against a given schema + ''' + def action_decorator(action): + @functools.wraps(action) + def wrapper(context, data_dict): + if can_skip_validator: + if context.get('skip_validation'): + return action(context, data_dict) + + schema = context.get('schema', schema_func()) + data_dict, errors = _validate(data_dict, schema, context) + if errors: + raise ValidationError(errors) + return action(context, data_dict) + return wrapper + return action_decorator def side_effect_free(action): '''A decorator that marks the given action function as side-effect-free. diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index b5f787d81e8..65e012a5097 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -18,6 +18,7 @@ import ckan.lib.dictization.model_dictize as model_dictize import ckan.lib.dictization.model_save as model_save import ckan.lib.navl.dictization_functions +import ckan.lib.uploader as uploader import ckan.lib.navl.validators as validators import ckan.lib.mailer as mailer import ckan.lib.datapreview as datapreview @@ -46,7 +47,8 @@ def package_create(context, data_dict): for the new dataset, you must also be authorized to edit these groups. Plugins may change the parameters of this function depending on the value - of the ``type`` parameter, see the ``IDatasetForm`` plugin interface. + of the ``type`` parameter, see the + :py:class:`~ckan.plugins.interfaces.IDatasetForm` plugin interface. :param name: the name of the new dataset, must be between 2 and 100 characters long and contain only lowercase alphanumeric characters, @@ -64,8 +66,9 @@ def package_create(context, data_dict): :param maintainer_email: the email address of the dataset's maintainer (optional) :type maintainer_email: string - :param license_id: the id of the dataset's license, see ``license_list()`` - for available values (optional) + :param license_id: the id of the dataset's license, see + :py:func:`~ckan.logic.action.get.license_list` for available values + (optional) :type license_id: license id string :param notes: a description of the dataset (optional) :type notes: string @@ -79,41 +82,45 @@ def package_create(context, data_dict): authorized to change the state of the dataset (optional, default: ``'active'``) :type state: string - :param type: the type of the dataset (optional), ``IDatasetForm`` plugins + :param type: the type of the dataset (optional), + :py:class:`~ckan.plugins.interfaces.IDatasetForm` plugins associate themselves with different dataset types and provide custom dataset handling behaviour for these types :type type: string - :param resources: the dataset's resources, see ``resource_create()`` - for the format of resource dictionaries (optional) + :param resources: the dataset's resources, see + :py:func:`resource_create` for the format of resource dictionaries + (optional) :type resources: list of resource dictionaries - :param tags: the dataset's tags, see ``tag_create()`` for the format + :param tags: the dataset's tags, see :py:func:`tag_create` for the format of tag dictionaries (optional) :type tags: list of tag dictionaries :param extras: the dataset's extras (optional), extras are arbitrary (key: value) metadata items that can be added to datasets, each extra dictionary should have keys ``'key'`` (a string), ``'value'`` (a - string), and optionally ``'deleted'`` + string) :type extras: list of dataset extra dictionaries - :param relationships_as_object: see ``package_relationship_create()`` for - the format of relationship dictionaries (optional) + :param relationships_as_object: see :py:func:`package_relationship_create` + for the format of relationship dictionaries (optional) :type relationships_as_object: list of relationship dictionaries - :param relationships_as_subject: see ``package_relationship_create()`` for - the format of relationship dictionaries (optional) + :param relationships_as_subject: see :py:func:`package_relationship_create` + for the format of relationship dictionaries (optional) :type relationships_as_subject: list of relationship dictionaries :param groups: the groups to which the dataset belongs (optional), each group dictionary should have one or more of the following keys which identify an existing group: ``'id'`` (the id of the group, string), ``'name'`` (the name of the group, string), ``'title'`` (the title of the group, string), to see - which groups exist call ``group_list()`` + which groups exist call :py:func:`~ckan.logic.action.get.group_list` :type groups: list of dictionaries :param owner_org: the id of the dataset's owning organization, see - ``organization_list()`` or ``organization_list_for_user`` for + :py:func:`~ckan.logic.action.get.organization_list` or + :py:func:`~ckan.logic.action.get.organization_list_for_user` for available values (optional) :type owner_org: string - :returns: the newly created dataset (unless 'return_id_only' is set to True - in the context, in which case just the dataset id will be returned) + :returns: the newly created dataset (unless ``'return_id_only'`` is set to + ``True`` in the context, in which case just the dataset id will + be returned) :rtype: dictionary ''' @@ -240,6 +247,8 @@ def resource_create(context, data_dict): :type cache_last_updated: iso date string :param webstore_last_updated: (optional) :type webstore_last_updated: iso date string + :param upload: (optional) + :type upload: FieldStorage (optional) needs multipart/form-data :returns: the newly created resource :rtype: dictionary @@ -257,15 +266,31 @@ def resource_create(context, data_dict): if not 'resources' in pkg_dict: pkg_dict['resources'] = [] + + upload = uploader.ResourceUpload(data_dict) + pkg_dict['resources'].append(data_dict) try: - pkg_dict = _get_action('package_update')(context, pkg_dict) + context['defer_commit'] = True + context['use_cache'] = False + _get_action('package_update')(context, pkg_dict) + context.pop('defer_commit') except ValidationError, e: errors = e.error_dict['resources'][-1] raise ValidationError(errors) - return pkg_dict['resources'][-1] + ## Get out resource_id resource from model as it will not appear in + ## package_show until after commit + upload.upload(context['package'].resources[-1].id, + uploader.get_max_resource_size()) + model.repo.commit() + + ## Run package show again to get out actual last_resource + pkg_dict = _get_action('package_show')(context, {'id': package_id}) + resource = pkg_dict['resources'][-1] + + return resource def resource_view_create(context, data_dict): @@ -510,8 +535,7 @@ def member_create(context, data_dict=None): if not obj: raise NotFound('%s was not found.' % obj_type.title()) - # User must be able to update the group to add a member to it - _check_access('group_update', context, data_dict) + _check_access('member_create', context, data_dict) # Look up existing, in case it exists member = model.Session.query(model.Member).\ @@ -536,10 +560,11 @@ def _group_or_org_create(context, data_dict, is_org=False): model = context['model'] user = context['user'] session = context['session'] - parent = context.get('parent', None) data_dict['is_organization'] = is_org - + upload = uploader.Upload('group') + upload.update_data_dict(data_dict, 'image_url', + 'image_upload', 'clear_upload') # get the schema group_plugin = lib_plugins.lookup_group_plugin( group_type=data_dict.get('type')) @@ -576,14 +601,6 @@ def _group_or_org_create(context, data_dict, is_org=False): group = model_save.group_dict_save(data, context) - if parent: - parent_group = model.Group.get( parent ) - if parent_group: - member = model.Member(group=parent_group, table_id=group.id, table_name='group') - session.add(member) - log.debug('Group %s is made child of group %s', - group.name, parent_group.name) - if user: admins = [model.User.by_name(user.decode('utf8'))] else: @@ -625,6 +642,7 @@ def _group_or_org_create(context, data_dict, is_org=False): logic.get_action('activity_create')(activity_create_context, activity_dict) + upload.upload(uploader.get_max_image_size()) if not context.get('defer_commit'): model.repo.commit() context["group"] = group @@ -656,7 +674,8 @@ def group_create(context, data_dict): You must be authorized to create groups. Plugins may change the parameters of this function depending on the value - of the ``type`` parameter, see the ``IGroupForm`` plugin interface. + of the ``type`` parameter, see the + :py:class:`~ckan.plugins.interfaces.IGroupForm` plugin interface. :param name: the name of the group, a string between 2 and 100 characters long, containing only lowercase alphanumeric characters, ``-`` and @@ -671,7 +690,8 @@ def group_create(context, data_dict): :param image_url: the URL to an image to be displayed on the group's page (optional) :type image_url: string - :param type: the type of the group (optional), ``IGroupForm`` plugins + :param type: the type of the group (optional), + :py:class:`~ckan.plugins.interfaces.IGroupForm` plugins associate themselves with different group types and provide custom group handling behaviour for these types Cannot be 'organization' @@ -722,7 +742,8 @@ def organization_create(context, data_dict): You must be authorized to create organizations. Plugins may change the parameters of this function depending on the value - of the ``type`` parameter, see the ``IGroupForm`` plugin interface. + of the ``type`` parameter, see the + :py:class:`~ckan.plugins.interfaces.IGroupForm` plugin interface. :param name: the name of the organization, a string between 2 and 100 characters long, containing only lowercase alphanumeric characters, ``-`` and @@ -890,7 +911,8 @@ def user_create(context, data_dict): # # The context is copied so as not to clobber the caller's context dict. user_dictize_context = context.copy() - user_dictize_context['keep_sensitive_data'] = True + user_dictize_context['keep_apikey'] = True + user_dictize_context['keep_email'] = True user_dict = model_dictize.user_dictize(user, user_dictize_context) context['user_obj'] = user @@ -1000,7 +1022,7 @@ def vocabulary_create(context, data_dict): :param name: the name of the new vocabulary, e.g. ``'Genre'`` :type name: string :param tags: the new tags to add to the new vocabulary, for the format of - tag dictionaries see ``tag_create()`` + tag dictionaries see :py:func:`tag_create` :type tags: list of tag dictionaries :returns: the newly-created vocabulary @@ -1039,8 +1061,7 @@ def activity_create(context, activity_dict, **kw): ``'my_dataset'`` :param activity_type: the type of the activity, this must be an activity type that CKAN knows how to render, e.g. ``'new package'``, - ``'changed user'``, ``'deleted group'`` etc. (for a full list see - ``activity_renderers`` in ``ckan/logic/action/get.py`` + ``'changed user'``, ``'deleted group'`` etc. :type activity_type: string :param data: any additional data about the activity :type data: dictionary @@ -1050,6 +1071,8 @@ def activity_create(context, activity_dict, **kw): ''' + _check_access('activity_create', context, activity_dict) + # this action had a ignore_auth param which has been removed # removed in 2.2 if 'ignore_auth' in kw: @@ -1070,8 +1093,6 @@ def activity_create(context, activity_dict, **kw): else: activity_dict['revision_id'] = None - _check_access('activity_create', context, activity_dict) - schema = context.get('schema') or ckan.logic.schema.default_create_activity_schema() data, errors = _validate(activity_dict, schema, context) if errors: @@ -1104,7 +1125,8 @@ def tag_create(context, data_dict): You can only use this function to create tags that belong to a vocabulary, not to create free tags. (To create a new free tag simply add the tag to - a package, e.g. using the ``package_update`` function.) + a package, e.g. using the + :py:func:`~ckan.logic.action.update.package_update` function.) :param name: the name for the new tag, a string between 2 and 100 characters long containing only alphanumeric characters and ``-``, diff --git a/ckan/logic/action/delete.py b/ckan/logic/action/delete.py index f6b965ef588..88690a409c9 100644 --- a/ckan/logic/action/delete.py +++ b/ckan/logic/action/delete.py @@ -1,5 +1,7 @@ '''API functions for deleting data from CKAN.''' +from sqlalchemy import or_ + import ckan.logic import ckan.logic.action import ckan.plugins as plugins @@ -248,8 +250,7 @@ def member_delete(context, data_dict=None): if not obj: raise NotFound('%s was not found.' % obj_type.title()) - # User must be able to update the group to remove a member from it - _check_access('group_update', context, data_dict) + _check_access('member_delete', context, data_dict) member = model.Session.query(model.Member).\ filter(model.Member.table_name == obj_type).\ @@ -298,6 +299,15 @@ def _group_or_org_delete(context, data_dict, is_org=False): rev = model.repo.new_revision() rev.author = user rev.message = _(u'REST API: Delete %s') % revisioned_details + + # The group's Member objects are deleted + # (including hierarchy connections to parent and children groups) + for member in model.Session.query(model.Member).\ + filter(or_(model.Member.table_id == id, + model.Member.group_id == id)).\ + filter(model.Member.state == 'active').all(): + member.delete() + group.delete() if is_org: @@ -584,6 +594,7 @@ def group_member_delete(context, data_dict=None): :type username: string ''' + _check_access('group_member_delete',context, data_dict) return _group_or_org_member_delete(context, data_dict) def organization_member_delete(context, data_dict=None): @@ -597,6 +608,7 @@ def organization_member_delete(context, data_dict=None): :type username: string ''' + _check_access('organization_member_delete',context, data_dict) return _group_or_org_member_delete(context, data_dict) diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py index ccb4861d6ad..548aff6f114 100644 --- a/ckan/logic/action/get.py +++ b/ckan/logic/action/get.py @@ -62,6 +62,7 @@ def site_read(context,data_dict=None): _check_access('site_read',context,data_dict) return True +@logic.validate(logic.schema.default_pagination_schema) def package_list(context, data_dict): '''Return a list of the names of the site's datasets (packages). @@ -80,18 +81,15 @@ def package_list(context, data_dict): _check_access('package_list', context, data_dict) - schema = context.get('schema', logic.schema.default_pagination_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - - package_revision_table = model.package_revision_table col = (package_revision_table.c.id if api == 2 else package_revision_table.c.name) query = _select([col]) - query = query.where(_and_(package_revision_table.c.state=='active', - package_revision_table.c.current==True)) + query = query.where(_and_( + package_revision_table.c.state=='active', + package_revision_table.c.current==True, + package_revision_table.c.private==False, + )) query = query.order_by(col) limit = data_dict.get('limit') @@ -101,8 +99,11 @@ def package_list(context, data_dict): offset = data_dict.get('offset') if offset: query = query.offset(offset) - return list(zip(*query.execute())[0]) + ## Returns the first field in each result record + return [r[0] for r in query.execute()] + +@logic.validate(logic.schema.default_package_list_schema) def current_package_list_with_resources(context, data_dict): '''Return a list of the site's datasets (packages) and their resources. @@ -121,20 +122,13 @@ def current_package_list_with_resources(context, data_dict): ''' model = context["model"] - schema = context.get('schema', logic.schema.default_package_list_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - limit = data_dict.get('limit') - offset = int(data_dict.get('offset', 0)) + offset = data_dict.get('offset', 0) if not 'offset' in data_dict and 'page' in data_dict: log.warning('"page" parameter is deprecated. ' 'Use the "offset" parameter instead') - page = int(data_dict['page']) - if page < 1: - raise ValidationError(_('Must be larger than 0')) + page = data_dict['page'] if limit: offset = (page - 1) * limit else: @@ -219,8 +213,6 @@ def related_show(context, data_dict=None): def related_list(context, data_dict=None): '''Return a dataset's related items. - Either the ``id`` or the ``dataset`` parameter must be given. - :param id: id or name of the dataset (optional) :type id: string :param dataset: dataset dictionary of the dataset (optional) @@ -269,10 +261,12 @@ def related_list(context, data_dict=None): if data_dict.get('featured', False): related_list = related_list.filter(model.Related.featured == 1) + related_items = related_list.all() + context['sorted'] = True else: relateds = model.Related.get_for_dataset(dataset, status='active') related_items = (r.related for r in relateds) - related_list = model_dictize.related_list_dictize( related_items, context) + related_list = model_dictize.related_list_dictize( related_items, context) return related_list @@ -331,6 +325,7 @@ def translated_capacity(capacity): def _group_or_org_list(context, data_dict, is_org=False): model = context['model'] + user = context['user'] api = context.get('api_version') groups = data_dict.get('groups') ref_group_by = 'id' if api == 2 else 'name' @@ -442,10 +437,10 @@ def group_list_authz(context, data_dict): (optional, default: ``False``) :type available_only: boolean - :param am_member: if True return only the groups the logged-in user is a - member of, otherwise return all groups that the user is authorized to + :param am_member: if ``True`` return only the groups the logged-in user is + a member of, otherwise return all groups that the user is authorized to edit (for example, sysadmin users are authorized to edit all groups) - (optional, default: False) + (optional, default: ``False``) :type am-member: boolean :returns: list of dictized groups that the user is authorized to edit @@ -460,7 +455,7 @@ def group_list_authz(context, data_dict): _check_access('group_list_authz',context, data_dict) sysadmin = new_authz.is_sysadmin(user) - roles = ckan.new_authz.get_roles_with_permission('edit_group') + roles = ckan.new_authz.get_roles_with_permission('manage_group') if not roles: return [] user_id = new_authz.get_user_id_for_username(user, allow_none=True) @@ -547,7 +542,8 @@ def organization_list_for_user(context, data_dict): orgs_list = model_dictize.group_list_dictize(orgs_q.all(), context) return orgs_list -def group_revision_list(context, data_dict): + +def _group_or_org_revision_list(context, data_dict): '''Return a group's revisions. :param id: the name or id of the group @@ -562,7 +558,6 @@ def group_revision_list(context, data_dict): if group is None: raise NotFound - _check_access('group_revision_list',context, data_dict) revision_dicts = [] for revision, object_revisions in group.all_related_revisions: @@ -571,6 +566,32 @@ def group_revision_list(context, data_dict): include_groups=False)) return revision_dicts +def group_revision_list(context, data_dict): + '''Return a group's revisions. + + :param id: the name or id of the group + :type id: string + + :rtype: list of dictionaries + + ''' + + _check_access('group_revision_list',context, data_dict) + return _group_or_org_revision_list(context, data_dict) + +def organization_revision_list(context, data_dict): + '''Return an organization's revisions. + + :param id: the name or id of the organization + :type id: string + + :rtype: list of dictionaries + + ''' + + _check_access('organization_revision_list',context, data_dict) + return _group_or_org_revision_list(context, data_dict) + def license_list(context, data_dict): '''Return the list of licenses available for datasets on the site. @@ -711,8 +732,8 @@ def package_relationships_list(context, data_dict): :param id2: the id or name of the second package :type id: string :param rel: relationship as string see - :func:`ckan.logic.action.create.package_relationship_create()` for the - relationship types (optional) + :py:func:`~ckan.logic.action.create.package_relationship_create` for + the relationship types (optional) :rtype: list of dictionaries @@ -757,6 +778,9 @@ def package_show(context, data_dict): :param id: the id or name of the dataset :type id: string + :param use_default_schema: use default package schema instead of + a custom schema defined with an IDatasetForm plugin (default: False) + :type use_default_schema: bool :rtype: dictionary @@ -774,6 +798,9 @@ def package_show(context, data_dict): _check_access('package_show', context, data_dict) + if data_dict.get('use_default_schema', False): + context['schema'] = ckan.logic.schema.default_show_package_schema() + package_dict = None use_cache = (context.get('use_cache', True) and not 'revision_id' in context @@ -985,6 +1012,11 @@ def _group_or_org_show(context, data_dict, is_org=False): group = model.Group.get(id) context['group'] = group + include_datasets = data_dict.get('include_datasets', True) + if isinstance(include_datasets, basestring): + include_datasets = (include_datasets.lower() in ('true', '1')) + context['include_datasets'] = include_datasets + if group is None: raise NotFound if is_org and not group.is_organization: @@ -1031,9 +1063,14 @@ def group_show(context, data_dict): :param id: the id or name of the group :type id: string + :param include_datasets: include a list of the group's datasets + (optional, default: ``True``) + :type id: boolean :rtype: dictionary + .. note:: Only its first 1000 datasets are returned + ''' return _group_or_org_show(context, data_dict) @@ -1042,9 +1079,13 @@ def organization_show(context, data_dict): :param id: the id or name of the organization :type id: string + :param include_datasets: include a list of the organization's datasets + (optional, default: ``True``) + :type id: boolean :rtype: dictionary + .. note:: Only its first 1000 datasets are returned ''' return _group_or_org_show(context, data_dict, is_org=True) @@ -1197,6 +1238,7 @@ def tag_show_rest(context, data_dict): return tag_dict +@logic.validate(logic.schema.default_autocomplete_schema) def package_autocomplete(context, data_dict): '''Return a list of datasets (packages) that match a string. @@ -1212,11 +1254,6 @@ def package_autocomplete(context, data_dict): :rtype: list of dictionaries ''' - schema = context.get('schema', logic.schema.default_autocomplete_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - model = context['model'] _check_access('package_autocomplete', context, data_dict) @@ -1248,6 +1285,7 @@ def package_autocomplete(context, data_dict): return pkg_list +@logic.validate(logic.schema.default_autocomplete_schema) def format_autocomplete(context, data_dict): '''Return a list of resource formats whose names contain a string. @@ -1260,11 +1298,6 @@ def format_autocomplete(context, data_dict): :rtype: list of strings ''' - schema = context.get('schema', logic.schema.default_autocomplete_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - model = context['model'] session = context['session'] @@ -1288,6 +1321,7 @@ def format_autocomplete(context, data_dict): return [resource.format.lower() for resource in query] +@logic.validate(logic.schema.default_autocomplete_schema) def user_autocomplete(context, data_dict): '''Return a list of user names that contain a string. @@ -1301,11 +1335,6 @@ def user_autocomplete(context, data_dict): ``'fullname'``, and ``'id'`` ''' - schema = context.get('schema', logic.schema.default_autocomplete_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - model = context['model'] user = context['user'] @@ -1344,13 +1373,13 @@ def package_search(context, data_dict): This action accepts a *subset* of solr's search query parameters: - :param q: the solr query. Optional. Default: `"*:*"` + :param q: the solr query. Optional. Default: ``"*:*"`` :type q: string - :param fq: any filter queries to apply. Note: `+site_id:{ckan_site_id}` + :param fq: any filter queries to apply. Note: ``+site_id:{ckan_site_id}`` is added to this string prior to the query being executed. :type fq: string :param sort: sorting of the search results. Optional. Default: - 'relevance asc, metadata_modified desc'. As per the solr + ``'relevance asc, metadata_modified desc'``. As per the solr documentation, this is a comma-separated string of field names and sort-orderings. :type sort: string @@ -1359,7 +1388,7 @@ def package_search(context, data_dict): :param start: the offset in the complete result for where the set of returned datasets should begin. :type start: int - :param facet: whether to enable faceted results. Default: "true". + :param facet: whether to enable faceted results. Default: ``True``. :type facet: string :param facet.mincount: the minimum counts for facet fields should be included in the results. @@ -1405,6 +1434,9 @@ def package_search(context, data_dict): "count", "display_name" and "name" entries. The display_name is a form of the name that can be used in titles. :type search_facets: nested dict of dicts. + :param use_default_schema: use default package schema instead of + a custom schema defined with an IDatasetForm plugin (default: False) + :type use_default_schema: bool An example result: :: @@ -1433,8 +1465,6 @@ def package_search(context, data_dict): # sometimes context['schema'] is None schema = (context.get('schema') or logic.schema.default_package_search_schema()) - if isinstance(data_dict.get('facet.field'), basestring): - data_dict['facet.field'] = json.loads(data_dict['facet.field']) data_dict, errors = _validate(data_dict, schema, context) # put the extras back into the data_dict so that the search can # report needless parameters @@ -1462,16 +1492,15 @@ def package_search(context, data_dict): # the query abort = data_dict.get('abort_search', False) - if data_dict.get('sort') in (None, 'rank'): - data_dict['sort'] = 'score desc, metadata_modified desc' - if data_dict.get('sort') in (None, 'rank'): data_dict['sort'] = 'score desc, metadata_modified desc' results = [] if not abort: + data_source = 'data_dict' if data_dict.get('use_default_schema', + False) else 'validated_data_dict' # return a list of package ids - data_dict['fl'] = 'id data_dict' + data_dict['fl'] = 'id {0}'.format(data_source) # If this query hasn't come from a controller that has set this flag # then we should remove any mention of capacity from the fq and @@ -1493,7 +1522,7 @@ def package_search(context, data_dict): for package in query.results: # get the package object - package, package_dict = package['id'], package.get('data_dict') + package, package_dict = package['id'], package.get(data_source) pkg_query = session.query(model.PackageRevision)\ .filter(model.PackageRevision.id == package)\ .filter(_and_( @@ -1573,6 +1602,7 @@ def package_search(context, data_dict): return search_results +@logic.validate(logic.schema.default_resource_search_schema) def resource_search(context, data_dict): ''' Searches for resources satisfying a given search criteria. @@ -1630,7 +1660,7 @@ def resource_search(context, data_dict): queries (rather than ValidationErrors). :param query: The search criteria. See above for description. - :type query: string or list of strings of the form "{field}:{term1}" + :type query: string or list of strings of the form ``{field}:{term1}`` :param fields: Deprecated :type fields: dict of fields to search terms. :param order_by: A field on the Resource model that orders the results. @@ -1644,11 +1674,6 @@ def resource_search(context, data_dict): :rtype: dict ''' - schema = context.get('schema', logic.schema.default_resource_search_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - model = context['model'] # Allow either the `query` or `fields` parameter to be given, but not both. @@ -2087,6 +2112,7 @@ def vocabulary_show(context, data_dict): vocabulary_dict = model_dictize.vocabulary_dictize(vocabulary, context) return vocabulary_dict +@logic.validate(logic.schema.default_activity_list_schema) def user_activity_list(context, data_dict): '''Return a user's public activity stream. @@ -2112,12 +2138,12 @@ def user_activity_list(context, data_dict): model = context['model'] - user_ref = _get_or_bust(data_dict, 'id') # May be user name or id. + user_ref = data_dict.get('id') # May be user name or id. user = model.User.get(user_ref) if user is None: raise logic.NotFound - offset = int(data_dict.get('offset', 0)) + offset = data_dict.get('offset', 0) limit = int( data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) @@ -2125,6 +2151,7 @@ def user_activity_list(context, data_dict): offset=offset) return model_dictize.activity_list_dictize(activity_objects, context) +@logic.validate(logic.schema.default_activity_list_schema) def package_activity_list(context, data_dict): '''Return a package's activity stream. @@ -2149,7 +2176,7 @@ def package_activity_list(context, data_dict): model = context['model'] - package_ref = _get_or_bust(data_dict, 'id') # May be name or ID. + package_ref = data_dict.get('id') # May be name or ID. package = model.Package.get(package_ref) if package is None: raise logic.NotFound @@ -2162,6 +2189,7 @@ def package_activity_list(context, data_dict): limit=limit, offset=offset) return model_dictize.activity_list_dictize(activity_objects, context) +@logic.validate(logic.schema.default_activity_list_schema) def group_activity_list(context, data_dict): '''Return a group's activity stream. @@ -2185,8 +2213,8 @@ def group_activity_list(context, data_dict): _check_access('group_show', context, data_dict) model = context['model'] - group_id = _get_or_bust(data_dict, 'id') - offset = int(data_dict.get('offset', 0)) + group_id = data_dict.get('id') + offset = data_dict.get('offset', 0) limit = int( data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) @@ -2198,6 +2226,7 @@ def group_activity_list(context, data_dict): limit=limit, offset=offset) return model_dictize.activity_list_dictize(activity_objects, context) +@logic.validate(logic.schema.default_activity_list_schema) def organization_activity_list(context, data_dict): '''Return a organization's activity stream. @@ -2212,8 +2241,8 @@ def organization_activity_list(context, data_dict): _check_access('organization_show', context, data_dict) model = context['model'] - org_id = _get_or_bust(data_dict, 'id') - offset = int(data_dict.get('offset', 0)) + org_id = data_dict.get('id') + offset = data_dict.get('offset', 0) limit = int( data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) @@ -2225,6 +2254,7 @@ def organization_activity_list(context, data_dict): limit=limit, offset=offset) return model_dictize.activity_list_dictize(activity_objects, context) +@logic.validate(logic.schema.default_pagination_schema) def recently_changed_packages_activity_list(context, data_dict): '''Return the activity stream of all recently added or changed packages. @@ -2242,7 +2272,7 @@ def recently_changed_packages_activity_list(context, data_dict): # FIXME: Filter out activities whose subject or object the user is not # authorized to read. model = context['model'] - offset = int(data_dict.get('offset', 0)) + offset = data_dict.get('offset', 0) limit = int( data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) @@ -2370,7 +2400,16 @@ def organization_activity_list_html(context, data_dict): ''' activity_stream = organization_activity_list(context, data_dict) - return activity_streams.activity_list_to_html(context, activity_stream) + offset = int(data_dict.get('offset', 0)) + extra_vars = { + 'controller': 'organization', + 'action': 'activity', + 'id': data_dict['id'], + 'offset': offset, + } + + return activity_streams.activity_list_to_html(context, activity_stream, + extra_vars) def recently_changed_packages_activity_list_html(context, data_dict): '''Return the activity stream of all recently changed packages as HTML. @@ -2655,6 +2694,7 @@ def group_followee_count(context, data_dict): context['model'].UserFollowingGroup) +@logic.validate(logic.schema.default_follow_user_schema) def followee_list(context, data_dict): '''Return the list of objects that are followed by the given user. @@ -2669,19 +2709,15 @@ def followee_list(context, data_dict): (optional) :type q: string - :rtype: list of dictionaries, each with keys 'type' (e.g. 'user', - 'dataset' or 'group'), 'display_name' (e.g. a user's display name, - or a package's title) and 'dict' (e.g. a dict representing the - followed user, package or group, the same as the dict that would be - returned by user_show, package_show or group_show) + :rtype: list of dictionaries, each with keys ``'type'`` (e.g. ``'user'``, + ``'dataset'`` or ``'group'``), ``'display_name'`` (e.g. a user's + display name, or a package's title) and ``'dict'`` (e.g. a dict + representing the followed user, package or group, the same as the dict + that would be returned by :py:func:`user_show`, + :py:func:`package_show` or :py:func:`group_show`) ''' _check_access('followee_list', context, data_dict) - schema = context.get('schema') or ( - ckan.logic.schema.default_follow_user_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) def display_name(followee): '''Return a display name for the given user, group or dataset dict.''' @@ -2814,6 +2850,7 @@ def group_followee_list(context, data_dict): return [model_dictize.group_dictize(group, context) for group in groups] +@logic.validate(logic.schema.default_pagination_schema) def dashboard_activity_list(context, data_dict): '''Return the authorized user's dashboard activity stream. @@ -2829,22 +2866,16 @@ def dashboard_activity_list(context, data_dict): :type offset: int :param limit: the maximum number of activities to return (optional, default: 31, the default value is configurable via the - ``ckan.activity_list_limit`` setting) + :ref:`ckan.activity_list_limit` setting) :rtype: list of activity dictionaries ''' - schema = context.get('schema', - ckan.logic.schema.default_pagination_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - _check_access('dashboard_activity_list', context, data_dict) model = context['model'] user_id = model.User.get(context['user']).id - offset = int(data_dict.get('offset', 0)) + offset = data_dict.get('offset', 0) limit = int( data_dict.get('limit', config.get('ckan.activity_list_limit', 31))) @@ -2871,6 +2902,7 @@ def dashboard_activity_list(context, data_dict): return activity_dicts +@logic.validate(ckan.logic.schema.default_pagination_schema) def dashboard_activity_list_html(context, data_dict): '''Return the authorized user's dashboard activity stream as HTML. @@ -2890,15 +2922,9 @@ def dashboard_activity_list_html(context, data_dict): :rtype: string ''' - schema = context.get( - 'schema', ckan.logic.schema.default_pagination_schema()) - data_dict, errors = _validate(data_dict, schema, context) - if errors: - raise ValidationError(errors) - activity_stream = dashboard_activity_list(context, data_dict) model = context['model'] - offset = int(data_dict.get('offset', 0)) + offset = data_dict.get('offset', 0) extra_vars = { 'controller': 'user', 'action': 'dashboard', @@ -2957,11 +2983,20 @@ def _unpick_search(sort, allowed_fields=None, total=None): def member_roles_list(context, data_dict): '''Return the possible roles for members of groups and organizations. - :returns: a list of dictionaries each with two keys: "text" (the display - name of the role, e.g. "Admin") and "value" (the internal name of the - role, e.g. "admin") + :param group_type: the group type, either ``"group"`` or ``"organization"`` + (optional, default ``"organization"``) + :type id: string + :returns: a list of dictionaries each with two keys: ``"text"`` (the + display name of the role, e.g. ``"Admin"``) and ``"value"`` (the + internal name of the role, e.g. ``"admin"``) :rtype: list of dictionaries ''' + group_type = data_dict.get('group_type', 'organization') + roles_list = new_authz.roles_list() + if group_type == 'group': + roles_list = [role for role in roles_list + if role['value'] != 'editor'] + _check_access('member_roles_list', context, data_dict) - return new_authz.roles_list() + return roles_list diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index f014139ed90..096145c6fae 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -20,6 +20,7 @@ import ckan.lib.email_notifications as email_notifications import ckan.lib.search as search import ckan.lib.datapreview as datapreview +import ckan.lib.uploader as uploader from ckan.common import _, request @@ -67,7 +68,9 @@ def _make_latest_rev_active(context, q): context['latest_revision'] = latest_rev.revision_id def make_latest_pending_package_active(context, data_dict): - '''TODO: What does this function do? + ''' + + .. todo:: What does this function do? You must be authorized to update the dataset. @@ -120,7 +123,8 @@ def related_update(context, data_dict): You must be the owner of a related item to update it. - For further parameters see ``related_create()``. + For further parameters see + :py:func:`~ckan.logic.action.create.related_create`. :param id: the id of the related item to update :type id: string @@ -133,7 +137,7 @@ def related_update(context, data_dict): id = _get_or_bust(data_dict, "id") session = context['session'] - schema = context.get('schema') or schema_.default_related_schema() + schema = context.get('schema') or schema_.default_update_related_schema() related = model.Related.get(id) context["related"] = related @@ -187,7 +191,8 @@ def resource_update(context, data_dict): To update a resource you must be authorized to update the dataset that the resource belongs to. - For further parameters see ``resource_create()``. + For further parameters see + :py:func:`~ckan.logic.action.create.resource_create`. :param id: the id of the resource to update :type id: string @@ -219,15 +224,23 @@ def resource_update(context, data_dict): else: logging.error('Could not find resource ' + id) raise NotFound(_('Resource was not found.')) + + upload = uploader.ResourceUpload(data_dict) + pkg_dict['resources'][n] = data_dict try: + context['defer_commit'] = True + context['use_cache'] = False pkg_dict = _get_action('package_update')(context, pkg_dict) + context.pop('defer_commit') except ValidationError, e: errors = e.error_dict['resources'][n] raise ValidationError(errors) - return pkg_dict['resources'][n] + upload.upload(id, uploader.get_max_resource_size()) + model.repo.commit() + return _get_action('resource_show')(context, {'id': id}) def resource_view_update(context, data_dict): @@ -325,18 +338,23 @@ def package_update(context, data_dict): You must be authorized to edit the dataset and the groups that it belongs to. + + It is recommended to call + :py:func:`ckan.logic.action.get.package_show`, make the desired changes to + the result, and then call ``package_update()`` with it. Plugins may change the parameters of this function depending on the value - of the dataset's ``type`` attribute, see the ``IDatasetForm`` plugin - interface. + of the dataset's ``type`` attribute, see the + :py:class:`~ckan.plugins.interfaces.IDatasetForm` plugin interface. - For further parameters see ``package_create()``. + For further parameters see + :py:func:`~ckan.logic.action.create.package_create`. :param id: the name or id of the dataset to update :type id: string - :returns: the updated dataset (if 'return_package_dict' is True in the - context, which is the default. Otherwise returns just the + :returns: the updated dataset (if ``'return_package_dict'`` is ``True`` in + the context, which is the default. Otherwise returns just the dataset id) :rtype: dictionary @@ -426,6 +444,50 @@ def package_update(context, data_dict): return output +def package_resource_reorder(context, data_dict): + '''Reorder resources against datasets. If only partial resource ids are + supplied then these are assumed to be first and the other resources will + stay in their original order + + :param id: the id or name of the package to update + :type id: string + :param order: a list of resource ids in the order needed + :type list: list + ''' + + id = _get_or_bust(data_dict, "id") + order = _get_or_bust(data_dict, "order") + if not isinstance(order, list): + raise ValidationError({'order': 'Must be a list of resource'}) + + if len(set(order)) != len(order): + raise ValidationError({'order': 'Must supply unique resource_ids'}) + + package_dict = _get_action('package_show')(context, {'id': id}) + existing_resources = package_dict.get('resources', []) + ordered_resources = [] + + for resource_id in order: + for i in range(0, len(existing_resources)): + if existing_resources[i]['id'] == resource_id: + resource = existing_resources.pop(i) + ordered_resources.append(resource) + break + else: + raise ValidationError( + {'order': + 'resource_id {id} can not be found'.format(id=resource_id)} + ) + + new_resources = ordered_resources + existing_resources + package_dict['resources'] = new_resources + + _check_access('package_resource_reorder', context, package_dict) + _get_action('package_update')(context, package_dict) + + return {'id': id, 'order': [resource['id'] for resource in new_resources]} + + def _update_package_relationship(relationship, comment, context): model = context['model'] api = context.get('api_version') @@ -499,7 +561,6 @@ def _group_or_org_update(context, data_dict, is_org=False): user = context['user'] session = context['session'] id = _get_or_bust(data_dict, 'id') - parent = context.get('parent', None) group = model.Group.get(id) context["group"] = group @@ -515,6 +576,10 @@ def _group_or_org_update(context, data_dict, is_org=False): except AttributeError: schema = group_plugin.form_to_db_schema() + upload = uploader.Upload('group', group.image_url) + upload.update_data_dict(data_dict, 'image_url', + 'image_upload', 'clear_upload') + if is_org: _check_access('organization_update', context, data_dict) else: @@ -555,23 +620,6 @@ def _group_or_org_update(context, data_dict, is_org=False): context['prevent_packages_update'] = True group = model_save.group_dict_save(data, context) - if parent: - parent_group = model.Group.get( parent ) - if parent_group and not parent_group in group.get_groups(group.type): - # Delete all of this groups memberships - current = session.query(model.Member).\ - filter(model.Member.table_id == group.id).\ - filter(model.Member.table_name == "group").all() - if current: - log.debug('Parents of group %s deleted: %r', group.name, - [membership.group.name for membership in current]) - for c in current: - session.delete(c) - member = model.Member(group=parent_group, table_id=group.id, table_name='group') - session.add(member) - log.debug('Group %s is made child of group %s', - group.name, parent_group.name) - if is_org: plugin_type = plugins.IOrganizationController else: @@ -619,9 +667,11 @@ def _group_or_org_update(context, data_dict, is_org=False): # TODO: Also create an activity detail recording what exactly changed # in the group. + upload.upload(uploader.get_max_image_size()) if not context.get('defer_commit'): model.repo.commit() + return model_dictize.group_dictize(group, context) def group_update(context, data_dict): @@ -630,9 +680,11 @@ def group_update(context, data_dict): You must be authorized to edit the group. Plugins may change the parameters of this function depending on the value - of the group's ``type`` attribute, see the ``IGroupForm`` plugin interface. + of the group's ``type`` attribute, see the + :py:class:`~ckan.plugins.interfaces.IGroupForm` plugin interface. - For further parameters see ``group_create()``. + For further parameters see + :py:func:`~ckan.logic.action.create.group_create`. :param id: the name or id of the group to update :type id: string @@ -648,7 +700,8 @@ def organization_update(context, data_dict): You must be authorized to edit the organization. - For further parameters see ``organization_create()``. + For further parameters see + :py:func:`~ckan.logic.action.create.organization_create`. :param id: the name or id of the organization to update :type id: string @@ -665,7 +718,8 @@ def user_update(context, data_dict): Normal users can only update their own user accounts. Sysadmins can update any user account. - For further parameters see ``user_create()``. + For further parameters see + :py:func:`~ckan.logic.action.create.user_create`. :param id: the name or id of the user to update :type id: string @@ -772,7 +826,8 @@ def task_status_update_many(context, data_dict): '''Update many task statuses at once. :param data: the task_status dictionaries to update, for the format of task - status dictionaries see ``task_status_update()`` + status dictionaries see + :py:func:`~task_status_update` :type data: list of dictionaries :returns: the updated task statuses @@ -846,7 +901,7 @@ def term_translation_update_many(context, data_dict): :param data: the term translation dictionaries to create or update, for the format of term translation dictionaries see - ``term_translation_update()`` + :py:func:`~term_translation_update` :type data: list of dictionaries :returns: a dictionary with key ``'success'`` whose value is a string @@ -930,7 +985,8 @@ def vocabulary_update(context, data_dict): You must be a sysadmin to update vocabularies. - For further parameters see ``vocabulary_create()``. + For further parameters see + :py:func:`~ckan.logic.action.create.vocabulary_create`. :param id: the id of the vocabulary to update :type id: string @@ -1055,7 +1111,7 @@ def user_role_bulk_update(context, data_dict): You must be authorized to update the domain object. :param user_roles: the updated user roles, for the format of user role - dictionaries see ``user_role_update()`` + dictionaries see :py:func:`~user_role_update` :type user_roles: list of dictionaries :returns: the updated roles of all users and authorization groups for the @@ -1084,7 +1140,8 @@ def user_role_bulk_update(context, data_dict): def dashboard_mark_activities_old(context, data_dict): '''Mark all the authorized user's new dashboard activities as old. - This will reset dashboard_new_activities_count to 0. + This will reset + :py:func:`~ckan.logic.action.get.dashboard_new_activities_count` to 0. ''' _check_access('dashboard_mark_activities_old', context, diff --git a/ckan/logic/auth/create.py b/ckan/logic/auth/create.py index 99b202551d7..80ffc7b8c77 100644 --- a/ckan/logic/auth/create.py +++ b/ckan/logic/auth/create.py @@ -1,5 +1,6 @@ import ckan.logic as logic import ckan.new_authz as new_authz +import ckan.logic.auth as logic_auth from ckan.common import _ @@ -109,21 +110,36 @@ def rating_create(context, data_dict): # No authz check in the logic function return {'success': True} + @logic.auth_allow_anonymous_access def user_create(context, data_dict=None): - user = context['user'] - - if ('api_version' in context - and not new_authz.check_config_permission('create_user_via_api')): - return {'success': False, 'msg': _('User %s not authorized to create users') % user} - else: - return {'success': True} + using_api = 'api_version' in context + create_user_via_api = new_authz.check_config_permission( + 'create_user_via_api') + create_user_via_web = new_authz.check_config_permission( + 'create_user_via_web') + + if using_api and not create_user_via_api: + return {'success': False, 'msg': _('User {user} not authorized to ' + 'create users via the API').format(user=context.get('user'))} + if not using_api and not create_user_via_web: + return {'success': False, 'msg': _('Not authorized to ' + 'create users')} + return {'success': True} def user_invite(context, data_dict=None): context['id'] = context.get('group_id') return group_member_create(context, data_dict) def _check_group_auth(context, data_dict): + '''Has this user got update permission for all of the given groups? + If there is a package in the context then ignore that package's groups. + (owner_org is checked elsewhere.) + :returns: False if not allowed to update one (or more) of the given groups. + True otherwise. i.e. True is the default. A blank data_dict + mentions no groups, so it returns True. + + ''' # FIXME This code is shared amoung other logic.auth files and should be # somewhere better if not data_dict: @@ -135,7 +151,7 @@ def _check_group_auth(context, data_dict): api_version = context.get('api_version') or '1' - group_blobs = data_dict.get("groups", []) + group_blobs = data_dict.get('groups', []) groups = set() for group_blob in group_blobs: # group_blob might be a dict or a group_ref @@ -206,3 +222,23 @@ def organization_member_create(context, data_dict): def group_member_create(context, data_dict): return _group_or_org_member_create(context, data_dict) + +def member_create(context, data_dict): + group = logic_auth.get_group_object(context, data_dict) + user = context['user'] + + # User must be able to update the group to add a member to it + permission = 'update' + # However if the user is member of group then they can add/remove datasets + if not group.is_organization and data_dict.get('object_type') == 'package': + permission = 'manage_group' + + authorized = new_authz.has_user_permission_for_group_or_org(group.id, + user, + permission) + if not authorized: + return {'success': False, + 'msg': _('User %s not authorized to edit group %s') % + (str(user), group.id)} + else: + return {'success': True} diff --git a/ckan/logic/auth/delete.py b/ckan/logic/auth/delete.py index d29fab903e4..a8f9b49aab1 100644 --- a/ckan/logic/auth/delete.py +++ b/ckan/logic/auth/delete.py @@ -2,6 +2,7 @@ import ckan.new_authz as new_authz from ckan.logic.auth import get_package_object, get_group_object, get_related_object from ckan.logic.auth import get_resource_object +import ckan.logic.auth.create as _auth_create from ckan.lib.base import _ @@ -134,19 +135,13 @@ def tag_delete(context, data_dict): # sysadmins only return {'success': False} -def _group_or_org_member_delete(context, data_dict): - group = get_group_object(context, data_dict) - user = context['user'] - authorized = new_authz.has_user_permission_for_group_or_org( - group.id, user, 'delete_member') - if not authorized: - return {'success': False, 'msg': _('User %s not authorized to delete organization %s members') % (user, group.id)} - else: - return {'success': True} - return {'success': True} - def group_member_delete(context, data_dict): - return _group_or_org_member_delete(context, data_dict) + ## just return true as logic runs through member_delete + return {'success': True} def organization_member_delete(context, data_dict): - return _group_or_org_member_delete(context, data_dict) + ## just return true as logic runs through member_delete + return {'success': True} + +def member_delete(context, data_dict): + return _auth_create.member_create(context, data_dict) diff --git a/ckan/logic/auth/get.py b/ckan/logic/auth/get.py index 32d8069b98f..ee25ede3601 100644 --- a/ckan/logic/auth/get.py +++ b/ckan/logic/auth/get.py @@ -39,6 +39,9 @@ def revision_list(context, data_dict): def group_revision_list(context, data_dict): return group_show(context, data_dict) +def organization_revision_list(context, data_dict): + return group_show(context, data_dict) + def package_revision_list(context, data_dict): return package_show(context, data_dict) diff --git a/ckan/logic/auth/update.py b/ckan/logic/auth/update.py index b0c7cbb6652..12500cdf6e8 100644 --- a/ckan/logic/auth/update.py +++ b/ckan/logic/auth/update.py @@ -17,7 +17,7 @@ def package_update(context, data_dict): if package.owner_org: # if there is an owner org then we must have update_dataset - # premission for that organization + # permission for that organization check1 = new_authz.has_user_permission_for_group_or_org( package.owner_org, user, 'update_dataset' ) @@ -41,6 +41,9 @@ def package_update(context, data_dict): return {'success': True} +def package_resource_reorder(context, data_dict): + ## the action function runs package update so no need to run it twice + return {'success': True} def resource_update(context, data_dict): model = context['model'] diff --git a/ckan/logic/converters.py b/ckan/logic/converters.py index 74aab6928a6..f6a844afb99 100644 --- a/ckan/logic/converters.py +++ b/ckan/logic/converters.py @@ -1,3 +1,5 @@ +import json + import ckan.model as model import ckan.lib.navl.dictization_functions as df import ckan.lib.field_types as field_types @@ -169,3 +171,13 @@ def convert_group_name_or_id_to_id(group_name_or_id, context): if not result: raise df.Invalid('%s: %s' % (_('Not found'), _('Group'))) return result.id + + +def convert_to_json_if_string(value, context): + if isinstance(value, basestring): + try: + return json.loads(value) + except ValueError: + raise df.Invalid(_('Could not parse as valid JSON')) + else: + return value diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index 2eee2a963d8..d910e895c22 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -33,6 +33,7 @@ isodate, int_validator, natural_number_validator, + is_positive_integer, boolean_validator, user_about_validator, vocabulary_name_validator, @@ -51,10 +52,13 @@ url_validator, datasets_with_no_organization_cannot_be_private, list_of_strings, + no_loops_in_hierarchy, ) from ckan.logic.converters import (convert_user_name_or_id_to_id, convert_package_name_or_id_to_id, - convert_group_name_or_id_to_id,) + convert_group_name_or_id_to_id, + convert_to_json_if_string, + ) from formencode.validators import OneOf import ckan.model import ckan.lib.maintain as maintain @@ -224,6 +228,8 @@ def default_show_package_schema(): schema['groups'].update({ 'description': [ignore_missing], + 'display_name': [ignore_missing], + 'image_display_url': [ignore_missing], }) # Remove validators for several keys from the schema so validation doesn't @@ -264,6 +270,7 @@ def default_group_schema(): 'title': [ignore_missing, unicode], 'description': [ignore_missing, unicode], 'image_url': [ignore_missing, unicode], + 'image_display_url': [ignore_missing, unicode], 'type': [ignore_missing, unicode], 'state': [ignore_not_group_admin, ignore_missing], 'created': [ignore], @@ -271,16 +278,12 @@ def default_group_schema(): 'approval_status': [ignore_missing, unicode], 'extras': default_extras_schema(), '__extras': [ignore], + '__junk': [ignore], 'packages': { "id": [not_empty, unicode, package_id_or_name_exists], "title":[ignore_missing, unicode], "name":[ignore_missing, unicode], "__extras": [ignore] - }, - 'groups': { - "name": [not_empty, unicode], - "capacity": [ignore_missing], - "__extras": [ignore] }, 'users': { "name": [not_empty, unicode], @@ -288,7 +291,7 @@ def default_group_schema(): "__extras": [ignore] }, 'groups': { - "name": [not_empty, unicode], + "name": [not_empty, no_loops_in_hierarchy, unicode], "capacity": [ignore_missing], "__extras": [ignore] } @@ -333,6 +336,15 @@ def default_related_schema(): return schema +def default_update_related_schema(): + schema = default_related_schema() + schema['id'] = [not_empty, unicode] + schema['title'] = [ignore_missing, unicode] + schema['type'] = [ignore_missing, unicode] + schema['owner_id'] = [ignore_missing, unicode] + return schema + + def default_extras_schema(): schema = { @@ -342,6 +354,7 @@ def default_extras_schema(): 'state': [ignore], 'deleted': [ignore_missing], 'revision_timestamp': [ignore], + '__extras': [ignore], } return schema @@ -513,7 +526,7 @@ def default_package_list_schema(): schema = { 'limit': [ignore_missing, natural_number_validator], 'offset': [ignore_missing, natural_number_validator], - 'page': [ignore_missing, natural_number_validator] + 'page': [ignore_missing, is_positive_integer] } return schema @@ -532,6 +545,12 @@ def default_dashboard_activity_list_schema(): return schema +def default_activity_list_schema(): + schema = default_pagination_schema() + schema['id'] = [not_missing, unicode] + return schema + + def default_autocomplete_schema(): schema = { 'q': [not_missing, unicode], @@ -551,7 +570,8 @@ def default_package_search_schema(): 'facet': [ignore_missing, unicode], 'facet.mincount': [ignore_missing, natural_number_validator], 'facet.limit': [ignore_missing, int_validator], - 'facet.field': [ignore_missing, list_of_strings], + 'facet.field': [ignore_missing, convert_to_json_if_string, + list_of_strings], 'extras': [ignore_missing] # Not used by Solr, but useful for extensions } return schema diff --git a/ckan/logic/validators.py b/ckan/logic/validators.py index 8faf6fe2696..85cc0dfd8bc 100644 --- a/ckan/logic/validators.py +++ b/ckan/logic/validators.py @@ -32,9 +32,12 @@ def owner_org_validator(key, data, errors, context): model = context['model'] user = context['user'] user = model.User.get(user) - if value == '': + if value == '' : + if not new_authz.check_config_permission('create_unowned_dataset'): + raise Invalid(_('A organization must be supplied')) + package = context.get('package') # only sysadmins can remove datasets from org - if not user.sysadmin: + if package and package.owner_org and not user.sysadmin: raise Invalid(_('You cannot remove a dataset from an existing organization')) return @@ -42,7 +45,9 @@ def owner_org_validator(key, data, errors, context): if not group: raise Invalid(_('Organization does not exist')) group_id = group.id - if not(user.sysadmin or user.is_in_group(group_id)): + if not(user.sysadmin or + new_authz.has_user_permission_for_group_or_org( + group_id, user.name, 'create_dataset')): raise Invalid(_('You cannot add a dataset to this organization')) data[key] = group_id @@ -68,7 +73,13 @@ def int_validator(value, context): def natural_number_validator(value, context): value = int_validator(value, context) if value < 0: - raise Invalid(_('Must be natural number')) + raise Invalid(_('Must be a natural number')) + return value + +def is_positive_integer(value, context): + value = int_validator(value, context) + if value < 1: + raise Invalid(_('Must be a postive integer')) return value def boolean_validator(value, context): @@ -691,7 +702,28 @@ def role_exists(role, context): def datasets_with_no_organization_cannot_be_private(key, data, errors, context): - if data[key] is True and data.get(('owner_org',)) is None: + + dataset_id = data.get(('id',)) + owner_org = data.get(('owner_org',)) + private = data[key] is True + + check_passed = True + + if not dataset_id and private and not owner_org: + # When creating a dataset, enforce it directly + check_passed = False + elif dataset_id and private and not owner_org: + # Check if the dataset actually has an owner_org, even if not provided + try: + dataset_dict = logic.get_action('package_show')({}, + {'id': dataset_id}) + if not dataset_dict.get('owner_org'): + check_passed = False + + except logic.NotFound: + check_passed = False + + if not check_passed: errors[key].append( _("Datasets with no organization can't be private.")) @@ -704,3 +736,22 @@ def list_of_strings(key, data, errors, context): if not isinstance(x, basestring): raise Invalid('%s: %s' % (_('Not a string'), x)) +def no_loops_in_hierarchy(key, data, errors, context): + '''Checks that the parent groups specified in the data would not cause + a loop in the group hierarchy, and therefore cause the recursion up/down + the hierarchy to get into an infinite loop. + ''' + if not 'id' in data: + # Must be a new group - has no children, so no chance of loops + return + group = context['model'].Group.get(data['id']) + allowable_parents = group.\ + groups_allowed_to_be_its_parent(type=group.type) + for parent in data['groups']: + parent_name = parent['name'] + # a blank name signifies top level, which is always allowed + if parent_name and context['model'].Group.get(parent_name) \ + not in allowable_parents: + raise Invalid(_('This parent would create a loop in the ' + 'hierarchy')) + diff --git a/ckan/model/group.py b/ckan/model/group.py index 86963983e9a..0d2102375f2 100644 --- a/ckan/model/group.py +++ b/ckan/model/group.py @@ -1,6 +1,6 @@ import datetime -from sqlalchemy import orm, types, Column, Table, ForeignKey, or_ +from sqlalchemy import orm, types, Column, Table, ForeignKey, or_, and_ import vdm.sqlalchemy import meta @@ -54,6 +54,20 @@ class Member(vdm.sqlalchemy.RevisionedObjectMixin, vdm.sqlalchemy.StatefulObjectMixin, domain_object.DomainObject): + '''A Member object represents any other object being a 'member' of a + particular Group. + + Meanings: + * Package - the Group is a collection of Packages + - capacity is 'public', 'private' + or 'organization' if the Group is an Organization + (see ckan.logic.action.package_owner_org_update) + * User - the User is granted permissions for the Group + - capacity is 'admin', 'editor' or 'member' + * Group - the Group (Member.group_id) is a parent of the Group (Member.id) + in a hierarchy. + - capacity is 'parent' + ''' def __init__(self, group=None, table_id=None, group_id=None, table_name=None, capacity='public', state='active'): self.group = group @@ -87,6 +101,21 @@ def related_packages(self): return meta.Session.query(_package.Package).filter_by( id=self.table_id).all() + def __unicode__(self): + # refer to objects by name, not ID, to help debugging + if self.table_name == 'package': + table_info = 'package=%s' % meta.Session.query(_package.Package).\ + get(self.table_id).name + elif self.table_name == 'group': + table_info = 'group=%s' % meta.Session.query(Group).\ + get(self.table_id).name + else: + table_info = 'table_name=%s table_id=%s' % (self.table_name, + self.table_id) + return u'' % \ + (self.group.name if self.group else repr(self.group), + table_info, self.capacity, self.state) + class Group(vdm.sqlalchemy.RevisionedObjectMixin, vdm.sqlalchemy.StatefulObjectMixin, @@ -145,17 +174,93 @@ def set_approval_status(self, status): pass def get_children_groups(self, type='group'): - # Returns a list of dicts where each dict contains "id", "name", - # and "title" When querying with a CTE specifying a model in the - # query parameter causes problems as it returns only the first - # level deep apparently not recursing any deeper than that. If - # we simplify and request only specific fields then if returns - # the full depth of the hierarchy. - results = meta.Session.query("id", "name", "title").\ - from_statement(HIERARCHY_CTE).params(id=self.id, type=type).all() - return [{"id":idf, "name": name, "title": title} - for idf, name, title in results] + '''Returns the groups one level underneath this group in the hierarchy. + ''' + # The original intention of this method was to provide the full depth + # of the tree, but the CTE was incorrect. This new query does what that + # old CTE actually did, but is now far simpler, and returns Group objects + # instead of a dict. + return meta.Session.query(Group).\ + filter_by(type=type).\ + filter_by(state='active').\ + join(Member, Member.group_id == Group.id).\ + filter_by(table_id=self.id).\ + filter_by(table_name='group').\ + filter_by(state='active').\ + all() + + def get_children_group_hierarchy(self, type='group'): + '''Returns the groups in all levels underneath this group in the + hierarchy. The ordering is such that children always come after their + parent. + + :rtype: a list of tuples, each one a Group ID, name and title and then + the ID of its parent group. + + e.g. + >>> dept-health.get_children_group_hierarchy() + [(u'8ac0...', u'national-health-service', u'National Health Service', u'e041...'), + (u'b468...', u'nhs-wirral-ccg', u'NHS Wirral CCG', u'8ac0...')] + ''' + results = meta.Session.query(Group.id, Group.name, Group.title, + 'parent_id').\ + from_statement(HIERARCHY_DOWNWARDS_CTE).\ + params(id=self.id, type=type).all() + return results + + def get_parent_groups(self, type='group'): + '''Returns this group's parent groups. + Returns a list. Will have max 1 value for organizations. + + ''' + return meta.Session.query(Group).\ + join(Member, + and_(Member.table_id == Group.id, + Member.table_name == 'group', + Member.state == 'active')).\ + filter(Member.group_id == self.id).\ + filter(Group.type == type).\ + filter(Group.state == 'active').\ + all() + + def get_parent_group_hierarchy(self, type='group'): + '''Returns this group's parent, parent's parent, parent's parent's + parent etc.. Sorted with the top level parent first.''' + return meta.Session.query(Group).\ + from_statement(HIERARCHY_UPWARDS_CTE).\ + params(id=self.id, type=type).all() + + @classmethod + def get_top_level_groups(cls, type='group'): + '''Returns a list of the groups (of the specified type) which have + no parent groups. Groups are sorted by title. + ''' + return meta.Session.query(cls).\ + outerjoin(Member, + and_(Member.group_id == Group.id, + Member.table_name == 'group', + Member.state == 'active')).\ + filter(Member.id == None).\ + filter(Group.type == type).\ + filter(Group.state == 'active').\ + order_by(Group.title).all() + + def groups_allowed_to_be_its_parent(self, type='group'): + '''Returns a list of the groups (of the specified type) which are + allowed to be this group's parent. It excludes ones which would + create a loop in the hierarchy, causing the recursive CTE to + be in an infinite loop. + + :returns: A list of group objects ordered by group title + ''' + all_groups = self.all(group_type=type) + excluded_groups = set(group_name + for group_id, group_name, group_title, parent in + self.get_children_group_hierarchy(type=type)) + excluded_groups.add(self.name) + return [group for group in all_groups + if group.name not in excluded_groups] def packages(self, with_private=False, limit=None, return_query=False, context=None): @@ -245,23 +350,6 @@ def add_package_by_name(self, package_name): table_name='package') meta.Session.add(member) - def get_groups(self, group_type=None, capacity=None): - """ Get all groups that this group is within """ - import ckan.model as model - if '_groups' not in self.__dict__: - self._groups = meta.Session.query(model.Group).\ - join(model.Member, model.Member.group_id == model.Group.id and - model.Member.table_name == 'group').\ - filter(model.Member.state == 'active').\ - filter(model.Member.table_id == self.id).all() - - groups = self._groups - if group_type: - groups = [g for g in groups if g.type == group_type] - if capacity: - groups = [g for g in groups if g.capacity == capacity] - return groups - @property def all_related_revisions(self): '''Returns chronological list of all object revisions related to @@ -289,7 +377,6 @@ def all_related_revisions(self): def __repr__(self): return '' % self.name - meta.mapper(Group, group_table, extension=[vdm.sqlalchemy.Revisioner(group_revision_table), ], ) @@ -313,16 +400,41 @@ def __repr__(self): #TODO MemberRevision.related_packages = lambda self: [self.continuity.package] +# Should there arise a bug that allows loops in the group hierarchy, then it +# will lead to infinite recursion, tieing up postgres processes at 100%, and +# the server will suffer. To avoid ever failing this badly, we put in this +# limit on recursion. +MAX_RECURSES = 8 + +HIERARCHY_DOWNWARDS_CTE = """WITH RECURSIVE child(depth) AS +( + -- non-recursive term + SELECT 0, * FROM member + WHERE table_id = :id AND table_name = 'group' AND state = 'active' + UNION ALL + -- recursive term + SELECT c.depth + 1, m.* FROM member AS m, child AS c + WHERE m.table_id = c.group_id AND m.table_name = 'group' + AND m.state = 'active' AND c.depth < {max_recurses} +) +SELECT G.id, G.name, G.title, child.depth, child.table_id as parent_id FROM child + INNER JOIN public.group G ON G.id = child.group_id + WHERE G.type = :type AND G.state='active' + ORDER BY child.depth ASC;""".format(max_recurses=MAX_RECURSES) + +HIERARCHY_UPWARDS_CTE = """WITH RECURSIVE parenttree(depth) AS ( + -- non-recursive term + SELECT 0, M.* FROM public.member AS M + WHERE group_id = :id AND M.table_name = 'group' AND M.state = 'active' + UNION + -- recursive term + SELECT PG.depth + 1, M.* FROM parenttree PG, public.member M + WHERE PG.table_id = M.group_id AND M.table_name = 'group' + AND M.state = 'active' AND PG.depth < {max_recurses} + ) + +SELECT G.*, PT.depth FROM parenttree AS PT + INNER JOIN public.group G ON G.id = PT.table_id + WHERE G.type = :type AND G.state='active' + ORDER BY PT.depth DESC;""".format(max_recurses=MAX_RECURSES) -HIERARCHY_CTE = """WITH RECURSIVE subtree(id) AS ( - SELECT M.* FROM public.member AS M - WHERE M.table_name = 'group' AND M.state = 'active' - UNION - SELECT M.* FROM public.member M, subtree SG - WHERE M.table_id = SG.group_id AND M.table_name = 'group' - AND M.state = 'active') - - SELECT G.* FROM subtree AS ST - INNER JOIN public.group G ON G.id = ST.table_id - WHERE group_id = :id AND G.type = :type and table_name='group' - and G.state='active'""" diff --git a/ckan/new_authz.py b/ckan/new_authz.py index 128bacce7fe..44eb09f5dc9 100644 --- a/ckan/new_authz.py +++ b/ckan/new_authz.py @@ -83,7 +83,7 @@ def _build(self): resolved_auth_function_plugins[name] ) ) - log.debug('Auth function %r was inserted', plugin.name) + log.debug('Auth function {0} from plugin {1} was inserted'.format(name, plugin.name)) resolved_auth_function_plugins[name] = plugin.name fetched_auth_functions[name] = auth_function # Use the updated ones in preference to the originals. @@ -96,6 +96,7 @@ def _build(self): def clear_auth_functions_cache(): _AuthFunctions.clear() + CONFIG_PERMISSIONS.clear() def auth_functions_list(): @@ -191,8 +192,8 @@ def is_authorized(action, context, data_dict=None): # these are the permissions that roles have ROLE_PERMISSIONS = OrderedDict([ ('admin', ['admin']), - ('editor', ['read', 'delete_dataset', 'create_dataset', 'update_dataset']), - ('member', ['read']), + ('editor', ['read', 'delete_dataset', 'create_dataset', 'update_dataset', 'manage_group']), + ('member', ['read', 'manage_group']), ]) @@ -240,7 +241,10 @@ def get_roles_with_permission(permission): def has_user_permission_for_group_or_org(group_id, user_name, permission): - ''' Check if the user has the given permission for the group ''' + ''' Check if the user has the given permissions for the group, allowing for + sysadmin rights and permission cascading down a group hierarchy. + + ''' if not group_id: return False group = model.Group.get(group_id) @@ -255,12 +259,35 @@ def has_user_permission_for_group_or_org(group_id, user_name, permission): user_id = get_user_id_for_username(user_name, allow_none=True) if not user_id: return False + if _has_user_permission_for_groups(user_id, permission, [group_id]): + return True + # Handle when permissions cascade. Check the user's roles on groups higher + # in the group hierarchy for permission. + for capacity in check_config_permission('roles_that_cascade_to_sub_groups'): + parent_groups = group.get_parent_group_hierarchy(type=group.type) + group_ids = [group_.id for group_ in parent_groups] + if _has_user_permission_for_groups(user_id, permission, group_ids, + capacity=capacity): + return True + return False + + +def _has_user_permission_for_groups(user_id, permission, group_ids, + capacity=None): + ''' Check if the user has the given permissions for the particular + group (ignoring permissions cascading in a group hierarchy). + Can also be filtered by a particular capacity. + ''' + if not group_ids: + return False # get any roles the user has for the group q = model.Session.query(model.Member) \ - .filter(model.Member.group_id == group_id) \ + .filter(model.Member.group_id.in_(group_ids)) \ .filter(model.Member.table_name == 'user') \ .filter(model.Member.state == 'active') \ .filter(model.Member.table_id == user_id) + if capacity: + q = q.filter(model.Member.capacity == capacity) # see if any role has the required permission # admin permission allows anything for the group for row in q.all(): @@ -271,7 +298,10 @@ def has_user_permission_for_group_or_org(group_id, user_name, permission): def users_role_for_group_or_org(group_id, user_name): - ''' Check if the user role for the group ''' + ''' Returns the user's role for the group. (Ignores privileges that cascade + in a group hierarchy.) + + ''' if not group_id: return None group_id = model.Group.get(group_id).id @@ -292,7 +322,7 @@ def users_role_for_group_or_org(group_id, user_name): def has_user_permission_for_some_org(user_name, permission): - ''' Check if the user has the given permission for the group ''' + ''' Check if the user has the given permission for any organization. ''' user_id = get_user_id_for_username(user_name, allow_none=True) if not user_id: return False @@ -350,19 +380,29 @@ def get_user_id_for_username(user_name, allow_none=False): 'user_delete_groups': True, 'user_delete_organizations': True, 'create_user_via_api': False, + 'create_user_via_web': True, + 'roles_that_cascade_to_sub_groups': 'admin', } CONFIG_PERMISSIONS = {} def check_config_permission(permission): - ''' Returns the permission True/False based on config ''' + ''' Returns the permission configuration, usually True/False ''' # set up perms if not already done if not CONFIG_PERMISSIONS: for perm in CONFIG_PERMISSIONS_DEFAULTS: key = 'ckan.auth.' + perm default = CONFIG_PERMISSIONS_DEFAULTS[perm] - CONFIG_PERMISSIONS[perm] = asbool(config.get(key, default)) + CONFIG_PERMISSIONS[perm] = config.get(key, default) + if perm == 'roles_that_cascade_to_sub_groups': + # this permission is a list of strings (space separated) + CONFIG_PERMISSIONS[perm] = \ + CONFIG_PERMISSIONS[perm].split(' ') \ + if CONFIG_PERMISSIONS[perm] else [] + else: + # most permissions are boolean + CONFIG_PERMISSIONS[perm] = asbool(CONFIG_PERMISSIONS[perm]) if permission in CONFIG_PERMISSIONS: return CONFIG_PERMISSIONS[permission] return False diff --git a/ckan/new_tests/factories.py b/ckan/new_tests/factories.py index 6e8385eeacf..7437ca2afb9 100644 --- a/ckan/new_tests/factories.py +++ b/ckan/new_tests/factories.py @@ -99,6 +99,124 @@ def _create(cls, target_class, *args, **kwargs): return user_dict +class Sysadmin(factory.Factory): + '''A factory class for creating sysadmin users.''' + + FACTORY_FOR = ckan.model.User + + fullname = 'Mr. Test Sysadmin' + password = 'pass' + about = 'Just another test sysadmin.' + + name = factory.Sequence(lambda n: 'test_sysadmin_{n}'.format(n=n)) + + email = factory.LazyAttribute(_generate_email) + + @classmethod + def _build(cls, target_class, *args, **kwargs): + raise NotImplementedError(".build() isn't supported in CKAN") + + @classmethod + def _create(cls, target_class, *args, **kwargs): + if args: + assert False, "Positional args aren't supported, use keyword args." + user_dict = helpers.call_action('user_create', **kwargs) + + # Create a sysadmin by accessing the db directly. + # This is probably bad but I don't think there's another way? + user = ckan.model.User(name='test_sysadmin', sysadmin=True) + ckan.model.Session.add(user) + ckan.model.Session.commit() + ckan.model.Session.remove() + + # We want to return a user dict not a model object, so call user_show + # to get one. We pass the user's name in the context because we want + # the API key and other sensitive data to be returned in the user + # dict. + user_dict = helpers.call_action('user_show', id=user.id, + context={'user': user.name}) + return user_dict + + +class Group(factory.Factory): + '''A factory class for creating CKAN groups.''' + + # This is the class that GroupFactory will create and return instances + # of. + FACTORY_FOR = ckan.model.Group + + # These are the default params that will be used to create new groups. + type = 'group' + is_organization = False + + title = 'Test Group' + description = 'Just another test group.' + image_url = 'http://placekitten.com/g/200/200' + + # Generate a different group name param for each user that gets created. + name = factory.Sequence(lambda n: 'test_group_{n}'.format(n=n)) + + @classmethod + def _build(cls, target_class, *args, **kwargs): + raise NotImplementedError(".build() isn't supported in CKAN") + + @classmethod + def _create(cls, target_class, *args, **kwargs): + if args: + assert False, "Positional args aren't supported, use keyword args." + + #TODO: we will need to be able to define this when creating the + # instance perhaps passing a 'user' param? + context = { + 'user': helpers.call_action('get_site_user')['name'] + } + + group_dict = helpers.call_action('group_create', + context=context, + **kwargs) + return group_dict + + +class Organization(factory.Factory): + '''A factory class for creating CKAN organizations.''' + + # This is the class that OrganizationFactory will create and return + # instances of. + FACTORY_FOR = ckan.model.Group + + # These are the default params that will be used to create new + # organizations. + type = 'organization' + is_organization = True + + title = 'Test Organization' + description = 'Just another test organization.' + image_url = 'http://placekitten.com/g/200/100' + + # Generate a different group name param for each user that gets created. + name = factory.Sequence(lambda n: 'test_org_{n}'.format(n=n)) + + @classmethod + def _build(cls, target_class, *args, **kwargs): + raise NotImplementedError(".build() isn't supported in CKAN") + + @classmethod + def _create(cls, target_class, *args, **kwargs): + if args: + assert False, "Positional args aren't supported, use keyword args." + + #TODO: we will need to be able to define this when creating the + # instance perhaps passing a 'user' param? + context = { + 'user': helpers.call_action('get_site_user')['name'] + } + + group_dict = helpers.call_action('organization_create', + context=context, + **kwargs) + return group_dict + + class MockUser(factory.Factory): '''A factory class for creating mock CKAN users using the mock library.''' diff --git a/ckan/new_tests/helpers.py b/ckan/new_tests/helpers.py index 51640a7e3dd..4e1e1ffd0c7 100644 --- a/ckan/new_tests/helpers.py +++ b/ckan/new_tests/helpers.py @@ -38,7 +38,7 @@ def reset_db(): # This prevents CKAN from hanging waiting for some unclosed connection. model.Session.close_all() - model.repo.clean_db() + model.repo.rebuild_db() def call_action(action_name, context=None, **kwargs): diff --git a/ckan/new_tests/logic/action/test_get.py b/ckan/new_tests/logic/action/test_get.py new file mode 100644 index 00000000000..f6895f32805 --- /dev/null +++ b/ckan/new_tests/logic/action/test_get.py @@ -0,0 +1,224 @@ +import nose.tools + +import ckan.logic as logic +import ckan.lib.search as search +import ckan.new_tests.helpers as helpers +import ckan.new_tests.factories as factories + + +class TestGet(object): + + @classmethod + def setup_class(cls): + helpers.reset_db() + + def setup(self): + import ckan.model as model + + # Reset the db before each test method. + model.repo.rebuild_db() + + # Clear the search index + search.clear() + + def test_group_list(self): + + group1 = factories.Group() + group2 = factories.Group() + + group_list = helpers.call_action('group_list') + + assert (sorted(group_list) == + sorted([g['name'] for g in [group1, group2]])) + + def test_group_show(self): + + group = factories.Group() + + group_dict = helpers.call_action('group_show', id=group['id']) + + # FIXME: Should this be returned by group_create? + group_dict.pop('num_followers', None) + assert group_dict == group + + def test_group_show_packages_returned(self): + + user_name = helpers.call_action('get_site_user')['name'] + + group = factories.Group() + + datasets = [ + {'name': 'dataset_1', 'groups': [{'name': group['name']}]}, + {'name': 'dataset_2', 'groups': [{'name': group['name']}]}, + ] + + for dataset in datasets: + helpers.call_action('package_create', + context={'user': user_name}, + **dataset) + + group_dict = helpers.call_action('group_show', id=group['id']) + + assert len(group_dict['packages']) == 2 + assert group_dict['package_count'] == 2 + + def test_group_show_no_packages_returned(self): + + user_name = helpers.call_action('get_site_user')['name'] + + group = factories.Group() + + datasets = [ + {'name': 'dataset_1', 'groups': [{'name': group['name']}]}, + {'name': 'dataset_2', 'groups': [{'name': group['name']}]}, + ] + + for dataset in datasets: + helpers.call_action('package_create', + context={'user': user_name}, + **dataset) + + group_dict = helpers.call_action('group_show', id=group['id'], + include_datasets=False) + + assert not 'packages' in group_dict + assert group_dict['package_count'] == 2 + + def test_organization_list(self): + + org1 = factories.Organization() + org2 = factories.Organization() + + org_list = helpers.call_action('organization_list') + + assert (sorted(org_list) == + sorted([g['name'] for g in [org1, org2]])) + + def test_organization_show(self): + + org = factories.Organization() + + org_dict = helpers.call_action('organization_show', id=org['id']) + + # FIXME: Should this be returned by organization_create? + org_dict.pop('num_followers', None) + assert org_dict == org + + def test_organization_show_packages_returned(self): + + user_name = helpers.call_action('get_site_user')['name'] + + org = factories.Organization() + + datasets = [ + {'name': 'dataset_1', 'owner_org': org['name']}, + {'name': 'dataset_2', 'owner_org': org['name']}, + ] + + for dataset in datasets: + helpers.call_action('package_create', + context={'user': user_name}, + **dataset) + + org_dict = helpers.call_action('organization_show', id=org['id']) + + assert len(org_dict['packages']) == 2 + assert org_dict['package_count'] == 2 + + def test_organization_show_private_packages_not_returned(self): + + user_name = helpers.call_action('get_site_user')['name'] + + org = factories.Organization() + + datasets = [ + {'name': 'dataset_1', 'owner_org': org['name']}, + {'name': 'dataset_2', 'owner_org': org['name'], 'private': True}, + ] + + for dataset in datasets: + helpers.call_action('package_create', + context={'user': user_name}, + **dataset) + + org_dict = helpers.call_action('organization_show', id=org['id']) + + assert len(org_dict['packages']) == 1 + assert org_dict['packages'][0]['name'] == 'dataset_1' + assert org_dict['package_count'] == 1 + + def test_user_get(self): + + user = factories.User() + + ## auth_ignored + got_user = helpers.call_action('user_show', id=user['id']) + + assert 'password' not in got_user + assert 'reset_key' not in got_user + assert 'apikey' not in got_user + assert 'email' not in got_user + + got_user = helpers.call_action('user_show', + context={'keep_email': True}, + id=user['id']) + + assert got_user['email'] == user['email'] + assert 'apikey' not in got_user + assert 'password' not in got_user + assert 'reset_key' not in got_user + + got_user = helpers.call_action('user_show', + context={'keep_apikey': True}, + id=user['id']) + + assert 'email' not in got_user + assert got_user['apikey'] == user['apikey'] + assert 'password' not in got_user + assert 'reset_key' not in got_user + + sysadmin = factories.User(sysadmin=True) + + got_user = helpers.call_action('user_show', + context={'user': sysadmin['name']}, + id=user['id']) + + assert got_user['email'] == user['email'] + assert got_user['apikey'] == user['apikey'] + assert 'password' not in got_user + assert 'reset_key' not in got_user + + +class TestBadLimitQueryParameters(object): + '''test class for #1258 non-int query parameters cause 500 errors + + Test that validation errors are raised when calling actions with + bad parameters. + ''' + + def test_activity_list_actions(self): + actions = [ + 'user_activity_list', + 'package_activity_list', + 'group_activity_list', + 'organization_activity_list', + 'recently_changed_packages_activity_list', + 'user_activity_list_html', + 'package_activity_list_html', + 'group_activity_list_html', + 'organization_activity_list_html', + 'recently_changed_packages_activity_list_html', + ] + for action in actions: + nose.tools.assert_raises( + logic.ValidationError, helpers.call_action, action, + id='test_user', limit='not_an_int', offset='not_an_int') + nose.tools.assert_raises( + logic.ValidationError, helpers.call_action, action, + id='test_user', limit=-1, offset=-1) + + def test_package_search_facet_field_is_json(self): + kwargs = {'facet.field': 'notjson'} + nose.tools.assert_raises( + logic.ValidationError, helpers.call_action, 'package_search', + **kwargs) diff --git a/ckan/new_tests/logic/action/test_update.py b/ckan/new_tests/logic/action/test_update.py index 8413a3a8178..268ca339c36 100644 --- a/ckan/new_tests/logic/action/test_update.py +++ b/ckan/new_tests/logic/action/test_update.py @@ -344,3 +344,44 @@ def test_user_update_does_not_return_reset_key(self): updated_user = helpers.call_action('user_update', **params) assert 'reset_key' not in updated_user + + def test_resource_reorder(self): + resource_urls = ["http://a.html", "http://b.html", "http://c.html"] + dataset = {"name": "basic", + "resources": [{'url': url} for url in resource_urls] + } + + dataset = helpers.call_action('package_create', **dataset) + created_resource_urls = [resource['url'] for resource + in dataset['resources']] + assert created_resource_urls == resource_urls + mapping = dict((resource['url'], resource['id']) for resource + in dataset['resources']) + + ## This should put c.html at the front + reorder = {'id': dataset['id'], 'order': + [mapping["http://c.html"]]} + + helpers.call_action('package_resource_reorder', **reorder) + + dataset = helpers.call_action('package_show', id=dataset['id']) + reordered_resource_urls = [resource['url'] for resource + in dataset['resources']] + + assert reordered_resource_urls == ["http://c.html", + "http://a.html", + "http://b.html"] + + reorder = {'id': dataset['id'], 'order': [mapping["http://b.html"], + mapping["http://c.html"], + mapping["http://a.html"]]} + + helpers.call_action('package_resource_reorder', **reorder) + dataset = helpers.call_action('package_show', id=dataset['id']) + + reordered_resource_urls = [resource['url'] for resource + in dataset['resources']] + + assert reordered_resource_urls == ["http://b.html", + "http://c.html", + "http://a.html"] diff --git a/ckan/new_tests/logic/test_validators.py b/ckan/new_tests/logic/test_validators.py index 4aab0fad23c..dcdc46cb046 100644 --- a/ckan/new_tests/logic/test_validators.py +++ b/ckan/new_tests/logic/test_validators.py @@ -317,3 +317,78 @@ def call_validator(*args, **kwargs): # TODO: Test user_name_validator()'s behavior when there's a 'user_obj' in # the context dict. + + def test_datasets_with_org_can_be_private_when_creating(self): + + import ckan.logic.validators as validators + + data = factories.validator_data_dict() + errors = factories.validator_errors_dict() + + key = ('private',) + data[key] = True + errors[key] = [] + + data[('owner_org',)] = 'some_org_id' + + # Mock ckan.model. + mock_model = mock.MagicMock() + + @t.does_not_modify_errors_dict + @t.does_not_modify_data_dict + @t.returns_None + def call_validator(*args, **kwargs): + return validators.datasets_with_no_organization_cannot_be_private( + *args, **kwargs) + call_validator(key, data, errors, context={'model': mock_model}) + + def test_datasets_with_no_org_cannot_be_private_when_creating(self): + + import ckan.logic.validators as validators + + data = factories.validator_data_dict() + errors = factories.validator_errors_dict() + + key = ('private',) + data[key] = True + errors[key] = [] + + # Mock ckan.model. + mock_model = mock.MagicMock() + + @t.does_not_modify_data_dict + @adds_message_to_errors_dict( + "Datasets with no organization can't be private.") + def call_validator(*args, **kwargs): + return validators.datasets_with_no_organization_cannot_be_private( + *args, **kwargs) + + call_validator(key, data, errors, context={'model': mock_model}) + + def test_datasets_with_org_can_be_private_when_updating(self): + + import ckan.logic.validators as validators + + data = factories.validator_data_dict() + errors = factories.validator_errors_dict() + + key = ('private',) + data[key] = True + errors[key] = [] + + data[('id',)] = 'some_dataset_id' + data[('owner_org',)] = 'some_org_id' + + # Mock ckan.model. + mock_model = mock.MagicMock() + + @t.does_not_modify_errors_dict + @t.does_not_modify_data_dict + @t.returns_None + def call_validator(*args, **kwargs): + return validators.datasets_with_no_organization_cannot_be_private( + *args, **kwargs) + call_validator(key, data, errors, context={'model': mock_model}) + + #TODO: Need to test when you are not providing owner_org and the validator + # queries for the dataset with package_show diff --git a/ckan/plugins/interfaces.py b/ckan/plugins/interfaces.py index b3a5093b319..9b6884b3fc3 100644 --- a/ckan/plugins/interfaces.py +++ b/ckan/plugins/interfaces.py @@ -784,7 +784,7 @@ def read_template(self): If the user requests the dataset in a format other than HTML (CKAN supports returning datasets in RDF or N3 format by appending .rdf - or .n3 to the dataset read URL, see :doc:`linked-data-and-rdf`) then + or .n3 to the dataset read URL, see :doc:`/linked-data-and-rdf`) then CKAN will try to render a template file with the same path as returned by this function, but a different filename extension, e.g. ``'package/read.rdf'``. diff --git a/ckan/plugins/toolkit.py b/ckan/plugins/toolkit.py index 34e9d671cf1..3e5ba71f8f8 100644 --- a/ckan/plugins/toolkit.py +++ b/ckan/plugins/toolkit.py @@ -216,7 +216,7 @@ def _initialize(self): def _render_snippet(cls, template, data=None): '''Render a template snippet and return the output. - See :doc:`/theming`. + See :doc:`/theming/index`. ''' data = data or {} @@ -266,7 +266,7 @@ def _add_resource(cls, path, name): Fanstatic libraries are directories containing static resource files (e.g. CSS, JavaScript or image files) that can be accessed from CKAN. - See :doc:`/theming` for more details. + See :doc:`/theming/index` for more details. ''' # we want the filename that of the function caller but they will diff --git a/ckan/public/base/css/fuchsia.css b/ckan/public/base/css/fuchsia.css index c87cd3b10a1..b22ecef39d5 100644 --- a/ckan/public/base/css/fuchsia.css +++ b/ckan/public/base/css/fuchsia.css @@ -49,16 +49,12 @@ sub { } img { /* Responsive images (ensure images don't scale beyond their parents) */ - max-width: 100%; /* Part 1: Set a maxium relative to the parent */ - width: auto\9; /* IE7-8 need help adjusting responsive images */ - height: auto; /* Part 2: Scale the height according to the width, otherwise you get stretching */ - vertical-align: middle; border: 0; -ms-interpolation-mode: bicubic; @@ -153,7 +149,7 @@ textarea { img { max-width: 100% !important; } - @page { + @page { margin: 0.5cm; } p, @@ -693,7 +689,6 @@ ol.inline > li { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding-left: 5px; padding-right: 5px; @@ -972,7 +967,6 @@ input[type="color"]:focus, outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -982,10 +976,8 @@ input[type="checkbox"] { margin: 4px 0 0; *margin-top: 0; /* IE7 */ - margin-top: 1px \9; /* IE8-9 */ - line-height: normal; } input[type="file"], @@ -1001,10 +993,8 @@ select, input[type="file"] { height: 30px; /* In IE7, the height of the select element cannot be changed by height, only font-size */ - *margin-top: 4px; /* For IE7, add top margin to align select with labels */ - line-height: 30px; } select { @@ -1402,7 +1392,6 @@ select:focus:invalid:focus { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; vertical-align: middle; padding-left: 5px; @@ -1553,7 +1542,6 @@ input.search-query { padding-left: 14px; padding-left: 4px \9; /* IE7-8 doesn't have border-radius, so don't indent the padding */ - margin-bottom: 0; -webkit-border-radius: 15px; -moz-border-radius: 15px; @@ -1610,7 +1598,6 @@ input.search-query { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-bottom: 0; vertical-align: middle; @@ -2220,7 +2207,6 @@ button.close { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding: 4px 12px; margin-bottom: 0; @@ -2243,7 +2229,6 @@ button.close { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #eaeaea; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); border: 1px solid #cccccc; *border: 0; @@ -2379,7 +2364,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #e73892; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-primary:hover, @@ -2411,7 +2395,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #f89406; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-warning:hover, @@ -2443,7 +2426,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #bd362f; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-danger:hover, @@ -2475,7 +2457,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #51a351; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-success:hover, @@ -2507,7 +2488,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #2f96b4; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-info:hover, @@ -2539,7 +2519,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #222222; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-inverse:hover, @@ -2614,7 +2593,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; font-size: 0; vertical-align: middle; @@ -2790,7 +2768,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .btn-group-vertical > .btn { @@ -3487,7 +3464,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #e5e5e5; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); -moz-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); @@ -3721,7 +3697,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #040404; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .navbar-inverse .btn-navbar:hover, @@ -3751,7 +3726,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; text-shadow: 0 1px 0 #ffffff; } @@ -3769,7 +3743,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-left: 0; margin-bottom: 0; @@ -3970,7 +3943,6 @@ input[type="submit"].btn.btn-mini { border: 1px solid rgba(0, 0, 0, 0.3); *border: 1px solid #999; /* IE6-7 */ - -webkit-border-radius: 6px; -moz-border-radius: 6px; border-radius: 6px; @@ -5575,6 +5547,12 @@ textarea { } .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; +} +.form-actions .control-required-message:first-child { + margin-left: 0; } .form-actions { background: none; @@ -5692,27 +5670,24 @@ textarea { padding: 7px 5px; } .simple-input .field .btn-search { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - width: 16px; - height: 16px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - background-position: -51px -16px; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + -webkit-transition: color 0.2s ease-in; + -moz-transition: color 0.2s ease-in; + -o-transition: color 0.2s ease-in; + transition: color 0.2s ease-in; +} +.simple-input .field .btn-search:hover { + color: #000; } .editor textarea { -webkit-border-radius: 3px 3px 0 0; @@ -5818,7 +5793,6 @@ textarea { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #e73892; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .control-custom.disabled .checkbox.btn:hover, @@ -6097,7 +6071,6 @@ textarea { outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -6120,6 +6093,43 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.js .image-upload #field-image-upload { + cursor: pointer; + position: absolute; + z-index: 1; + opacity: 0; + filter: alpha(opacity=0); +} +.js .image-upload .controls { + position: relative; +} +.js .image-upload .btn { + position: relative; + top: 0; + margin-right: 10px; +} +.js .image-upload .btn.hover { + color: #333333; + text-decoration: none; + background-position: 0 -15px; + -webkit-transition: background-position 0.1s linear; + -moz-transition: background-position 0.1s linear; + -o-transition: background-position 0.1s linear; + transition: background-position 0.1s linear; +} +.js .image-upload .btn-remove-url { + position: absolute; + margin-right: 0; + top: 4px; + right: 5px; + padding: 0 4px; + -webkit-border-radius: 100px; + -moz-border-radius: 100px; + border-radius: 100px; +} +.js .image-upload .btn-remove-url .icon-remove { + margin-right: 0; +} .add-member-form .control-label { width: 100%; text-align: left; @@ -6228,44 +6238,45 @@ textarea { top: 14px; right: 10px; } -.dataset-resource-form .dataset-form-resource-types { - margin-bottom: 5px; +.resource-list.reordering .resource-item { + border: 1px solid #dddddd; + margin-bottom: 10px; + cursor: move; } -.dataset-form-resource-types .ckan-icon { - position: relative; - top: 3px; +.resource-list.reordering .resource-item .handle { + display: block; + position: absolute; + color: #888888; + left: -31px; + top: 50%; + margin-top: -15px; + width: 30px; + height: 30px; + line-height: 30px; + text-align: center; + border: 1px solid #dddddd; + border-width: 1px 0 1px 1px; + background-color: #ffffff; + -webkit-border-radius: 20px 0 0 20px; + -moz-border-radius: 20px 0 0 20px; + border-radius: 20px 0 0 20px; } -.dataset-form-resource-types .radio { - font-weight: normal; - padding-left: 0; - padding-right: 18px; +.resource-list.reordering .resource-item .handle:hover { + text-decoration: none; } -.dataset-form-resource-types label { - position: relative; +.resource-list.reordering .resource-item:hover .handle { + background-color: #eeeeee; } -.dataset-form-resource-types input[type=radio]:checked + label { - font-weight: bold; +.resource-list.reordering .resource-item.ui-sortable-helper { + background-color: #eeeeee; + border: 1px solid #e73892; } -.dataset-form-resource-types input[type=radio]:checked + label:after { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - width: 16px; - height: 16px; - background-position: -192px 0; - display: block; - content: ""; - position: absolute; - top: auto; - left: 0; - bottom: -12px; +.resource-list.reordering .resource-item.ui-sortable-helper .handle { + background-color: #eeeeee; + border-color: #e73892; + color: #333333; } -.dataset-form-resource-types input[type=radio] { +.resource-item .handle { display: none; } .tag-list { @@ -6414,6 +6425,10 @@ textarea { font-weight: normal; color: #000000; } +.search-form.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; +} .tertiary .control-order-by { float: none; margin: 0; @@ -6534,7 +6549,6 @@ textarea { margin-right: 5px; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .actions li:last-of-type { @@ -7105,6 +7119,7 @@ h4 small { margin-top: 15px; padding-top: 10px; border-top: 1px dotted #DDD; + word-break: break-word; } .context-info .info dl dd { margin-top: 3px; @@ -7159,6 +7174,11 @@ h4 small { .context-info.editing .module-content { margin-top: 0; } +.flash-messages .alert { + -webkit-box-shadow: 0 0 0 1px #ffffff; + -moz-box-shadow: 0 0 0 1px #ffffff; + box-shadow: 0 0 0 1px #ffffff; +} .homepage [role=main] { padding: 20px 0; min-height: 0; @@ -7711,6 +7731,9 @@ h4 small { -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } +.activity .item.no-avatar p { + margin-left: 40px; +} .activity .load-less { margin-bottom: 15px; } @@ -7728,9 +7751,28 @@ h4 small { float: right; text-decoration: none; } +.popover .popover-content { + font-size: 14px; + line-height: 20px; + color: #444444; + word-break: break-all; +} +.popover .popover-content dl { + margin: 0; +} +.popover .popover-content dl dd { + margin-left: 0; + margin-bottom: 10px; +} .activity .item .icon { background-color: #999999; } +.activity .item.failure .icon { + background-color: #b95252; +} +.activity .item.success .icon { + background-color: #69a67a; +} .activity .item.added-tag .icon { background-color: #6995a6; } @@ -7981,6 +8023,21 @@ h4 small { font-size: 16px; margin: 3px 0; } +.datapusher-status-link:hover { + text-decoration: none; +} +.datapusher-status.status-unknown { + color: #bbb; +} +.datapusher-status.status-pending { + color: #FFCC00; +} +.datapusher-status.status-error { + color: red; +} +.datapusher-status.status-complete { + color: #009900; +} body { background: #e73892 url("../../../base/images/bg.png"); } @@ -8182,7 +8239,6 @@ iframe { .ie7 .control-custom .checkbox { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .stages { @@ -8216,7 +8272,6 @@ iframe { .ie7 .masthead nav { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .masthead .header-image { diff --git a/ckan/public/base/css/green.css b/ckan/public/base/css/green.css index 6558ce82f8d..061794d7a3d 100644 --- a/ckan/public/base/css/green.css +++ b/ckan/public/base/css/green.css @@ -49,16 +49,12 @@ sub { } img { /* Responsive images (ensure images don't scale beyond their parents) */ - max-width: 100%; /* Part 1: Set a maxium relative to the parent */ - width: auto\9; /* IE7-8 need help adjusting responsive images */ - height: auto; /* Part 2: Scale the height according to the width, otherwise you get stretching */ - vertical-align: middle; border: 0; -ms-interpolation-mode: bicubic; @@ -153,7 +149,7 @@ textarea { img { max-width: 100% !important; } - @page { + @page { margin: 0.5cm; } p, @@ -693,7 +689,6 @@ ol.inline > li { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding-left: 5px; padding-right: 5px; @@ -972,7 +967,6 @@ input[type="color"]:focus, outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -982,10 +976,8 @@ input[type="checkbox"] { margin: 4px 0 0; *margin-top: 0; /* IE7 */ - margin-top: 1px \9; /* IE8-9 */ - line-height: normal; } input[type="file"], @@ -1001,10 +993,8 @@ select, input[type="file"] { height: 30px; /* In IE7, the height of the select element cannot be changed by height, only font-size */ - *margin-top: 4px; /* For IE7, add top margin to align select with labels */ - line-height: 30px; } select { @@ -1402,7 +1392,6 @@ select:focus:invalid:focus { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; vertical-align: middle; padding-left: 5px; @@ -1553,7 +1542,6 @@ input.search-query { padding-left: 14px; padding-left: 4px \9; /* IE7-8 doesn't have border-radius, so don't indent the padding */ - margin-bottom: 0; -webkit-border-radius: 15px; -moz-border-radius: 15px; @@ -1610,7 +1598,6 @@ input.search-query { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-bottom: 0; vertical-align: middle; @@ -2220,7 +2207,6 @@ button.close { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding: 4px 12px; margin-bottom: 0; @@ -2243,7 +2229,6 @@ button.close { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #eaeaea; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); border: 1px solid #cccccc; *border: 0; @@ -2379,7 +2364,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #2f9b45; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-primary:hover, @@ -2411,7 +2395,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #f89406; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-warning:hover, @@ -2443,7 +2426,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #bd362f; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-danger:hover, @@ -2475,7 +2457,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #51a351; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-success:hover, @@ -2507,7 +2488,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #2f96b4; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-info:hover, @@ -2539,7 +2519,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #222222; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-inverse:hover, @@ -2614,7 +2593,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; font-size: 0; vertical-align: middle; @@ -2790,7 +2768,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .btn-group-vertical > .btn { @@ -3487,7 +3464,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #e5e5e5; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); -moz-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); @@ -3721,7 +3697,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #040404; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .navbar-inverse .btn-navbar:hover, @@ -3751,7 +3726,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; text-shadow: 0 1px 0 #ffffff; } @@ -3769,7 +3743,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-left: 0; margin-bottom: 0; @@ -3970,7 +3943,6 @@ input[type="submit"].btn.btn-mini { border: 1px solid rgba(0, 0, 0, 0.3); *border: 1px solid #999; /* IE6-7 */ - -webkit-border-radius: 6px; -moz-border-radius: 6px; border-radius: 6px; @@ -5575,6 +5547,12 @@ textarea { } .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; +} +.form-actions .control-required-message:first-child { + margin-left: 0; } .form-actions { background: none; @@ -5692,27 +5670,24 @@ textarea { padding: 7px 5px; } .simple-input .field .btn-search { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - width: 16px; - height: 16px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - background-position: -51px -16px; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + -webkit-transition: color 0.2s ease-in; + -moz-transition: color 0.2s ease-in; + -o-transition: color 0.2s ease-in; + transition: color 0.2s ease-in; +} +.simple-input .field .btn-search:hover { + color: #000; } .editor textarea { -webkit-border-radius: 3px 3px 0 0; @@ -5818,7 +5793,6 @@ textarea { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #2f9b45; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .control-custom.disabled .checkbox.btn:hover, @@ -6097,7 +6071,6 @@ textarea { outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -6120,6 +6093,43 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.js .image-upload #field-image-upload { + cursor: pointer; + position: absolute; + z-index: 1; + opacity: 0; + filter: alpha(opacity=0); +} +.js .image-upload .controls { + position: relative; +} +.js .image-upload .btn { + position: relative; + top: 0; + margin-right: 10px; +} +.js .image-upload .btn.hover { + color: #333333; + text-decoration: none; + background-position: 0 -15px; + -webkit-transition: background-position 0.1s linear; + -moz-transition: background-position 0.1s linear; + -o-transition: background-position 0.1s linear; + transition: background-position 0.1s linear; +} +.js .image-upload .btn-remove-url { + position: absolute; + margin-right: 0; + top: 4px; + right: 5px; + padding: 0 4px; + -webkit-border-radius: 100px; + -moz-border-radius: 100px; + border-radius: 100px; +} +.js .image-upload .btn-remove-url .icon-remove { + margin-right: 0; +} .add-member-form .control-label { width: 100%; text-align: left; @@ -6228,44 +6238,45 @@ textarea { top: 14px; right: 10px; } -.dataset-resource-form .dataset-form-resource-types { - margin-bottom: 5px; +.resource-list.reordering .resource-item { + border: 1px solid #dddddd; + margin-bottom: 10px; + cursor: move; } -.dataset-form-resource-types .ckan-icon { - position: relative; - top: 3px; +.resource-list.reordering .resource-item .handle { + display: block; + position: absolute; + color: #888888; + left: -31px; + top: 50%; + margin-top: -15px; + width: 30px; + height: 30px; + line-height: 30px; + text-align: center; + border: 1px solid #dddddd; + border-width: 1px 0 1px 1px; + background-color: #ffffff; + -webkit-border-radius: 20px 0 0 20px; + -moz-border-radius: 20px 0 0 20px; + border-radius: 20px 0 0 20px; } -.dataset-form-resource-types .radio { - font-weight: normal; - padding-left: 0; - padding-right: 18px; +.resource-list.reordering .resource-item .handle:hover { + text-decoration: none; } -.dataset-form-resource-types label { - position: relative; +.resource-list.reordering .resource-item:hover .handle { + background-color: #eeeeee; } -.dataset-form-resource-types input[type=radio]:checked + label { - font-weight: bold; +.resource-list.reordering .resource-item.ui-sortable-helper { + background-color: #eeeeee; + border: 1px solid #2f9b45; } -.dataset-form-resource-types input[type=radio]:checked + label:after { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - width: 16px; - height: 16px; - background-position: -192px 0; - display: block; - content: ""; - position: absolute; - top: auto; - left: 0; - bottom: -12px; +.resource-list.reordering .resource-item.ui-sortable-helper .handle { + background-color: #eeeeee; + border-color: #2f9b45; + color: #333333; } -.dataset-form-resource-types input[type=radio] { +.resource-item .handle { display: none; } .tag-list { @@ -6414,6 +6425,10 @@ textarea { font-weight: normal; color: #000000; } +.search-form.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; +} .tertiary .control-order-by { float: none; margin: 0; @@ -6534,7 +6549,6 @@ textarea { margin-right: 5px; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .actions li:last-of-type { @@ -7105,6 +7119,7 @@ h4 small { margin-top: 15px; padding-top: 10px; border-top: 1px dotted #DDD; + word-break: break-word; } .context-info .info dl dd { margin-top: 3px; @@ -7159,6 +7174,11 @@ h4 small { .context-info.editing .module-content { margin-top: 0; } +.flash-messages .alert { + -webkit-box-shadow: 0 0 0 1px #ffffff; + -moz-box-shadow: 0 0 0 1px #ffffff; + box-shadow: 0 0 0 1px #ffffff; +} .homepage [role=main] { padding: 20px 0; min-height: 0; @@ -7711,6 +7731,9 @@ h4 small { -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } +.activity .item.no-avatar p { + margin-left: 40px; +} .activity .load-less { margin-bottom: 15px; } @@ -7728,9 +7751,28 @@ h4 small { float: right; text-decoration: none; } +.popover .popover-content { + font-size: 14px; + line-height: 20px; + color: #444444; + word-break: break-all; +} +.popover .popover-content dl { + margin: 0; +} +.popover .popover-content dl dd { + margin-left: 0; + margin-bottom: 10px; +} .activity .item .icon { background-color: #999999; } +.activity .item.failure .icon { + background-color: #b95252; +} +.activity .item.success .icon { + background-color: #69a67a; +} .activity .item.added-tag .icon { background-color: #6995a6; } @@ -7981,6 +8023,21 @@ h4 small { font-size: 16px; margin: 3px 0; } +.datapusher-status-link:hover { + text-decoration: none; +} +.datapusher-status.status-unknown { + color: #bbb; +} +.datapusher-status.status-pending { + color: #FFCC00; +} +.datapusher-status.status-error { + color: red; +} +.datapusher-status.status-complete { + color: #009900; +} body { background: #2f9b45 url("../../../base/images/bg.png"); } @@ -8182,7 +8239,6 @@ iframe { .ie7 .control-custom .checkbox { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .stages { @@ -8216,7 +8272,6 @@ iframe { .ie7 .masthead nav { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .masthead .header-image { diff --git a/ckan/public/base/css/main.css b/ckan/public/base/css/main.css index d7635468324..2c2cd89412a 100644 --- a/ckan/public/base/css/main.css +++ b/ckan/public/base/css/main.css @@ -49,16 +49,12 @@ sub { } img { /* Responsive images (ensure images don't scale beyond their parents) */ - max-width: 100%; /* Part 1: Set a maxium relative to the parent */ - width: auto\9; /* IE7-8 need help adjusting responsive images */ - height: auto; /* Part 2: Scale the height according to the width, otherwise you get stretching */ - vertical-align: middle; border: 0; -ms-interpolation-mode: bicubic; @@ -153,7 +149,7 @@ textarea { img { max-width: 100% !important; } - @page { + @page { margin: 0.5cm; } p, @@ -693,7 +689,6 @@ ol.inline > li { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding-left: 5px; padding-right: 5px; @@ -972,7 +967,6 @@ input[type="color"]:focus, outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -982,10 +976,8 @@ input[type="checkbox"] { margin: 4px 0 0; *margin-top: 0; /* IE7 */ - margin-top: 1px \9; /* IE8-9 */ - line-height: normal; } input[type="file"], @@ -1001,10 +993,8 @@ select, input[type="file"] { height: 30px; /* In IE7, the height of the select element cannot be changed by height, only font-size */ - *margin-top: 4px; /* For IE7, add top margin to align select with labels */ - line-height: 30px; } select { @@ -1402,7 +1392,6 @@ select:focus:invalid:focus { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; vertical-align: middle; padding-left: 5px; @@ -1553,7 +1542,6 @@ input.search-query { padding-left: 14px; padding-left: 4px \9; /* IE7-8 doesn't have border-radius, so don't indent the padding */ - margin-bottom: 0; -webkit-border-radius: 15px; -moz-border-radius: 15px; @@ -1610,7 +1598,6 @@ input.search-query { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-bottom: 0; vertical-align: middle; @@ -2220,7 +2207,6 @@ button.close { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding: 4px 12px; margin-bottom: 0; @@ -2243,7 +2229,6 @@ button.close { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #eaeaea; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); border: 1px solid #cccccc; *border: 0; @@ -2379,7 +2364,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #085871; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-primary:hover, @@ -2411,7 +2395,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #f89406; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-warning:hover, @@ -2443,7 +2426,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #bd362f; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-danger:hover, @@ -2475,7 +2457,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #51a351; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-success:hover, @@ -2507,7 +2488,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #2f96b4; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-info:hover, @@ -2539,7 +2519,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #222222; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-inverse:hover, @@ -2614,7 +2593,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; font-size: 0; vertical-align: middle; @@ -2790,7 +2768,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .btn-group-vertical > .btn { @@ -3487,7 +3464,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #e5e5e5; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); -moz-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); @@ -3721,7 +3697,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #040404; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .navbar-inverse .btn-navbar:hover, @@ -3751,7 +3726,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; text-shadow: 0 1px 0 #ffffff; } @@ -3769,7 +3743,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-left: 0; margin-bottom: 0; @@ -3970,7 +3943,6 @@ input[type="submit"].btn.btn-mini { border: 1px solid rgba(0, 0, 0, 0.3); *border: 1px solid #999; /* IE6-7 */ - -webkit-border-radius: 6px; -moz-border-radius: 6px; border-radius: 6px; @@ -5575,6 +5547,12 @@ textarea { } .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; +} +.form-actions .control-required-message:first-child { + margin-left: 0; } .form-actions { background: none; @@ -5692,27 +5670,24 @@ textarea { padding: 7px 5px; } .simple-input .field .btn-search { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - width: 16px; - height: 16px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - background-position: -51px -16px; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + -webkit-transition: color 0.2s ease-in; + -moz-transition: color 0.2s ease-in; + -o-transition: color 0.2s ease-in; + transition: color 0.2s ease-in; +} +.simple-input .field .btn-search:hover { + color: #000; } .editor textarea { -webkit-border-radius: 3px 3px 0 0; @@ -5818,7 +5793,6 @@ textarea { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #085871; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .control-custom.disabled .checkbox.btn:hover, @@ -6097,7 +6071,6 @@ textarea { outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -6120,6 +6093,43 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.js .image-upload #field-image-upload { + cursor: pointer; + position: absolute; + z-index: 1; + opacity: 0; + filter: alpha(opacity=0); +} +.js .image-upload .controls { + position: relative; +} +.js .image-upload .btn { + position: relative; + top: 0; + margin-right: 10px; +} +.js .image-upload .btn.hover { + color: #333333; + text-decoration: none; + background-position: 0 -15px; + -webkit-transition: background-position 0.1s linear; + -moz-transition: background-position 0.1s linear; + -o-transition: background-position 0.1s linear; + transition: background-position 0.1s linear; +} +.js .image-upload .btn-remove-url { + position: absolute; + margin-right: 0; + top: 4px; + right: 5px; + padding: 0 4px; + -webkit-border-radius: 100px; + -moz-border-radius: 100px; + border-radius: 100px; +} +.js .image-upload .btn-remove-url .icon-remove { + margin-right: 0; +} .add-member-form .control-label { width: 100%; text-align: left; @@ -6222,50 +6232,52 @@ textarea { .resource-item .description { font-size: 12px; margin-bottom: 0; + min-height: 12px; } .resource-item .btn-group { position: absolute; top: 14px; right: 10px; } -.dataset-resource-form .dataset-form-resource-types { - margin-bottom: 5px; +.resource-list.reordering .resource-item { + border: 1px solid #dddddd; + margin-bottom: 10px; + cursor: move; } -.dataset-form-resource-types .ckan-icon { - position: relative; - top: 3px; +.resource-list.reordering .resource-item .handle { + display: block; + position: absolute; + color: #888888; + left: -31px; + top: 50%; + margin-top: -15px; + width: 30px; + height: 30px; + line-height: 30px; + text-align: center; + border: 1px solid #dddddd; + border-width: 1px 0 1px 1px; + background-color: #ffffff; + -webkit-border-radius: 20px 0 0 20px; + -moz-border-radius: 20px 0 0 20px; + border-radius: 20px 0 0 20px; } -.dataset-form-resource-types .radio { - font-weight: normal; - padding-left: 0; - padding-right: 18px; +.resource-list.reordering .resource-item .handle:hover { + text-decoration: none; } -.dataset-form-resource-types label { - position: relative; +.resource-list.reordering .resource-item:hover .handle { + background-color: #eeeeee; } -.dataset-form-resource-types input[type=radio]:checked + label { - font-weight: bold; +.resource-list.reordering .resource-item.ui-sortable-helper { + background-color: #eeeeee; + border: 1px solid #187794; } -.dataset-form-resource-types input[type=radio]:checked + label:after { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - width: 16px; - height: 16px; - background-position: -192px 0; - display: block; - content: ""; - position: absolute; - top: auto; - left: 0; - bottom: -12px; +.resource-list.reordering .resource-item.ui-sortable-helper .handle { + background-color: #eeeeee; + border-color: #187794; + color: #333333; } -.dataset-form-resource-types input[type=radio] { +.resource-item .handle { display: none; } .tag-list { @@ -6327,6 +6339,117 @@ textarea { .label[data-format*=turtle] { background-color: #0b4498; } +.view-list { + margin: 0; + list-style: none; +} +.view-list li { + position: relative; + margin-bottom: 10px; +} +.view-list li a { + display: block; + min-height: 50px; + padding: 10px; + border: 1px solid #dddddd; + overflow: hidden; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; +} +.view-list li a .icon { + float: left; + width: 50px; + height: 50px; + overflow: hidden; + margin-right: 10px; + color: #444444; + background-color: #eeeeee; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; +} +.view-list li a .icon i { + display: block; + text-align: center; + font-size: 28px; + line-height: 50px; +} +.view-list li a h3 { + color: #000000; + font-weight: bold; + font-size: 16px; + margin: 0 0 3px 0; +} +.view-list li a p { + margin: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: #444444; +} +.view-list li a.active, +.view-list li a:hover { + text-decoration: none; + border-color: #187794; +} +.view-list li a.active .icon, +.view-list li a:hover .icon { + background-color: #187794; + color: #f6f6f6; +} +.view-list li .arrow { + position: absolute; + display: none; + border: 8px solid transparent; + border-top-color: #187794; + left: 50%; + bottom: -15px; + margin-left: -4px; +} +.view-list li.active a { + text-decoration: none; + border-color: #187794; +} +.view-list li.active a .icon { + background-color: #187794; + color: #f6f6f6; +} +.view-list li.active .arrow { + display: block; +} +.view-list.stacked { + overflow-y: hidden; + overflow-x: auto; + height: 100px; + white-space: nowrap; +} +.view-list.stacked li { + display: inline-block; + width: 250px; + margin-right: 10px; +} +.view-list.stacked li:last-child { + margin-right: 0; +} +.view-list.stacked::-webkit-scrollbar { + width: 7px; + height: 7px; +} +.view-list.stacked::-webkit-scrollbar-track { + border-radius: 10px; + background-color: #f6f6f6; +} +.view-list.stacked::-webkit-scrollbar-thumb { + border-radius: 10px; + background-color: #c3c3c3; +} +.view-list.stacked::-webkit-scrollbar-thumb:hover { + background-color: #187794; +} +.resource-view { + margin-top: 20px; +} .search-form { margin-bottom: 20px; padding-bottom: 25px; @@ -6414,6 +6537,10 @@ textarea { font-weight: normal; color: #000000; } +.search-form.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; +} .tertiary .control-order-by { float: none; margin: 0; @@ -6534,7 +6661,6 @@ textarea { margin-right: 5px; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .actions li:last-of-type { @@ -7105,6 +7231,7 @@ h4 small { margin-top: 15px; padding-top: 10px; border-top: 1px dotted #DDD; + word-break: break-word; } .context-info .info dl dd { margin-top: 3px; @@ -7159,6 +7286,11 @@ h4 small { .context-info.editing .module-content { margin-top: 0; } +.flash-messages .alert { + -webkit-box-shadow: 0 0 0 1px #ffffff; + -moz-box-shadow: 0 0 0 1px #ffffff; + box-shadow: 0 0 0 1px #ffffff; +} .homepage [role=main] { padding: 20px 0; min-height: 0; @@ -7711,6 +7843,9 @@ h4 small { -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } +.activity .item.no-avatar p { + margin-left: 40px; +} .activity .load-less { margin-bottom: 15px; } @@ -7728,9 +7863,28 @@ h4 small { float: right; text-decoration: none; } +.popover .popover-content { + font-size: 14px; + line-height: 20px; + color: #444444; + word-break: break-all; +} +.popover .popover-content dl { + margin: 0; +} +.popover .popover-content dl dd { + margin-left: 0; + margin-bottom: 10px; +} .activity .item .icon { background-color: #999999; } +.activity .item.failure .icon { + background-color: #b95252; +} +.activity .item.success .icon { + background-color: #69a67a; +} .activity .item.added-tag .icon { background-color: #6995a6; } @@ -7981,6 +8135,21 @@ h4 small { font-size: 16px; margin: 3px 0; } +.datapusher-status-link:hover { + text-decoration: none; +} +.datapusher-status.status-unknown { + color: #bbb; +} +.datapusher-status.status-pending { + color: #FFCC00; +} +.datapusher-status.status-error { + color: red; +} +.datapusher-status.status-complete { + color: #009900; +} body { background: #005d7a url("../../../base/images/bg.png"); } @@ -8182,7 +8351,6 @@ iframe { .ie7 .control-custom .checkbox { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .stages { @@ -8216,7 +8384,6 @@ iframe { .ie7 .masthead nav { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .masthead .header-image { diff --git a/ckan/public/base/css/maroon.css b/ckan/public/base/css/maroon.css index 4de9736ff0e..73f14b214d9 100644 --- a/ckan/public/base/css/maroon.css +++ b/ckan/public/base/css/maroon.css @@ -49,16 +49,12 @@ sub { } img { /* Responsive images (ensure images don't scale beyond their parents) */ - max-width: 100%; /* Part 1: Set a maxium relative to the parent */ - width: auto\9; /* IE7-8 need help adjusting responsive images */ - height: auto; /* Part 2: Scale the height according to the width, otherwise you get stretching */ - vertical-align: middle; border: 0; -ms-interpolation-mode: bicubic; @@ -153,7 +149,7 @@ textarea { img { max-width: 100% !important; } - @page { + @page { margin: 0.5cm; } p, @@ -693,7 +689,6 @@ ol.inline > li { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding-left: 5px; padding-right: 5px; @@ -972,7 +967,6 @@ input[type="color"]:focus, outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -982,10 +976,8 @@ input[type="checkbox"] { margin: 4px 0 0; *margin-top: 0; /* IE7 */ - margin-top: 1px \9; /* IE8-9 */ - line-height: normal; } input[type="file"], @@ -1001,10 +993,8 @@ select, input[type="file"] { height: 30px; /* In IE7, the height of the select element cannot be changed by height, only font-size */ - *margin-top: 4px; /* For IE7, add top margin to align select with labels */ - line-height: 30px; } select { @@ -1402,7 +1392,6 @@ select:focus:invalid:focus { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; vertical-align: middle; padding-left: 5px; @@ -1553,7 +1542,6 @@ input.search-query { padding-left: 14px; padding-left: 4px \9; /* IE7-8 doesn't have border-radius, so don't indent the padding */ - margin-bottom: 0; -webkit-border-radius: 15px; -moz-border-radius: 15px; @@ -1610,7 +1598,6 @@ input.search-query { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-bottom: 0; vertical-align: middle; @@ -2220,7 +2207,6 @@ button.close { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding: 4px 12px; margin-bottom: 0; @@ -2243,7 +2229,6 @@ button.close { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #eaeaea; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); border: 1px solid #cccccc; *border: 0; @@ -2379,7 +2364,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #810606; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-primary:hover, @@ -2411,7 +2395,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #f89406; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-warning:hover, @@ -2443,7 +2426,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #bd362f; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-danger:hover, @@ -2475,7 +2457,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #51a351; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-success:hover, @@ -2507,7 +2488,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #2f96b4; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-info:hover, @@ -2539,7 +2519,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #222222; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-inverse:hover, @@ -2614,7 +2593,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; font-size: 0; vertical-align: middle; @@ -2790,7 +2768,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .btn-group-vertical > .btn { @@ -3487,7 +3464,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #e5e5e5; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); -moz-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); @@ -3721,7 +3697,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #040404; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .navbar-inverse .btn-navbar:hover, @@ -3751,7 +3726,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; text-shadow: 0 1px 0 #ffffff; } @@ -3769,7 +3743,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-left: 0; margin-bottom: 0; @@ -3970,7 +3943,6 @@ input[type="submit"].btn.btn-mini { border: 1px solid rgba(0, 0, 0, 0.3); *border: 1px solid #999; /* IE6-7 */ - -webkit-border-radius: 6px; -moz-border-radius: 6px; border-radius: 6px; @@ -5575,6 +5547,12 @@ textarea { } .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; +} +.form-actions .control-required-message:first-child { + margin-left: 0; } .form-actions { background: none; @@ -5692,27 +5670,24 @@ textarea { padding: 7px 5px; } .simple-input .field .btn-search { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - width: 16px; - height: 16px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - background-position: -51px -16px; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + -webkit-transition: color 0.2s ease-in; + -moz-transition: color 0.2s ease-in; + -o-transition: color 0.2s ease-in; + transition: color 0.2s ease-in; +} +.simple-input .field .btn-search:hover { + color: #000; } .editor textarea { -webkit-border-radius: 3px 3px 0 0; @@ -5818,7 +5793,6 @@ textarea { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #810606; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .control-custom.disabled .checkbox.btn:hover, @@ -6097,7 +6071,6 @@ textarea { outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -6120,6 +6093,43 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.js .image-upload #field-image-upload { + cursor: pointer; + position: absolute; + z-index: 1; + opacity: 0; + filter: alpha(opacity=0); +} +.js .image-upload .controls { + position: relative; +} +.js .image-upload .btn { + position: relative; + top: 0; + margin-right: 10px; +} +.js .image-upload .btn.hover { + color: #333333; + text-decoration: none; + background-position: 0 -15px; + -webkit-transition: background-position 0.1s linear; + -moz-transition: background-position 0.1s linear; + -o-transition: background-position 0.1s linear; + transition: background-position 0.1s linear; +} +.js .image-upload .btn-remove-url { + position: absolute; + margin-right: 0; + top: 4px; + right: 5px; + padding: 0 4px; + -webkit-border-radius: 100px; + -moz-border-radius: 100px; + border-radius: 100px; +} +.js .image-upload .btn-remove-url .icon-remove { + margin-right: 0; +} .add-member-form .control-label { width: 100%; text-align: left; @@ -6228,44 +6238,45 @@ textarea { top: 14px; right: 10px; } -.dataset-resource-form .dataset-form-resource-types { - margin-bottom: 5px; +.resource-list.reordering .resource-item { + border: 1px solid #dddddd; + margin-bottom: 10px; + cursor: move; } -.dataset-form-resource-types .ckan-icon { - position: relative; - top: 3px; +.resource-list.reordering .resource-item .handle { + display: block; + position: absolute; + color: #888888; + left: -31px; + top: 50%; + margin-top: -15px; + width: 30px; + height: 30px; + line-height: 30px; + text-align: center; + border: 1px solid #dddddd; + border-width: 1px 0 1px 1px; + background-color: #ffffff; + -webkit-border-radius: 20px 0 0 20px; + -moz-border-radius: 20px 0 0 20px; + border-radius: 20px 0 0 20px; } -.dataset-form-resource-types .radio { - font-weight: normal; - padding-left: 0; - padding-right: 18px; +.resource-list.reordering .resource-item .handle:hover { + text-decoration: none; } -.dataset-form-resource-types label { - position: relative; +.resource-list.reordering .resource-item:hover .handle { + background-color: #eeeeee; } -.dataset-form-resource-types input[type=radio]:checked + label { - font-weight: bold; +.resource-list.reordering .resource-item.ui-sortable-helper { + background-color: #eeeeee; + border: 1px solid #810606; } -.dataset-form-resource-types input[type=radio]:checked + label:after { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - width: 16px; - height: 16px; - background-position: -192px 0; - display: block; - content: ""; - position: absolute; - top: auto; - left: 0; - bottom: -12px; +.resource-list.reordering .resource-item.ui-sortable-helper .handle { + background-color: #eeeeee; + border-color: #810606; + color: #333333; } -.dataset-form-resource-types input[type=radio] { +.resource-item .handle { display: none; } .tag-list { @@ -6414,6 +6425,10 @@ textarea { font-weight: normal; color: #000000; } +.search-form.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; +} .tertiary .control-order-by { float: none; margin: 0; @@ -6534,7 +6549,6 @@ textarea { margin-right: 5px; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .actions li:last-of-type { @@ -7105,6 +7119,7 @@ h4 small { margin-top: 15px; padding-top: 10px; border-top: 1px dotted #DDD; + word-break: break-word; } .context-info .info dl dd { margin-top: 3px; @@ -7159,6 +7174,11 @@ h4 small { .context-info.editing .module-content { margin-top: 0; } +.flash-messages .alert { + -webkit-box-shadow: 0 0 0 1px #ffffff; + -moz-box-shadow: 0 0 0 1px #ffffff; + box-shadow: 0 0 0 1px #ffffff; +} .homepage [role=main] { padding: 20px 0; min-height: 0; @@ -7711,6 +7731,9 @@ h4 small { -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } +.activity .item.no-avatar p { + margin-left: 40px; +} .activity .load-less { margin-bottom: 15px; } @@ -7728,9 +7751,28 @@ h4 small { float: right; text-decoration: none; } +.popover .popover-content { + font-size: 14px; + line-height: 20px; + color: #444444; + word-break: break-all; +} +.popover .popover-content dl { + margin: 0; +} +.popover .popover-content dl dd { + margin-left: 0; + margin-bottom: 10px; +} .activity .item .icon { background-color: #999999; } +.activity .item.failure .icon { + background-color: #b95252; +} +.activity .item.success .icon { + background-color: #69a67a; +} .activity .item.added-tag .icon { background-color: #6995a6; } @@ -7981,6 +8023,21 @@ h4 small { font-size: 16px; margin: 3px 0; } +.datapusher-status-link:hover { + text-decoration: none; +} +.datapusher-status.status-unknown { + color: #bbb; +} +.datapusher-status.status-pending { + color: #FFCC00; +} +.datapusher-status.status-error { + color: red; +} +.datapusher-status.status-complete { + color: #009900; +} body { background: #810606 url("../../../base/images/bg.png"); } @@ -8182,7 +8239,6 @@ iframe { .ie7 .control-custom .checkbox { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .stages { @@ -8216,7 +8272,6 @@ iframe { .ie7 .masthead nav { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .masthead .header-image { diff --git a/ckan/public/base/css/red.css b/ckan/public/base/css/red.css index 4b1a9181328..3105bbda479 100644 --- a/ckan/public/base/css/red.css +++ b/ckan/public/base/css/red.css @@ -49,16 +49,12 @@ sub { } img { /* Responsive images (ensure images don't scale beyond their parents) */ - max-width: 100%; /* Part 1: Set a maxium relative to the parent */ - width: auto\9; /* IE7-8 need help adjusting responsive images */ - height: auto; /* Part 2: Scale the height according to the width, otherwise you get stretching */ - vertical-align: middle; border: 0; -ms-interpolation-mode: bicubic; @@ -153,7 +149,7 @@ textarea { img { max-width: 100% !important; } - @page { + @page { margin: 0.5cm; } p, @@ -693,7 +689,6 @@ ol.inline > li { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding-left: 5px; padding-right: 5px; @@ -972,7 +967,6 @@ input[type="color"]:focus, outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -982,10 +976,8 @@ input[type="checkbox"] { margin: 4px 0 0; *margin-top: 0; /* IE7 */ - margin-top: 1px \9; /* IE8-9 */ - line-height: normal; } input[type="file"], @@ -1001,10 +993,8 @@ select, input[type="file"] { height: 30px; /* In IE7, the height of the select element cannot be changed by height, only font-size */ - *margin-top: 4px; /* For IE7, add top margin to align select with labels */ - line-height: 30px; } select { @@ -1402,7 +1392,6 @@ select:focus:invalid:focus { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; vertical-align: middle; padding-left: 5px; @@ -1553,7 +1542,6 @@ input.search-query { padding-left: 14px; padding-left: 4px \9; /* IE7-8 doesn't have border-radius, so don't indent the padding */ - margin-bottom: 0; -webkit-border-radius: 15px; -moz-border-radius: 15px; @@ -1610,7 +1598,6 @@ input.search-query { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-bottom: 0; vertical-align: middle; @@ -2220,7 +2207,6 @@ button.close { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; padding: 4px 12px; margin-bottom: 0; @@ -2243,7 +2229,6 @@ button.close { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #eaeaea; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); border: 1px solid #cccccc; *border: 0; @@ -2379,7 +2364,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #c14531; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-primary:hover, @@ -2411,7 +2395,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #f89406; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-warning:hover, @@ -2443,7 +2426,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #bd362f; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-danger:hover, @@ -2475,7 +2457,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #51a351; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-success:hover, @@ -2507,7 +2488,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #2f96b4; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-info:hover, @@ -2539,7 +2519,6 @@ input[type="button"].btn-block { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #222222; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .btn-inverse:hover, @@ -2614,7 +2593,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; font-size: 0; vertical-align: middle; @@ -2790,7 +2768,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .btn-group-vertical > .btn { @@ -3487,7 +3464,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #e5e5e5; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); -moz-box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.075); @@ -3721,7 +3697,6 @@ input[type="submit"].btn.btn-mini { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #040404; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .navbar-inverse .btn-navbar:hover, @@ -3751,7 +3726,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; text-shadow: 0 1px 0 #ffffff; } @@ -3769,7 +3743,6 @@ input[type="submit"].btn.btn-mini { display: inline-block; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; margin-left: 0; margin-bottom: 0; @@ -3970,7 +3943,6 @@ input[type="submit"].btn.btn-mini { border: 1px solid rgba(0, 0, 0, 0.3); *border: 1px solid #999; /* IE6-7 */ - -webkit-border-radius: 6px; -moz-border-radius: 6px; border-radius: 6px; @@ -5575,6 +5547,12 @@ textarea { } .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; +} +.form-actions .control-required-message:first-child { + margin-left: 0; } .form-actions { background: none; @@ -5692,27 +5670,24 @@ textarea { padding: 7px 5px; } .simple-input .field .btn-search { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - width: 16px; - height: 16px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - background-position: -51px -16px; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + -webkit-transition: color 0.2s ease-in; + -moz-transition: color 0.2s ease-in; + -o-transition: color 0.2s ease-in; + transition: color 0.2s ease-in; +} +.simple-input .field .btn-search:hover { + color: #000; } .editor textarea { -webkit-border-radius: 3px 3px 0 0; @@ -5818,7 +5793,6 @@ textarea { border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); *background-color: #c14531; /* Darken IE7 buttons by default so they stand out more given they won't have borders */ - filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); } .control-custom.disabled .checkbox.btn:hover, @@ -6097,7 +6071,6 @@ textarea { outline: 0; outline: thin dotted \9; /* IE6-9 */ - -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6); @@ -6120,6 +6093,43 @@ textarea { -moz-box-shadow: none; box-shadow: none; } +.js .image-upload #field-image-upload { + cursor: pointer; + position: absolute; + z-index: 1; + opacity: 0; + filter: alpha(opacity=0); +} +.js .image-upload .controls { + position: relative; +} +.js .image-upload .btn { + position: relative; + top: 0; + margin-right: 10px; +} +.js .image-upload .btn.hover { + color: #333333; + text-decoration: none; + background-position: 0 -15px; + -webkit-transition: background-position 0.1s linear; + -moz-transition: background-position 0.1s linear; + -o-transition: background-position 0.1s linear; + transition: background-position 0.1s linear; +} +.js .image-upload .btn-remove-url { + position: absolute; + margin-right: 0; + top: 4px; + right: 5px; + padding: 0 4px; + -webkit-border-radius: 100px; + -moz-border-radius: 100px; + border-radius: 100px; +} +.js .image-upload .btn-remove-url .icon-remove { + margin-right: 0; +} .add-member-form .control-label { width: 100%; text-align: left; @@ -6228,44 +6238,45 @@ textarea { top: 14px; right: 10px; } -.dataset-resource-form .dataset-form-resource-types { - margin-bottom: 5px; +.resource-list.reordering .resource-item { + border: 1px solid #dddddd; + margin-bottom: 10px; + cursor: move; } -.dataset-form-resource-types .ckan-icon { - position: relative; - top: 3px; +.resource-list.reordering .resource-item .handle { + display: block; + position: absolute; + color: #888888; + left: -31px; + top: 50%; + margin-top: -15px; + width: 30px; + height: 30px; + line-height: 30px; + text-align: center; + border: 1px solid #dddddd; + border-width: 1px 0 1px 1px; + background-color: #ffffff; + -webkit-border-radius: 20px 0 0 20px; + -moz-border-radius: 20px 0 0 20px; + border-radius: 20px 0 0 20px; } -.dataset-form-resource-types .radio { - font-weight: normal; - padding-left: 0; - padding-right: 18px; +.resource-list.reordering .resource-item .handle:hover { + text-decoration: none; } -.dataset-form-resource-types label { - position: relative; +.resource-list.reordering .resource-item:hover .handle { + background-color: #eeeeee; } -.dataset-form-resource-types input[type=radio]:checked + label { - font-weight: bold; +.resource-list.reordering .resource-item.ui-sortable-helper { + background-color: #eeeeee; + border: 1px solid #c14531; } -.dataset-form-resource-types input[type=radio]:checked + label:after { - *margin-right: .3em; - display: inline-block; - vertical-align: text-bottom; - position: relative; - top: 2px; - background-image: url("../../../base/images/sprite-ckan-icons.png"); - background-repeat: no-repeat; - background-position: 16px 16px; - width: 16px; - height: 16px; - background-position: -192px 0; - display: block; - content: ""; - position: absolute; - top: auto; - left: 0; - bottom: -12px; +.resource-list.reordering .resource-item.ui-sortable-helper .handle { + background-color: #eeeeee; + border-color: #c14531; + color: #333333; } -.dataset-form-resource-types input[type=radio] { +.resource-item .handle { display: none; } .tag-list { @@ -6414,6 +6425,10 @@ textarea { font-weight: normal; color: #000000; } +.search-form.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; +} .tertiary .control-order-by { float: none; margin: 0; @@ -6534,7 +6549,6 @@ textarea { margin-right: 5px; *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .actions li:last-of-type { @@ -7105,6 +7119,7 @@ h4 small { margin-top: 15px; padding-top: 10px; border-top: 1px dotted #DDD; + word-break: break-word; } .context-info .info dl dd { margin-top: 3px; @@ -7159,6 +7174,11 @@ h4 small { .context-info.editing .module-content { margin-top: 0; } +.flash-messages .alert { + -webkit-box-shadow: 0 0 0 1px #ffffff; + -moz-box-shadow: 0 0 0 1px #ffffff; + box-shadow: 0 0 0 1px #ffffff; +} .homepage [role=main] { padding: 20px 0; min-height: 0; @@ -7711,6 +7731,9 @@ h4 small { -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } +.activity .item.no-avatar p { + margin-left: 40px; +} .activity .load-less { margin-bottom: 15px; } @@ -7728,9 +7751,28 @@ h4 small { float: right; text-decoration: none; } +.popover .popover-content { + font-size: 14px; + line-height: 20px; + color: #444444; + word-break: break-all; +} +.popover .popover-content dl { + margin: 0; +} +.popover .popover-content dl dd { + margin-left: 0; + margin-bottom: 10px; +} .activity .item .icon { background-color: #999999; } +.activity .item.failure .icon { + background-color: #b95252; +} +.activity .item.success .icon { + background-color: #69a67a; +} .activity .item.added-tag .icon { background-color: #6995a6; } @@ -7981,6 +8023,21 @@ h4 small { font-size: 16px; margin: 3px 0; } +.datapusher-status-link:hover { + text-decoration: none; +} +.datapusher-status.status-unknown { + color: #bbb; +} +.datapusher-status.status-pending { + color: #FFCC00; +} +.datapusher-status.status-error { + color: red; +} +.datapusher-status.status-complete { + color: #009900; +} body { background: #c14531 url("../../../base/images/bg.png"); } @@ -8182,7 +8239,6 @@ iframe { .ie7 .control-custom .checkbox { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .stages { @@ -8216,7 +8272,6 @@ iframe { .ie7 .masthead nav { *display: inline; /* IE7 inline-block hack */ - *zoom: 1; } .ie7 .masthead .header-image { diff --git a/ckan/public/base/javascript/client.js b/ckan/public/base/javascript/client.js index 45940c0086d..e1c60ccf24e 100644 --- a/ckan/public/base/javascript/client.js +++ b/ckan/public/base/javascript/client.js @@ -370,7 +370,7 @@ }); ckan.sandbox.setup(function (instance) { - instance.client = new Client({endpoint: ckan.API_ROOT}); + instance.client = new Client({endpoint: ckan.SITE_ROOT}); }); ckan.Client = Client; diff --git a/ckan/public/base/javascript/main.js b/ckan/public/base/javascript/main.js index 6089b6ff533..165b1a75f7e 100644 --- a/ckan/public/base/javascript/main.js +++ b/ckan/public/base/javascript/main.js @@ -29,14 +29,15 @@ this.ckan = this.ckan || {}; ckan.SITE_ROOT = getRootFromData('siteRoot'); ckan.LOCALE_ROOT = getRootFromData('localeRoot'); - ckan.API_ROOT = getRootFromData('apiRoot'); // Load the localisations before instantiating the modules. ckan.sandbox().client.getLocaleData(locale).done(function (data) { ckan.i18n.load(data); ckan.module.initialize(); }); - // jQuery('[data-target="popover"]').popover(); + if (jQuery.fn.popover !== undefined) { + jQuery('[data-target="popover"]').popover(); + } }; /* Returns a full url for the current site with the provided path appended. diff --git a/ckan/public/base/javascript/modules/autocomplete.js b/ckan/public/base/javascript/modules/autocomplete.js index 648fe5960d0..ed7c676051f 100644 --- a/ckan/public/base/javascript/modules/autocomplete.js +++ b/ckan/public/base/javascript/modules/autocomplete.js @@ -72,6 +72,12 @@ this.ckan.module('autocomplete', function (jQuery, _) { } settings.initSelection = this.formatInitialValue; } + else { + if (/MSIE (\d+\.\d+);/.test(navigator.userAgent)) { + var ieversion=new Number(RegExp.$1); + if (ieversion<=7) {return} + } + } var select2 = this.el.select2(settings).data('select2'); diff --git a/ckan/public/base/javascript/modules/custom-fields.js b/ckan/public/base/javascript/modules/custom-fields.js index 7124d27a763..3bd6daa0646 100644 --- a/ckan/public/base/javascript/modules/custom-fields.js +++ b/ckan/public/base/javascript/modules/custom-fields.js @@ -83,7 +83,6 @@ this.ckan.module('custom-fields', function (jQuery, _) { */ disableField: function (field, disable) { field.toggleClass('disabled', disable !== false); - field.find(':input:not(:checkbox)').prop('disabled', disable !== false); }, /* Event handler that fires when the last key in the custom field block diff --git a/ckan/public/base/javascript/modules/image-upload.js b/ckan/public/base/javascript/modules/image-upload.js new file mode 100644 index 00000000000..47f38fb240e --- /dev/null +++ b/ckan/public/base/javascript/modules/image-upload.js @@ -0,0 +1,192 @@ +/* Image Upload + * + */ +this.ckan.module('image-upload', function($, _) { + return { + /* options object can be extended using data-module-* attributes */ + options: { + is_url: true, + is_upload: false, + field_upload: 'image_upload', + field_url: 'image_url', + field_clear: 'clear_upload', + upload_label: '', + i18n: { + upload: _('Upload'), + url: _('Link'), + remove: _('Remove'), + upload_label: _('Image'), + upload_tooltip: _('Upload a file on your computer'), + url_tooltip: _('Link to a URL on the internet (you can also link to an API)'), + remove_tooltip: _('Reset this') + }, + template: [ + '' + ].join("\n") + }, + + state: { + attached: 1, + blank: 2, + web: 3 + }, + + /* Initialises the module setting up elements and event listeners. + * + * Returns nothing. + */ + initialize: function () { + $.proxyAll(this, /_on/); + var options = this.options; + + // firstly setup the fields + var field_upload = 'input[name="' + options.field_upload + '"]'; + var field_url = 'input[name="' + options.field_url + '"]'; + var field_clear = 'input[name="' + options.field_clear + '"]'; + + this.input = $(field_upload, this.el); + this.field_url = $(field_url, this.el).parents('.control-group'); + this.field_image = this.input.parents('.control-group'); + + // Is there a clear checkbox on the form already? + var checkbox = $(field_clear, this.el); + if (checkbox.length > 0) { + options.is_upload = true; + checkbox.parents('.control-group').remove(); + } + + // Adds the hidden clear input to the form + this.field_clear = $('') + .appendTo(this.el); + + // Button to set the field to be a URL + this.button_url = $(' '+this.i18n('url')+'') + .prop('title', this.i18n('url_tooltip')) + .on('click', this._onFromWeb) + .insertAfter(this.input); + + // Button to attach local file to the form + this.button_upload = $(''+this.i18n('upload')+'') + .insertAfter(this.input); + + // Button to reset the form back to the first from when there is a image uploaded + this.button_remove = $('') + .text(this.i18n('remove')) + .on('click', this._onRemove) + .insertAfter(this.button_upload); + + // Button for resetting the form when there is a URL set + $('') + .prop('title', this.i18n('remove_tooltip')) + .on('click', this._onRemove) + .insertBefore($('input', this.field_url)); + + // Update the main label + $('label[for="field-image-upload"]').text(options.upload_label || this.i18n('upload_label')); + + // Setup the file input + this.input + .on('mouseover', this._onInputMouseOver) + .on('mouseout', this._onInputMouseOut) + .on('change', this._onInputChange) + .prop('title', this.i18n('upload_tooltip')) + .css('width', this.button_upload.outerWidth()); + + // Fields storage. Used in this.changeState + this.fields = $('') + .add(this.button_remove) + .add(this.button_upload) + .add(this.button_url) + .add(this.input) + .add(this.field_url) + .add(this.field_image); + + // Setup the initial state + if (options.is_url) { + this.changeState(this.state.web); + } else if (options.is_upload) { + this.changeState(this.state.attached); + } else { + this.changeState(this.state.blank); + } + + }, + + /* Method to change the display state of the image fields + * + * state - Pseudo constant for passing the state we should be in now + * + * Examples + * + * this.changeState(this.state.web); // Sets the state in URL mode + * + * Returns nothing. + */ + changeState: function(state) { + this.fields.hide(); + if (state == this.state.blank) { + this.button_upload + .add(this.field_image) + .add(this.button_url) + .add(this.input) + .show(); + } else if (state == this.state.attached) { + this.button_remove + .add(this.field_image) + .show(); + } else if (state == this.state.web) { + this.field_url + .show(); + } + }, + + /* Event listener for when someone sets the field to URL mode + * + * Returns nothing. + */ + _onFromWeb: function() { + this.changeState(this.state.web); + $('input', this.field_url).focus(); + if (this.options.is_upload) { + this.field_clear.val('true'); + } + }, + + /* Event listener for resetting the field back to the blank state + * + * Returns nothing. + */ + _onRemove: function() { + this.changeState(this.state.blank); + $('input', this.field_url).val(''); + this.field_clear.val('true'); + }, + + /* Event listener for when someone chooses a file to upload + * + * Returns nothing. + */ + _onInputChange: function() { + this.file_name = this.input.val(); + this.field_clear.val(''); + this.changeState(this.state.attached); + }, + + /* Event listener for when a user mouseovers the hidden file input + * + * Returns nothing. + */ + _onInputMouseOver: function() { + this.button_upload.addClass('hover'); + }, + + /* Event listener for when a user mouseouts the hidden file input + * + * Returns nothing. + */ + _onInputMouseOut: function() { + this.button_upload.removeClass('hover'); + } + + } +}); diff --git a/ckan/public/base/javascript/modules/resource-reorder.js b/ckan/public/base/javascript/modules/resource-reorder.js new file mode 100644 index 00000000000..f836d7f2fa5 --- /dev/null +++ b/ckan/public/base/javascript/modules/resource-reorder.js @@ -0,0 +1,144 @@ +/* Module for reordering resources + */ +this.ckan.module('resource-reorder', function($, _) { + return { + options: { + id: false, + i18n: { + label: _('Reorder resources'), + save: _('Save order'), + saving: _('Saving...'), + cancel: _('Cancel') + } + }, + template: { + title: '

    ', + button: [ + '', + '', + '', + '' + ].join('\n'), + form_actions: [ + '
    ', + '', + '', + '
    ' + ].join('\n'), + handle: [ + '', + '', + '' + ].join('\n'), + saving: [ + '', + '', + '', + '' + ].join('\n') + }, + is_reordering: false, + cache: false, + + initialize: function() { + jQuery.proxyAll(this, /_on/); + + this.html_title = $(this.template.title) + .text(this.i18n('label')) + .insertBefore(this.el) + .hide(); + var button = $(this.template.button) + .on('click', this._onHandleStartReorder) + .appendTo('.page_primary_action'); + $('span', button).text(this.i18n('label')); + + this.html_form_actions = $(this.template.form_actions) + .hide() + .insertAfter(this.el); + $('.save', this.html_form_actions) + .text(this.i18n('save')) + .on('click', this._onHandleSave); + $('.cancel', this.html_form_actions) + .text(this.i18n('cancel')) + .on('click', this._onHandleCancel); + + this.html_handles = $(this.template.handle) + .hide() + .appendTo($('.resource-item', this.el)); + + this.html_saving = $(this.template.saving) + .hide() + .insertBefore($('.save', this.html_form_actions)); + $('span', this.html_saving).text(this.i18n('saving')); + + this.cache = this.el.html(); + + this.el + .sortable() + .sortable('disable'); + + }, + + _onHandleStartReorder: function() { + if (!this.is_reordering) { + this.html_form_actions + .add(this.html_handles) + .add(this.html_title) + .show(); + this.el + .addClass('reordering') + .sortable('enable'); + $('.page_primary_action').hide(); + this.is_reordering = true; + } + }, + + _onHandleCancel: function() { + if ( + this.is_reordering + && !$('.cancel', this.html_form_actions).hasClass('disabled') + ) { + this.reset(); + this.is_reordering = false; + this.el.html(this.cache) + .sortable() + .sortable('disable'); + this.html_handles = $('.handle', this.el); + } + }, + + _onHandleSave: function() { + if (!$('.save', this.html_form_actions).hasClass('disabled')) { + var module = this; + module.html_saving.show(); + $('.save, .cancel', module.html_form_actions).addClass('disabled'); + var order = []; + $('.resource-item', module.el).each(function() { + order.push($(this).data('id')); + }); + module.sandbox.client.call('POST', 'package_resource_reorder', { + id: module.options.id, + order: order + }, function() { + module.html_saving.hide(); + $('.save, .cancel', module.html_form_actions).removeClass('disabled'); + module.cache = module.el.html(); + module.reset(); + module.is_reordering = false; + }); + } + }, + + reset: function() { + this.html_form_actions + .add(this.html_handles) + .add(this.html_title) + .hide(); + this.el + .removeClass('reordering') + .sortable('disable'); + $('.page_primary_action').show(); + } + + }; +}); diff --git a/ckan/public/base/javascript/modules/slug-preview.js b/ckan/public/base/javascript/modules/slug-preview.js index e622401ee09..63fb08d94e7 100644 --- a/ckan/public/base/javascript/modules/slug-preview.js +++ b/ckan/public/base/javascript/modules/slug-preview.js @@ -9,17 +9,20 @@ this.ckan.module('slug-preview-target', { el.after(preview); }); - // Once the preview box is modified stop watching it. - sandbox.subscribe('slug-preview-modified', function () { - el.off('.slug-preview'); - }); + // Make sure there isn't a value in the field already... + if (el.val() == '') { + // Once the preview box is modified stop watching it. + sandbox.subscribe('slug-preview-modified', function () { + el.off('.slug-preview'); + }); - // Watch for updates to the target field and update the hidden slug field - // triggering the "change" event manually. - el.on('keyup.slug-preview', function (event) { - sandbox.publish('slug-target-changed', this.value); - //slug.val(this.value).trigger('change'); - }); + // Watch for updates to the target field and update the hidden slug field + // triggering the "change" event manually. + el.on('keyup.slug-preview', function (event) { + sandbox.publish('slug-target-changed', this.value); + //slug.val(this.value).trigger('change'); + }); + } } }); diff --git a/ckan/public/base/javascript/resource.config b/ckan/public/base/javascript/resource.config index b1a7302d9e8..0dee42a831f 100644 --- a/ckan/public/base/javascript/resource.config +++ b/ckan/public/base/javascript/resource.config @@ -32,12 +32,14 @@ ckan = modules/table-selectable-rows.js modules/resource-form.js modules/resource-upload-field.js + modules/resource-reorder.js modules/follow.js modules/activity-stream.js modules/dashboard.js modules/table-toggle-more.js modules/dataset-visibility.js modules/media-grid.js + modules/image-upload.js main = apply_html_class diff --git a/ckan/public/base/less/dataset.less b/ckan/public/base/less/dataset.less index fc1cd38407b..50edf442f96 100644 --- a/ckan/public/base/less/dataset.less +++ b/ckan/public/base/less/dataset.less @@ -85,6 +85,7 @@ .resource-item .description { font-size: 12px; margin-bottom: 0; + min-height: 12px; } .resource-item .btn-group { @@ -93,46 +94,50 @@ right: 10px; } -// Dataset Forms - -.dataset-resource-form .dataset-form-resource-types { - margin-bottom: 5px; -} - -.dataset-form-resource-types .ckan-icon { - position: relative; - top: 3px; -} - -.dataset-form-resource-types .radio { - font-weight: normal; - padding-left: 0; - padding-right: 18px; -} - -.dataset-form-resource-types label { - position: relative; -} - -.dataset-form-resource-types input[type=radio]:checked+label { - font-weight: bold; -} - -.dataset-form-resource-types input[type=radio]:checked+label:after { - .ckan-icon; - .ckan-icon-callout; - display: block; - content: ""; - position: absolute; - top: auto; - left: 0; - bottom: -12px; +.resource-list.reordering { + .resource-item { + border: 1px solid @moduleHeadingBorderColor; + margin-bottom: 10px; + cursor: move; + .handle { + display: block; + position: absolute; + color: @moduleHeadingActionTextColor; + left: -31px; + top: 50%; + margin-top: -15px; + width: 30px; + height: 30px; + line-height: 30px; + text-align: center; + border: 1px solid @moduleHeadingBorderColor; + border-width: 1px 0 1px 1px; + background-color: @moduleBackgroundColor; + .border-radius(20px 0 0 20px); + &:hover { + text-decoration: none; + } + } + &:hover .handle { + background-color: @layoutBackgroundColor; + } + &.ui-sortable-helper { + background-color: @layoutBackgroundColor; + border: 1px solid @layoutLinkColor; + .handle { + background-color: @layoutBackgroundColor; + border-color: @layoutLinkColor; + color: @navLinkColor; + } + } + } } - -.dataset-form-resource-types input[type=radio] { +.resource-item .handle { display: none; } +// Dataset Forms + // Tag List .tag-list { diff --git a/ckan/public/base/less/forms.less b/ckan/public/base/less/forms.less index fc8c7c86987..bf45350340f 100644 --- a/ckan/public/base/less/forms.less +++ b/ckan/public/base/less/forms.less @@ -85,6 +85,12 @@ textarea { .form-actions .control-required-message { float: left; + margin-left: 20px; + margin-bottom: 0; + line-height: 30px; + &:first-child { + margin-left: 0; + } } .form-actions { @@ -237,18 +243,21 @@ textarea { } .simple-input .field .btn-search { - .ckan-icon; - .ckan-icon-search; position: absolute; display: block; height: 17px; width: 17px; + padding: 0; top: 50%; right: 0; - margin-top: -8px; + margin-top: -10px; background-color: transparent; border: none; - text-indent: -999em; + color: #999; + .transition(color 0.2s ease-in); + &:hover { + color: #000; + } } .editor textarea { @@ -671,6 +680,39 @@ textarea { } } +.js .image-upload { + #field-image-upload { + cursor: pointer; + position: absolute; + z-index: 1; + .opacity(0); + } + .controls { + position: relative; + } + .btn { + position: relative; + top: 0; + margin-right: 10px; + &.hover { + color: @grayDark; + text-decoration: none; + background-position: 0 -15px; + .transition(background-position .1s linear); + } + } + .btn-remove-url { + position: absolute; + margin-right: 0; + top: 4px; + right: 5px; + padding: 0 4px; + .border-radius(100px); + .icon-remove { + margin-right: 0; + } + } +} .add-member-form .control-label { width: 100%; text-align: left; diff --git a/ckan/public/base/less/layout.less b/ckan/public/base/less/layout.less index 78905c89eee..9ae4d86796d 100644 --- a/ckan/public/base/less/layout.less +++ b/ckan/public/base/less/layout.less @@ -114,6 +114,7 @@ margin-top: 15px; padding-top: 10px; border-top: 1px dotted #DDD; + word-break: break-word; dl dd { margin-top: 3px; margin-left: 0; @@ -165,3 +166,10 @@ } } } + +.flash-messages { + .alert { + .box-shadow(0 0 0 1px white); + } +} + diff --git a/ckan/public/base/less/search.less b/ckan/public/base/less/search.less index a9e4193eb83..fffe049ff29 100644 --- a/ckan/public/base/less/search.less +++ b/ckan/public/base/less/search.less @@ -84,6 +84,10 @@ color: @layoutBoldColor; } } + &.no-bottom-border { + border-bottom-width: 0; + margin-bottom: 0; + } } .tertiary { diff --git a/ckan/public/base/test/spec/modules/custom-fields.spec.js b/ckan/public/base/test/spec/modules/custom-fields.spec.js index 99088cc5e9e..79e5d03187b 100644 --- a/ckan/public/base/test/spec/modules/custom-fields.spec.js +++ b/ckan/public/base/test/spec/modules/custom-fields.spec.js @@ -135,18 +135,6 @@ describe('ckan.module.CustomFieldsModule()', function () { assert.isTrue(this.target.hasClass('disabled')); }); - it('should set the disabled property on the input elements', function () { - this.module.disableField(this.target); - - this.target.find(':input').each(function () { - if (jQuery(this).is(':checkbox')) { - assert.isFalse(this.disabled, 'checkbox should not be disabled'); - } else { - assert.isTrue(this.disabled, 'input should be disabled'); - } - }); - }); - it('should remove a .disable class to the element if disable is false', function () { this.target.addClass('disable'); @@ -154,19 +142,6 @@ describe('ckan.module.CustomFieldsModule()', function () { assert.isFalse(this.target.hasClass('disabled')); }); - it('should unset the disabled property on the input elements if disable is false', function () { - this.target.find(':input:not(:checkbox)').prop('disabled', true); - - this.module.disableField(this.target, false); - - this.target.find(':input').each(function () { - if (jQuery(this).is(':checkbox')) { - assert.isFalse(this.disabled, 'checkbox should not be disabled'); - } else { - assert.isFalse(this.disabled, 'input should not be disabled'); - } - }); - }); }); describe('._onChange(event)', function () { diff --git a/ckan/public/base/vendor/jquery.ui.core.js b/ckan/public/base/vendor/jquery.ui.core.js new file mode 100755 index 00000000000..91ca5ff5056 --- /dev/null +++ b/ckan/public/base/vendor/jquery.ui.core.js @@ -0,0 +1,320 @@ +/*! + * jQuery UI Core 1.10.3 + * http://jqueryui.com + * + * Copyright 2013 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/category/ui-core/ + */ +(function( $, undefined ) { + +var uuid = 0, + runiqueId = /^ui-id-\d+$/; + +// $.ui might exist from components with no dependencies, e.g., $.ui.position +$.ui = $.ui || {}; + +$.extend( $.ui, { + version: "1.10.3", + + keyCode: { + BACKSPACE: 8, + COMMA: 188, + DELETE: 46, + DOWN: 40, + END: 35, + ENTER: 13, + ESCAPE: 27, + HOME: 36, + LEFT: 37, + NUMPAD_ADD: 107, + NUMPAD_DECIMAL: 110, + NUMPAD_DIVIDE: 111, + NUMPAD_ENTER: 108, + NUMPAD_MULTIPLY: 106, + NUMPAD_SUBTRACT: 109, + PAGE_DOWN: 34, + PAGE_UP: 33, + PERIOD: 190, + RIGHT: 39, + SPACE: 32, + TAB: 9, + UP: 38 + } +}); + +// plugins +$.fn.extend({ + focus: (function( orig ) { + return function( delay, fn ) { + return typeof delay === "number" ? + this.each(function() { + var elem = this; + setTimeout(function() { + $( elem ).focus(); + if ( fn ) { + fn.call( elem ); + } + }, delay ); + }) : + orig.apply( this, arguments ); + }; + })( $.fn.focus ), + + scrollParent: function() { + var scrollParent; + if (($.ui.ie && (/(static|relative)/).test(this.css("position"))) || (/absolute/).test(this.css("position"))) { + scrollParent = this.parents().filter(function() { + return (/(relative|absolute|fixed)/).test($.css(this,"position")) && (/(auto|scroll)/).test($.css(this,"overflow")+$.css(this,"overflow-y")+$.css(this,"overflow-x")); + }).eq(0); + } else { + scrollParent = this.parents().filter(function() { + return (/(auto|scroll)/).test($.css(this,"overflow")+$.css(this,"overflow-y")+$.css(this,"overflow-x")); + }).eq(0); + } + + return (/fixed/).test(this.css("position")) || !scrollParent.length ? $(document) : scrollParent; + }, + + zIndex: function( zIndex ) { + if ( zIndex !== undefined ) { + return this.css( "zIndex", zIndex ); + } + + if ( this.length ) { + var elem = $( this[ 0 ] ), position, value; + while ( elem.length && elem[ 0 ] !== document ) { + // Ignore z-index if position is set to a value where z-index is ignored by the browser + // This makes behavior of this function consistent across browsers + // WebKit always returns auto if the element is positioned + position = elem.css( "position" ); + if ( position === "absolute" || position === "relative" || position === "fixed" ) { + // IE returns 0 when zIndex is not specified + // other browsers return a string + // we ignore the case of nested elements with an explicit value of 0 + //
    + value = parseInt( elem.css( "zIndex" ), 10 ); + if ( !isNaN( value ) && value !== 0 ) { + return value; + } + } + elem = elem.parent(); + } + } + + return 0; + }, + + uniqueId: function() { + return this.each(function() { + if ( !this.id ) { + this.id = "ui-id-" + (++uuid); + } + }); + }, + + removeUniqueId: function() { + return this.each(function() { + if ( runiqueId.test( this.id ) ) { + $( this ).removeAttr( "id" ); + } + }); + } +}); + +// selectors +function focusable( element, isTabIndexNotNaN ) { + var map, mapName, img, + nodeName = element.nodeName.toLowerCase(); + if ( "area" === nodeName ) { + map = element.parentNode; + mapName = map.name; + if ( !element.href || !mapName || map.nodeName.toLowerCase() !== "map" ) { + return false; + } + img = $( "img[usemap=#" + mapName + "]" )[0]; + return !!img && visible( img ); + } + return ( /input|select|textarea|button|object/.test( nodeName ) ? + !element.disabled : + "a" === nodeName ? + element.href || isTabIndexNotNaN : + isTabIndexNotNaN) && + // the element and all of its ancestors must be visible + visible( element ); +} + +function visible( element ) { + return $.expr.filters.visible( element ) && + !$( element ).parents().addBack().filter(function() { + return $.css( this, "visibility" ) === "hidden"; + }).length; +} + +$.extend( $.expr[ ":" ], { + data: $.expr.createPseudo ? + $.expr.createPseudo(function( dataName ) { + return function( elem ) { + return !!$.data( elem, dataName ); + }; + }) : + // support: jQuery <1.8 + function( elem, i, match ) { + return !!$.data( elem, match[ 3 ] ); + }, + + focusable: function( element ) { + return focusable( element, !isNaN( $.attr( element, "tabindex" ) ) ); + }, + + tabbable: function( element ) { + var tabIndex = $.attr( element, "tabindex" ), + isTabIndexNaN = isNaN( tabIndex ); + return ( isTabIndexNaN || tabIndex >= 0 ) && focusable( element, !isTabIndexNaN ); + } +}); + +// support: jQuery <1.8 +if ( !$( "" ).outerWidth( 1 ).jquery ) { + $.each( [ "Width", "Height" ], function( i, name ) { + var side = name === "Width" ? [ "Left", "Right" ] : [ "Top", "Bottom" ], + type = name.toLowerCase(), + orig = { + innerWidth: $.fn.innerWidth, + innerHeight: $.fn.innerHeight, + outerWidth: $.fn.outerWidth, + outerHeight: $.fn.outerHeight + }; + + function reduce( elem, size, border, margin ) { + $.each( side, function() { + size -= parseFloat( $.css( elem, "padding" + this ) ) || 0; + if ( border ) { + size -= parseFloat( $.css( elem, "border" + this + "Width" ) ) || 0; + } + if ( margin ) { + size -= parseFloat( $.css( elem, "margin" + this ) ) || 0; + } + }); + return size; + } + + $.fn[ "inner" + name ] = function( size ) { + if ( size === undefined ) { + return orig[ "inner" + name ].call( this ); + } + + return this.each(function() { + $( this ).css( type, reduce( this, size ) + "px" ); + }); + }; + + $.fn[ "outer" + name] = function( size, margin ) { + if ( typeof size !== "number" ) { + return orig[ "outer" + name ].call( this, size ); + } + + return this.each(function() { + $( this).css( type, reduce( this, size, true, margin ) + "px" ); + }); + }; + }); +} + +// support: jQuery <1.8 +if ( !$.fn.addBack ) { + $.fn.addBack = function( selector ) { + return this.add( selector == null ? + this.prevObject : this.prevObject.filter( selector ) + ); + }; +} + +// support: jQuery 1.6.1, 1.6.2 (http://bugs.jquery.com/ticket/9413) +if ( $( "" ).data( "a-b", "a" ).removeData( "a-b" ).data( "a-b" ) ) { + $.fn.removeData = (function( removeData ) { + return function( key ) { + if ( arguments.length ) { + return removeData.call( this, $.camelCase( key ) ); + } else { + return removeData.call( this ); + } + }; + })( $.fn.removeData ); +} + + + + + +// deprecated +$.ui.ie = !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() ); + +$.support.selectstart = "onselectstart" in document.createElement( "div" ); +$.fn.extend({ + disableSelection: function() { + return this.bind( ( $.support.selectstart ? "selectstart" : "mousedown" ) + + ".ui-disableSelection", function( event ) { + event.preventDefault(); + }); + }, + + enableSelection: function() { + return this.unbind( ".ui-disableSelection" ); + } +}); + +$.extend( $.ui, { + // $.ui.plugin is deprecated. Use $.widget() extensions instead. + plugin: { + add: function( module, option, set ) { + var i, + proto = $.ui[ module ].prototype; + for ( i in set ) { + proto.plugins[ i ] = proto.plugins[ i ] || []; + proto.plugins[ i ].push( [ option, set[ i ] ] ); + } + }, + call: function( instance, name, args ) { + var i, + set = instance.plugins[ name ]; + if ( !set || !instance.element[ 0 ].parentNode || instance.element[ 0 ].parentNode.nodeType === 11 ) { + return; + } + + for ( i = 0; i < set.length; i++ ) { + if ( instance.options[ set[ i ][ 0 ] ] ) { + set[ i ][ 1 ].apply( instance.element, args ); + } + } + } + }, + + // only used by resizable + hasScroll: function( el, a ) { + + //If overflow is hidden, the element might have extra content, but the user wants to hide it + if ( $( el ).css( "overflow" ) === "hidden") { + return false; + } + + var scroll = ( a && a === "left" ) ? "scrollLeft" : "scrollTop", + has = false; + + if ( el[ scroll ] > 0 ) { + return true; + } + + // TODO: determine which cases actually cause this to happen + // if the element doesn't have the scroll set, see if it's possible to + // set the scroll + el[ scroll ] = 1; + has = ( el[ scroll ] > 0 ); + el[ scroll ] = 0; + return has; + } +}); + +})( jQuery ); diff --git a/ckan/public/base/vendor/jquery.ui.mouse.js b/ckan/public/base/vendor/jquery.ui.mouse.js new file mode 100755 index 00000000000..62022cedf02 --- /dev/null +++ b/ckan/public/base/vendor/jquery.ui.mouse.js @@ -0,0 +1,169 @@ +/*! + * jQuery UI Mouse 1.10.3 + * http://jqueryui.com + * + * Copyright 2013 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/mouse/ + * + * Depends: + * jquery.ui.widget.js + */ +(function( $, undefined ) { + +var mouseHandled = false; +$( document ).mouseup( function() { + mouseHandled = false; +}); + +$.widget("ui.mouse", { + version: "1.10.3", + options: { + cancel: "input,textarea,button,select,option", + distance: 1, + delay: 0 + }, + _mouseInit: function() { + var that = this; + + this.element + .bind("mousedown."+this.widgetName, function(event) { + return that._mouseDown(event); + }) + .bind("click."+this.widgetName, function(event) { + if (true === $.data(event.target, that.widgetName + ".preventClickEvent")) { + $.removeData(event.target, that.widgetName + ".preventClickEvent"); + event.stopImmediatePropagation(); + return false; + } + }); + + this.started = false; + }, + + // TODO: make sure destroying one instance of mouse doesn't mess with + // other instances of mouse + _mouseDestroy: function() { + this.element.unbind("."+this.widgetName); + if ( this._mouseMoveDelegate ) { + $(document) + .unbind("mousemove."+this.widgetName, this._mouseMoveDelegate) + .unbind("mouseup."+this.widgetName, this._mouseUpDelegate); + } + }, + + _mouseDown: function(event) { + // don't let more than one widget handle mouseStart + if( mouseHandled ) { return; } + + // we may have missed mouseup (out of window) + (this._mouseStarted && this._mouseUp(event)); + + this._mouseDownEvent = event; + + var that = this, + btnIsLeft = (event.which === 1), + // event.target.nodeName works around a bug in IE 8 with + // disabled inputs (#7620) + elIsCancel = (typeof this.options.cancel === "string" && event.target.nodeName ? $(event.target).closest(this.options.cancel).length : false); + if (!btnIsLeft || elIsCancel || !this._mouseCapture(event)) { + return true; + } + + this.mouseDelayMet = !this.options.delay; + if (!this.mouseDelayMet) { + this._mouseDelayTimer = setTimeout(function() { + that.mouseDelayMet = true; + }, this.options.delay); + } + + if (this._mouseDistanceMet(event) && this._mouseDelayMet(event)) { + this._mouseStarted = (this._mouseStart(event) !== false); + if (!this._mouseStarted) { + event.preventDefault(); + return true; + } + } + + // Click event may never have fired (Gecko & Opera) + if (true === $.data(event.target, this.widgetName + ".preventClickEvent")) { + $.removeData(event.target, this.widgetName + ".preventClickEvent"); + } + + // these delegates are required to keep context + this._mouseMoveDelegate = function(event) { + return that._mouseMove(event); + }; + this._mouseUpDelegate = function(event) { + return that._mouseUp(event); + }; + $(document) + .bind("mousemove."+this.widgetName, this._mouseMoveDelegate) + .bind("mouseup."+this.widgetName, this._mouseUpDelegate); + + event.preventDefault(); + + mouseHandled = true; + return true; + }, + + _mouseMove: function(event) { + // IE mouseup check - mouseup happened when mouse was out of window + if ($.ui.ie && ( !document.documentMode || document.documentMode < 9 ) && !event.button) { + return this._mouseUp(event); + } + + if (this._mouseStarted) { + this._mouseDrag(event); + return event.preventDefault(); + } + + if (this._mouseDistanceMet(event) && this._mouseDelayMet(event)) { + this._mouseStarted = + (this._mouseStart(this._mouseDownEvent, event) !== false); + (this._mouseStarted ? this._mouseDrag(event) : this._mouseUp(event)); + } + + return !this._mouseStarted; + }, + + _mouseUp: function(event) { + $(document) + .unbind("mousemove."+this.widgetName, this._mouseMoveDelegate) + .unbind("mouseup."+this.widgetName, this._mouseUpDelegate); + + if (this._mouseStarted) { + this._mouseStarted = false; + + if (event.target === this._mouseDownEvent.target) { + $.data(event.target, this.widgetName + ".preventClickEvent", true); + } + + this._mouseStop(event); + } + + return false; + }, + + _mouseDistanceMet: function(event) { + return (Math.max( + Math.abs(this._mouseDownEvent.pageX - event.pageX), + Math.abs(this._mouseDownEvent.pageY - event.pageY) + ) >= this.options.distance + ); + }, + + _mouseDelayMet: function(/* event */) { + return this.mouseDelayMet; + }, + + // These are placeholder methods, to be overriden by extending plugin + _mouseStart: function(/* event */) {}, + _mouseDrag: function(/* event */) {}, + _mouseStop: function(/* event */) {}, + _mouseCapture: function(/* event */) { return true; } +}); + +})(jQuery); diff --git a/ckan/public/base/vendor/jquery.ui.sortable.js b/ckan/public/base/vendor/jquery.ui.sortable.js new file mode 100755 index 00000000000..c9f2c9021cb --- /dev/null +++ b/ckan/public/base/vendor/jquery.ui.sortable.js @@ -0,0 +1,1285 @@ +/*! + * jQuery UI Sortable 1.10.3 + * http://jqueryui.com + * + * Copyright 2013 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/sortable/ + * + * Depends: + * jquery.ui.core.js + * jquery.ui.mouse.js + * jquery.ui.widget.js + */ +(function( $, undefined ) { + +/*jshint loopfunc: true */ + +function isOverAxis( x, reference, size ) { + return ( x > reference ) && ( x < ( reference + size ) ); +} + +function isFloating(item) { + return (/left|right/).test(item.css("float")) || (/inline|table-cell/).test(item.css("display")); +} + +$.widget("ui.sortable", $.ui.mouse, { + version: "1.10.3", + widgetEventPrefix: "sort", + ready: false, + options: { + appendTo: "parent", + axis: false, + connectWith: false, + containment: false, + cursor: "auto", + cursorAt: false, + dropOnEmpty: true, + forcePlaceholderSize: false, + forceHelperSize: false, + grid: false, + handle: false, + helper: "original", + items: "> *", + opacity: false, + placeholder: false, + revert: false, + scroll: true, + scrollSensitivity: 20, + scrollSpeed: 20, + scope: "default", + tolerance: "intersect", + zIndex: 1000, + + // callbacks + activate: null, + beforeStop: null, + change: null, + deactivate: null, + out: null, + over: null, + receive: null, + remove: null, + sort: null, + start: null, + stop: null, + update: null + }, + _create: function() { + + var o = this.options; + this.containerCache = {}; + this.element.addClass("ui-sortable"); + + //Get the items + this.refresh(); + + //Let's determine if the items are being displayed horizontally + this.floating = this.items.length ? o.axis === "x" || isFloating(this.items[0].item) : false; + + //Let's determine the parent's offset + this.offset = this.element.offset(); + + //Initialize mouse events for interaction + this._mouseInit(); + + //We're ready to go + this.ready = true; + + }, + + _destroy: function() { + this.element + .removeClass("ui-sortable ui-sortable-disabled"); + this._mouseDestroy(); + + for ( var i = this.items.length - 1; i >= 0; i-- ) { + this.items[i].item.removeData(this.widgetName + "-item"); + } + + return this; + }, + + _setOption: function(key, value){ + if ( key === "disabled" ) { + this.options[ key ] = value; + + this.widget().toggleClass( "ui-sortable-disabled", !!value ); + } else { + // Don't call widget base _setOption for disable as it adds ui-state-disabled class + $.Widget.prototype._setOption.apply(this, arguments); + } + }, + + _mouseCapture: function(event, overrideHandle) { + var currentItem = null, + validHandle = false, + that = this; + + if (this.reverting) { + return false; + } + + if(this.options.disabled || this.options.type === "static") { + return false; + } + + //We have to refresh the items data once first + this._refreshItems(event); + + //Find out if the clicked node (or one of its parents) is a actual item in this.items + $(event.target).parents().each(function() { + if($.data(this, that.widgetName + "-item") === that) { + currentItem = $(this); + return false; + } + }); + if($.data(event.target, that.widgetName + "-item") === that) { + currentItem = $(event.target); + } + + if(!currentItem) { + return false; + } + if(this.options.handle && !overrideHandle) { + $(this.options.handle, currentItem).find("*").addBack().each(function() { + if(this === event.target) { + validHandle = true; + } + }); + if(!validHandle) { + return false; + } + } + + this.currentItem = currentItem; + this._removeCurrentsFromItems(); + return true; + + }, + + _mouseStart: function(event, overrideHandle, noActivation) { + + var i, body, + o = this.options; + + this.currentContainer = this; + + //We only need to call refreshPositions, because the refreshItems call has been moved to mouseCapture + this.refreshPositions(); + + //Create and append the visible helper + this.helper = this._createHelper(event); + + //Cache the helper size + this._cacheHelperProportions(); + + /* + * - Position generation - + * This block generates everything position related - it's the core of draggables. + */ + + //Cache the margins of the original element + this._cacheMargins(); + + //Get the next scrolling parent + this.scrollParent = this.helper.scrollParent(); + + //The element's absolute position on the page minus margins + this.offset = this.currentItem.offset(); + this.offset = { + top: this.offset.top - this.margins.top, + left: this.offset.left - this.margins.left + }; + + $.extend(this.offset, { + click: { //Where the click happened, relative to the element + left: event.pageX - this.offset.left, + top: event.pageY - this.offset.top + }, + parent: this._getParentOffset(), + relative: this._getRelativeOffset() //This is a relative to absolute position minus the actual position calculation - only used for relative positioned helper + }); + + // Only after we got the offset, we can change the helper's position to absolute + // TODO: Still need to figure out a way to make relative sorting possible + this.helper.css("position", "absolute"); + this.cssPosition = this.helper.css("position"); + + //Generate the original position + this.originalPosition = this._generatePosition(event); + this.originalPageX = event.pageX; + this.originalPageY = event.pageY; + + //Adjust the mouse offset relative to the helper if "cursorAt" is supplied + (o.cursorAt && this._adjustOffsetFromHelper(o.cursorAt)); + + //Cache the former DOM position + this.domPosition = { prev: this.currentItem.prev()[0], parent: this.currentItem.parent()[0] }; + + //If the helper is not the original, hide the original so it's not playing any role during the drag, won't cause anything bad this way + if(this.helper[0] !== this.currentItem[0]) { + this.currentItem.hide(); + } + + //Create the placeholder + this._createPlaceholder(); + + //Set a containment if given in the options + if(o.containment) { + this._setContainment(); + } + + if( o.cursor && o.cursor !== "auto" ) { // cursor option + body = this.document.find( "body" ); + + // support: IE + this.storedCursor = body.css( "cursor" ); + body.css( "cursor", o.cursor ); + + this.storedStylesheet = $( "" ).appendTo( body ); + } + + if(o.opacity) { // opacity option + if (this.helper.css("opacity")) { + this._storedOpacity = this.helper.css("opacity"); + } + this.helper.css("opacity", o.opacity); + } + + if(o.zIndex) { // zIndex option + if (this.helper.css("zIndex")) { + this._storedZIndex = this.helper.css("zIndex"); + } + this.helper.css("zIndex", o.zIndex); + } + + //Prepare scrolling + if(this.scrollParent[0] !== document && this.scrollParent[0].tagName !== "HTML") { + this.overflowOffset = this.scrollParent.offset(); + } + + //Call callbacks + this._trigger("start", event, this._uiHash()); + + //Recache the helper size + if(!this._preserveHelperProportions) { + this._cacheHelperProportions(); + } + + + //Post "activate" events to possible containers + if( !noActivation ) { + for ( i = this.containers.length - 1; i >= 0; i-- ) { + this.containers[ i ]._trigger( "activate", event, this._uiHash( this ) ); + } + } + + //Prepare possible droppables + if($.ui.ddmanager) { + $.ui.ddmanager.current = this; + } + + if ($.ui.ddmanager && !o.dropBehaviour) { + $.ui.ddmanager.prepareOffsets(this, event); + } + + this.dragging = true; + + this.helper.addClass("ui-sortable-helper"); + this._mouseDrag(event); //Execute the drag once - this causes the helper not to be visible before getting its correct position + return true; + + }, + + _mouseDrag: function(event) { + var i, item, itemElement, intersection, + o = this.options, + scrolled = false; + + //Compute the helpers position + this.position = this._generatePosition(event); + this.positionAbs = this._convertPositionTo("absolute"); + + if (!this.lastPositionAbs) { + this.lastPositionAbs = this.positionAbs; + } + + //Do scrolling + if(this.options.scroll) { + if(this.scrollParent[0] !== document && this.scrollParent[0].tagName !== "HTML") { + + if((this.overflowOffset.top + this.scrollParent[0].offsetHeight) - event.pageY < o.scrollSensitivity) { + this.scrollParent[0].scrollTop = scrolled = this.scrollParent[0].scrollTop + o.scrollSpeed; + } else if(event.pageY - this.overflowOffset.top < o.scrollSensitivity) { + this.scrollParent[0].scrollTop = scrolled = this.scrollParent[0].scrollTop - o.scrollSpeed; + } + + if((this.overflowOffset.left + this.scrollParent[0].offsetWidth) - event.pageX < o.scrollSensitivity) { + this.scrollParent[0].scrollLeft = scrolled = this.scrollParent[0].scrollLeft + o.scrollSpeed; + } else if(event.pageX - this.overflowOffset.left < o.scrollSensitivity) { + this.scrollParent[0].scrollLeft = scrolled = this.scrollParent[0].scrollLeft - o.scrollSpeed; + } + + } else { + + if(event.pageY - $(document).scrollTop() < o.scrollSensitivity) { + scrolled = $(document).scrollTop($(document).scrollTop() - o.scrollSpeed); + } else if($(window).height() - (event.pageY - $(document).scrollTop()) < o.scrollSensitivity) { + scrolled = $(document).scrollTop($(document).scrollTop() + o.scrollSpeed); + } + + if(event.pageX - $(document).scrollLeft() < o.scrollSensitivity) { + scrolled = $(document).scrollLeft($(document).scrollLeft() - o.scrollSpeed); + } else if($(window).width() - (event.pageX - $(document).scrollLeft()) < o.scrollSensitivity) { + scrolled = $(document).scrollLeft($(document).scrollLeft() + o.scrollSpeed); + } + + } + + if(scrolled !== false && $.ui.ddmanager && !o.dropBehaviour) { + $.ui.ddmanager.prepareOffsets(this, event); + } + } + + //Regenerate the absolute position used for position checks + this.positionAbs = this._convertPositionTo("absolute"); + + //Set the helper position + if(!this.options.axis || this.options.axis !== "y") { + this.helper[0].style.left = this.position.left+"px"; + } + if(!this.options.axis || this.options.axis !== "x") { + this.helper[0].style.top = this.position.top+"px"; + } + + //Rearrange + for (i = this.items.length - 1; i >= 0; i--) { + + //Cache variables and intersection, continue if no intersection + item = this.items[i]; + itemElement = item.item[0]; + intersection = this._intersectsWithPointer(item); + if (!intersection) { + continue; + } + + // Only put the placeholder inside the current Container, skip all + // items form other containers. This works because when moving + // an item from one container to another the + // currentContainer is switched before the placeholder is moved. + // + // Without this moving items in "sub-sortables" can cause the placeholder to jitter + // beetween the outer and inner container. + if (item.instance !== this.currentContainer) { + continue; + } + + // cannot intersect with itself + // no useless actions that have been done before + // no action if the item moved is the parent of the item checked + if (itemElement !== this.currentItem[0] && + this.placeholder[intersection === 1 ? "next" : "prev"]()[0] !== itemElement && + !$.contains(this.placeholder[0], itemElement) && + (this.options.type === "semi-dynamic" ? !$.contains(this.element[0], itemElement) : true) + ) { + + this.direction = intersection === 1 ? "down" : "up"; + + if (this.options.tolerance === "pointer" || this._intersectsWithSides(item)) { + this._rearrange(event, item); + } else { + break; + } + + this._trigger("change", event, this._uiHash()); + break; + } + } + + //Post events to containers + this._contactContainers(event); + + //Interconnect with droppables + if($.ui.ddmanager) { + $.ui.ddmanager.drag(this, event); + } + + //Call callbacks + this._trigger("sort", event, this._uiHash()); + + this.lastPositionAbs = this.positionAbs; + return false; + + }, + + _mouseStop: function(event, noPropagation) { + + if(!event) { + return; + } + + //If we are using droppables, inform the manager about the drop + if ($.ui.ddmanager && !this.options.dropBehaviour) { + $.ui.ddmanager.drop(this, event); + } + + if(this.options.revert) { + var that = this, + cur = this.placeholder.offset(), + axis = this.options.axis, + animation = {}; + + if ( !axis || axis === "x" ) { + animation.left = cur.left - this.offset.parent.left - this.margins.left + (this.offsetParent[0] === document.body ? 0 : this.offsetParent[0].scrollLeft); + } + if ( !axis || axis === "y" ) { + animation.top = cur.top - this.offset.parent.top - this.margins.top + (this.offsetParent[0] === document.body ? 0 : this.offsetParent[0].scrollTop); + } + this.reverting = true; + $(this.helper).animate( animation, parseInt(this.options.revert, 10) || 500, function() { + that._clear(event); + }); + } else { + this._clear(event, noPropagation); + } + + return false; + + }, + + cancel: function() { + + if(this.dragging) { + + this._mouseUp({ target: null }); + + if(this.options.helper === "original") { + this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper"); + } else { + this.currentItem.show(); + } + + //Post deactivating events to containers + for (var i = this.containers.length - 1; i >= 0; i--){ + this.containers[i]._trigger("deactivate", null, this._uiHash(this)); + if(this.containers[i].containerCache.over) { + this.containers[i]._trigger("out", null, this._uiHash(this)); + this.containers[i].containerCache.over = 0; + } + } + + } + + if (this.placeholder) { + //$(this.placeholder[0]).remove(); would have been the jQuery way - unfortunately, it unbinds ALL events from the original node! + if(this.placeholder[0].parentNode) { + this.placeholder[0].parentNode.removeChild(this.placeholder[0]); + } + if(this.options.helper !== "original" && this.helper && this.helper[0].parentNode) { + this.helper.remove(); + } + + $.extend(this, { + helper: null, + dragging: false, + reverting: false, + _noFinalSort: null + }); + + if(this.domPosition.prev) { + $(this.domPosition.prev).after(this.currentItem); + } else { + $(this.domPosition.parent).prepend(this.currentItem); + } + } + + return this; + + }, + + serialize: function(o) { + + var items = this._getItemsAsjQuery(o && o.connected), + str = []; + o = o || {}; + + $(items).each(function() { + var res = ($(o.item || this).attr(o.attribute || "id") || "").match(o.expression || (/(.+)[\-=_](.+)/)); + if (res) { + str.push((o.key || res[1]+"[]")+"="+(o.key && o.expression ? res[1] : res[2])); + } + }); + + if(!str.length && o.key) { + str.push(o.key + "="); + } + + return str.join("&"); + + }, + + toArray: function(o) { + + var items = this._getItemsAsjQuery(o && o.connected), + ret = []; + + o = o || {}; + + items.each(function() { ret.push($(o.item || this).attr(o.attribute || "id") || ""); }); + return ret; + + }, + + /* Be careful with the following core functions */ + _intersectsWith: function(item) { + + var x1 = this.positionAbs.left, + x2 = x1 + this.helperProportions.width, + y1 = this.positionAbs.top, + y2 = y1 + this.helperProportions.height, + l = item.left, + r = l + item.width, + t = item.top, + b = t + item.height, + dyClick = this.offset.click.top, + dxClick = this.offset.click.left, + isOverElementHeight = ( this.options.axis === "x" ) || ( ( y1 + dyClick ) > t && ( y1 + dyClick ) < b ), + isOverElementWidth = ( this.options.axis === "y" ) || ( ( x1 + dxClick ) > l && ( x1 + dxClick ) < r ), + isOverElement = isOverElementHeight && isOverElementWidth; + + if ( this.options.tolerance === "pointer" || + this.options.forcePointerForContainers || + (this.options.tolerance !== "pointer" && this.helperProportions[this.floating ? "width" : "height"] > item[this.floating ? "width" : "height"]) + ) { + return isOverElement; + } else { + + return (l < x1 + (this.helperProportions.width / 2) && // Right Half + x2 - (this.helperProportions.width / 2) < r && // Left Half + t < y1 + (this.helperProportions.height / 2) && // Bottom Half + y2 - (this.helperProportions.height / 2) < b ); // Top Half + + } + }, + + _intersectsWithPointer: function(item) { + + var isOverElementHeight = (this.options.axis === "x") || isOverAxis(this.positionAbs.top + this.offset.click.top, item.top, item.height), + isOverElementWidth = (this.options.axis === "y") || isOverAxis(this.positionAbs.left + this.offset.click.left, item.left, item.width), + isOverElement = isOverElementHeight && isOverElementWidth, + verticalDirection = this._getDragVerticalDirection(), + horizontalDirection = this._getDragHorizontalDirection(); + + if (!isOverElement) { + return false; + } + + return this.floating ? + ( ((horizontalDirection && horizontalDirection === "right") || verticalDirection === "down") ? 2 : 1 ) + : ( verticalDirection && (verticalDirection === "down" ? 2 : 1) ); + + }, + + _intersectsWithSides: function(item) { + + var isOverBottomHalf = isOverAxis(this.positionAbs.top + this.offset.click.top, item.top + (item.height/2), item.height), + isOverRightHalf = isOverAxis(this.positionAbs.left + this.offset.click.left, item.left + (item.width/2), item.width), + verticalDirection = this._getDragVerticalDirection(), + horizontalDirection = this._getDragHorizontalDirection(); + + if (this.floating && horizontalDirection) { + return ((horizontalDirection === "right" && isOverRightHalf) || (horizontalDirection === "left" && !isOverRightHalf)); + } else { + return verticalDirection && ((verticalDirection === "down" && isOverBottomHalf) || (verticalDirection === "up" && !isOverBottomHalf)); + } + + }, + + _getDragVerticalDirection: function() { + var delta = this.positionAbs.top - this.lastPositionAbs.top; + return delta !== 0 && (delta > 0 ? "down" : "up"); + }, + + _getDragHorizontalDirection: function() { + var delta = this.positionAbs.left - this.lastPositionAbs.left; + return delta !== 0 && (delta > 0 ? "right" : "left"); + }, + + refresh: function(event) { + this._refreshItems(event); + this.refreshPositions(); + return this; + }, + + _connectWith: function() { + var options = this.options; + return options.connectWith.constructor === String ? [options.connectWith] : options.connectWith; + }, + + _getItemsAsjQuery: function(connected) { + + var i, j, cur, inst, + items = [], + queries = [], + connectWith = this._connectWith(); + + if(connectWith && connected) { + for (i = connectWith.length - 1; i >= 0; i--){ + cur = $(connectWith[i]); + for ( j = cur.length - 1; j >= 0; j--){ + inst = $.data(cur[j], this.widgetFullName); + if(inst && inst !== this && !inst.options.disabled) { + queries.push([$.isFunction(inst.options.items) ? inst.options.items.call(inst.element) : $(inst.options.items, inst.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"), inst]); + } + } + } + } + + queries.push([$.isFunction(this.options.items) ? this.options.items.call(this.element, null, { options: this.options, item: this.currentItem }) : $(this.options.items, this.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"), this]); + + for (i = queries.length - 1; i >= 0; i--){ + queries[i][0].each(function() { + items.push(this); + }); + } + + return $(items); + + }, + + _removeCurrentsFromItems: function() { + + var list = this.currentItem.find(":data(" + this.widgetName + "-item)"); + + this.items = $.grep(this.items, function (item) { + for (var j=0; j < list.length; j++) { + if(list[j] === item.item[0]) { + return false; + } + } + return true; + }); + + }, + + _refreshItems: function(event) { + + this.items = []; + this.containers = [this]; + + var i, j, cur, inst, targetData, _queries, item, queriesLength, + items = this.items, + queries = [[$.isFunction(this.options.items) ? this.options.items.call(this.element[0], event, { item: this.currentItem }) : $(this.options.items, this.element), this]], + connectWith = this._connectWith(); + + if(connectWith && this.ready) { //Shouldn't be run the first time through due to massive slow-down + for (i = connectWith.length - 1; i >= 0; i--){ + cur = $(connectWith[i]); + for (j = cur.length - 1; j >= 0; j--){ + inst = $.data(cur[j], this.widgetFullName); + if(inst && inst !== this && !inst.options.disabled) { + queries.push([$.isFunction(inst.options.items) ? inst.options.items.call(inst.element[0], event, { item: this.currentItem }) : $(inst.options.items, inst.element), inst]); + this.containers.push(inst); + } + } + } + } + + for (i = queries.length - 1; i >= 0; i--) { + targetData = queries[i][1]; + _queries = queries[i][0]; + + for (j=0, queriesLength = _queries.length; j < queriesLength; j++) { + item = $(_queries[j]); + + item.data(this.widgetName + "-item", targetData); // Data for target checking (mouse manager) + + items.push({ + item: item, + instance: targetData, + width: 0, height: 0, + left: 0, top: 0 + }); + } + } + + }, + + refreshPositions: function(fast) { + + //This has to be redone because due to the item being moved out/into the offsetParent, the offsetParent's position will change + if(this.offsetParent && this.helper) { + this.offset.parent = this._getParentOffset(); + } + + var i, item, t, p; + + for (i = this.items.length - 1; i >= 0; i--){ + item = this.items[i]; + + //We ignore calculating positions of all connected containers when we're not over them + if(item.instance !== this.currentContainer && this.currentContainer && item.item[0] !== this.currentItem[0]) { + continue; + } + + t = this.options.toleranceElement ? $(this.options.toleranceElement, item.item) : item.item; + + if (!fast) { + item.width = t.outerWidth(); + item.height = t.outerHeight(); + } + + p = t.offset(); + item.left = p.left; + item.top = p.top; + } + + if(this.options.custom && this.options.custom.refreshContainers) { + this.options.custom.refreshContainers.call(this); + } else { + for (i = this.containers.length - 1; i >= 0; i--){ + p = this.containers[i].element.offset(); + this.containers[i].containerCache.left = p.left; + this.containers[i].containerCache.top = p.top; + this.containers[i].containerCache.width = this.containers[i].element.outerWidth(); + this.containers[i].containerCache.height = this.containers[i].element.outerHeight(); + } + } + + return this; + }, + + _createPlaceholder: function(that) { + that = that || this; + var className, + o = that.options; + + if(!o.placeholder || o.placeholder.constructor === String) { + className = o.placeholder; + o.placeholder = { + element: function() { + + var nodeName = that.currentItem[0].nodeName.toLowerCase(), + element = $( "<" + nodeName + ">", that.document[0] ) + .addClass(className || that.currentItem[0].className+" ui-sortable-placeholder") + .removeClass("ui-sortable-helper"); + + if ( nodeName === "tr" ) { + that.currentItem.children().each(function() { + $( " ", that.document[0] ) + .attr( "colspan", $( this ).attr( "colspan" ) || 1 ) + .appendTo( element ); + }); + } else if ( nodeName === "img" ) { + element.attr( "src", that.currentItem.attr( "src" ) ); + } + + if ( !className ) { + element.css( "visibility", "hidden" ); + } + + return element; + }, + update: function(container, p) { + + // 1. If a className is set as 'placeholder option, we don't force sizes - the class is responsible for that + // 2. The option 'forcePlaceholderSize can be enabled to force it even if a class name is specified + if(className && !o.forcePlaceholderSize) { + return; + } + + //If the element doesn't have a actual height by itself (without styles coming from a stylesheet), it receives the inline height from the dragged item + if(!p.height()) { p.height(that.currentItem.innerHeight() - parseInt(that.currentItem.css("paddingTop")||0, 10) - parseInt(that.currentItem.css("paddingBottom")||0, 10)); } + if(!p.width()) { p.width(that.currentItem.innerWidth() - parseInt(that.currentItem.css("paddingLeft")||0, 10) - parseInt(that.currentItem.css("paddingRight")||0, 10)); } + } + }; + } + + //Create the placeholder + that.placeholder = $(o.placeholder.element.call(that.element, that.currentItem)); + + //Append it after the actual current item + that.currentItem.after(that.placeholder); + + //Update the size of the placeholder (TODO: Logic to fuzzy, see line 316/317) + o.placeholder.update(that, that.placeholder); + + }, + + _contactContainers: function(event) { + var i, j, dist, itemWithLeastDistance, posProperty, sizeProperty, base, cur, nearBottom, floating, + innermostContainer = null, + innermostIndex = null; + + // get innermost container that intersects with item + for (i = this.containers.length - 1; i >= 0; i--) { + + // never consider a container that's located within the item itself + if($.contains(this.currentItem[0], this.containers[i].element[0])) { + continue; + } + + if(this._intersectsWith(this.containers[i].containerCache)) { + + // if we've already found a container and it's more "inner" than this, then continue + if(innermostContainer && $.contains(this.containers[i].element[0], innermostContainer.element[0])) { + continue; + } + + innermostContainer = this.containers[i]; + innermostIndex = i; + + } else { + // container doesn't intersect. trigger "out" event if necessary + if(this.containers[i].containerCache.over) { + this.containers[i]._trigger("out", event, this._uiHash(this)); + this.containers[i].containerCache.over = 0; + } + } + + } + + // if no intersecting containers found, return + if(!innermostContainer) { + return; + } + + // move the item into the container if it's not there already + if(this.containers.length === 1) { + if (!this.containers[innermostIndex].containerCache.over) { + this.containers[innermostIndex]._trigger("over", event, this._uiHash(this)); + this.containers[innermostIndex].containerCache.over = 1; + } + } else { + + //When entering a new container, we will find the item with the least distance and append our item near it + dist = 10000; + itemWithLeastDistance = null; + floating = innermostContainer.floating || isFloating(this.currentItem); + posProperty = floating ? "left" : "top"; + sizeProperty = floating ? "width" : "height"; + base = this.positionAbs[posProperty] + this.offset.click[posProperty]; + for (j = this.items.length - 1; j >= 0; j--) { + if(!$.contains(this.containers[innermostIndex].element[0], this.items[j].item[0])) { + continue; + } + if(this.items[j].item[0] === this.currentItem[0]) { + continue; + } + if (floating && !isOverAxis(this.positionAbs.top + this.offset.click.top, this.items[j].top, this.items[j].height)) { + continue; + } + cur = this.items[j].item.offset()[posProperty]; + nearBottom = false; + if(Math.abs(cur - base) > Math.abs(cur + this.items[j][sizeProperty] - base)){ + nearBottom = true; + cur += this.items[j][sizeProperty]; + } + + if(Math.abs(cur - base) < dist) { + dist = Math.abs(cur - base); itemWithLeastDistance = this.items[j]; + this.direction = nearBottom ? "up": "down"; + } + } + + //Check if dropOnEmpty is enabled + if(!itemWithLeastDistance && !this.options.dropOnEmpty) { + return; + } + + if(this.currentContainer === this.containers[innermostIndex]) { + return; + } + + itemWithLeastDistance ? this._rearrange(event, itemWithLeastDistance, null, true) : this._rearrange(event, null, this.containers[innermostIndex].element, true); + this._trigger("change", event, this._uiHash()); + this.containers[innermostIndex]._trigger("change", event, this._uiHash(this)); + this.currentContainer = this.containers[innermostIndex]; + + //Update the placeholder + this.options.placeholder.update(this.currentContainer, this.placeholder); + + this.containers[innermostIndex]._trigger("over", event, this._uiHash(this)); + this.containers[innermostIndex].containerCache.over = 1; + } + + + }, + + _createHelper: function(event) { + + var o = this.options, + helper = $.isFunction(o.helper) ? $(o.helper.apply(this.element[0], [event, this.currentItem])) : (o.helper === "clone" ? this.currentItem.clone() : this.currentItem); + + //Add the helper to the DOM if that didn't happen already + if(!helper.parents("body").length) { + $(o.appendTo !== "parent" ? o.appendTo : this.currentItem[0].parentNode)[0].appendChild(helper[0]); + } + + if(helper[0] === this.currentItem[0]) { + this._storedCSS = { width: this.currentItem[0].style.width, height: this.currentItem[0].style.height, position: this.currentItem.css("position"), top: this.currentItem.css("top"), left: this.currentItem.css("left") }; + } + + if(!helper[0].style.width || o.forceHelperSize) { + helper.width(this.currentItem.width()); + } + if(!helper[0].style.height || o.forceHelperSize) { + helper.height(this.currentItem.height()); + } + + return helper; + + }, + + _adjustOffsetFromHelper: function(obj) { + if (typeof obj === "string") { + obj = obj.split(" "); + } + if ($.isArray(obj)) { + obj = {left: +obj[0], top: +obj[1] || 0}; + } + if ("left" in obj) { + this.offset.click.left = obj.left + this.margins.left; + } + if ("right" in obj) { + this.offset.click.left = this.helperProportions.width - obj.right + this.margins.left; + } + if ("top" in obj) { + this.offset.click.top = obj.top + this.margins.top; + } + if ("bottom" in obj) { + this.offset.click.top = this.helperProportions.height - obj.bottom + this.margins.top; + } + }, + + _getParentOffset: function() { + + + //Get the offsetParent and cache its position + this.offsetParent = this.helper.offsetParent(); + var po = this.offsetParent.offset(); + + // This is a special case where we need to modify a offset calculated on start, since the following happened: + // 1. The position of the helper is absolute, so it's position is calculated based on the next positioned parent + // 2. The actual offset parent is a child of the scroll parent, and the scroll parent isn't the document, which means that + // the scroll is included in the initial calculation of the offset of the parent, and never recalculated upon drag + if(this.cssPosition === "absolute" && this.scrollParent[0] !== document && $.contains(this.scrollParent[0], this.offsetParent[0])) { + po.left += this.scrollParent.scrollLeft(); + po.top += this.scrollParent.scrollTop(); + } + + // This needs to be actually done for all browsers, since pageX/pageY includes this information + // with an ugly IE fix + if( this.offsetParent[0] === document.body || (this.offsetParent[0].tagName && this.offsetParent[0].tagName.toLowerCase() === "html" && $.ui.ie)) { + po = { top: 0, left: 0 }; + } + + return { + top: po.top + (parseInt(this.offsetParent.css("borderTopWidth"),10) || 0), + left: po.left + (parseInt(this.offsetParent.css("borderLeftWidth"),10) || 0) + }; + + }, + + _getRelativeOffset: function() { + + if(this.cssPosition === "relative") { + var p = this.currentItem.position(); + return { + top: p.top - (parseInt(this.helper.css("top"),10) || 0) + this.scrollParent.scrollTop(), + left: p.left - (parseInt(this.helper.css("left"),10) || 0) + this.scrollParent.scrollLeft() + }; + } else { + return { top: 0, left: 0 }; + } + + }, + + _cacheMargins: function() { + this.margins = { + left: (parseInt(this.currentItem.css("marginLeft"),10) || 0), + top: (parseInt(this.currentItem.css("marginTop"),10) || 0) + }; + }, + + _cacheHelperProportions: function() { + this.helperProportions = { + width: this.helper.outerWidth(), + height: this.helper.outerHeight() + }; + }, + + _setContainment: function() { + + var ce, co, over, + o = this.options; + if(o.containment === "parent") { + o.containment = this.helper[0].parentNode; + } + if(o.containment === "document" || o.containment === "window") { + this.containment = [ + 0 - this.offset.relative.left - this.offset.parent.left, + 0 - this.offset.relative.top - this.offset.parent.top, + $(o.containment === "document" ? document : window).width() - this.helperProportions.width - this.margins.left, + ($(o.containment === "document" ? document : window).height() || document.body.parentNode.scrollHeight) - this.helperProportions.height - this.margins.top + ]; + } + + if(!(/^(document|window|parent)$/).test(o.containment)) { + ce = $(o.containment)[0]; + co = $(o.containment).offset(); + over = ($(ce).css("overflow") !== "hidden"); + + this.containment = [ + co.left + (parseInt($(ce).css("borderLeftWidth"),10) || 0) + (parseInt($(ce).css("paddingLeft"),10) || 0) - this.margins.left, + co.top + (parseInt($(ce).css("borderTopWidth"),10) || 0) + (parseInt($(ce).css("paddingTop"),10) || 0) - this.margins.top, + co.left+(over ? Math.max(ce.scrollWidth,ce.offsetWidth) : ce.offsetWidth) - (parseInt($(ce).css("borderLeftWidth"),10) || 0) - (parseInt($(ce).css("paddingRight"),10) || 0) - this.helperProportions.width - this.margins.left, + co.top+(over ? Math.max(ce.scrollHeight,ce.offsetHeight) : ce.offsetHeight) - (parseInt($(ce).css("borderTopWidth"),10) || 0) - (parseInt($(ce).css("paddingBottom"),10) || 0) - this.helperProportions.height - this.margins.top + ]; + } + + }, + + _convertPositionTo: function(d, pos) { + + if(!pos) { + pos = this.position; + } + var mod = d === "absolute" ? 1 : -1, + scroll = this.cssPosition === "absolute" && !(this.scrollParent[0] !== document && $.contains(this.scrollParent[0], this.offsetParent[0])) ? this.offsetParent : this.scrollParent, + scrollIsRootNode = (/(html|body)/i).test(scroll[0].tagName); + + return { + top: ( + pos.top + // The absolute mouse position + this.offset.relative.top * mod + // Only for relative positioned nodes: Relative offset from element to offset parent + this.offset.parent.top * mod - // The offsetParent's offset without borders (offset + border) + ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollTop() : ( scrollIsRootNode ? 0 : scroll.scrollTop() ) ) * mod) + ), + left: ( + pos.left + // The absolute mouse position + this.offset.relative.left * mod + // Only for relative positioned nodes: Relative offset from element to offset parent + this.offset.parent.left * mod - // The offsetParent's offset without borders (offset + border) + ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollLeft() : scrollIsRootNode ? 0 : scroll.scrollLeft() ) * mod) + ) + }; + + }, + + _generatePosition: function(event) { + + var top, left, + o = this.options, + pageX = event.pageX, + pageY = event.pageY, + scroll = this.cssPosition === "absolute" && !(this.scrollParent[0] !== document && $.contains(this.scrollParent[0], this.offsetParent[0])) ? this.offsetParent : this.scrollParent, scrollIsRootNode = (/(html|body)/i).test(scroll[0].tagName); + + // This is another very weird special case that only happens for relative elements: + // 1. If the css position is relative + // 2. and the scroll parent is the document or similar to the offset parent + // we have to refresh the relative offset during the scroll so there are no jumps + if(this.cssPosition === "relative" && !(this.scrollParent[0] !== document && this.scrollParent[0] !== this.offsetParent[0])) { + this.offset.relative = this._getRelativeOffset(); + } + + /* + * - Position constraining - + * Constrain the position to a mix of grid, containment. + */ + + if(this.originalPosition) { //If we are not dragging yet, we won't check for options + + if(this.containment) { + if(event.pageX - this.offset.click.left < this.containment[0]) { + pageX = this.containment[0] + this.offset.click.left; + } + if(event.pageY - this.offset.click.top < this.containment[1]) { + pageY = this.containment[1] + this.offset.click.top; + } + if(event.pageX - this.offset.click.left > this.containment[2]) { + pageX = this.containment[2] + this.offset.click.left; + } + if(event.pageY - this.offset.click.top > this.containment[3]) { + pageY = this.containment[3] + this.offset.click.top; + } + } + + if(o.grid) { + top = this.originalPageY + Math.round((pageY - this.originalPageY) / o.grid[1]) * o.grid[1]; + pageY = this.containment ? ( (top - this.offset.click.top >= this.containment[1] && top - this.offset.click.top <= this.containment[3]) ? top : ((top - this.offset.click.top >= this.containment[1]) ? top - o.grid[1] : top + o.grid[1])) : top; + + left = this.originalPageX + Math.round((pageX - this.originalPageX) / o.grid[0]) * o.grid[0]; + pageX = this.containment ? ( (left - this.offset.click.left >= this.containment[0] && left - this.offset.click.left <= this.containment[2]) ? left : ((left - this.offset.click.left >= this.containment[0]) ? left - o.grid[0] : left + o.grid[0])) : left; + } + + } + + return { + top: ( + pageY - // The absolute mouse position + this.offset.click.top - // Click offset (relative to the element) + this.offset.relative.top - // Only for relative positioned nodes: Relative offset from element to offset parent + this.offset.parent.top + // The offsetParent's offset without borders (offset + border) + ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollTop() : ( scrollIsRootNode ? 0 : scroll.scrollTop() ) )) + ), + left: ( + pageX - // The absolute mouse position + this.offset.click.left - // Click offset (relative to the element) + this.offset.relative.left - // Only for relative positioned nodes: Relative offset from element to offset parent + this.offset.parent.left + // The offsetParent's offset without borders (offset + border) + ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollLeft() : scrollIsRootNode ? 0 : scroll.scrollLeft() )) + ) + }; + + }, + + _rearrange: function(event, i, a, hardRefresh) { + + a ? a[0].appendChild(this.placeholder[0]) : i.item[0].parentNode.insertBefore(this.placeholder[0], (this.direction === "down" ? i.item[0] : i.item[0].nextSibling)); + + //Various things done here to improve the performance: + // 1. we create a setTimeout, that calls refreshPositions + // 2. on the instance, we have a counter variable, that get's higher after every append + // 3. on the local scope, we copy the counter variable, and check in the timeout, if it's still the same + // 4. this lets only the last addition to the timeout stack through + this.counter = this.counter ? ++this.counter : 1; + var counter = this.counter; + + this._delay(function() { + if(counter === this.counter) { + this.refreshPositions(!hardRefresh); //Precompute after each DOM insertion, NOT on mousemove + } + }); + + }, + + _clear: function(event, noPropagation) { + + this.reverting = false; + // We delay all events that have to be triggered to after the point where the placeholder has been removed and + // everything else normalized again + var i, + delayedTriggers = []; + + // We first have to update the dom position of the actual currentItem + // Note: don't do it if the current item is already removed (by a user), or it gets reappended (see #4088) + if(!this._noFinalSort && this.currentItem.parent().length) { + this.placeholder.before(this.currentItem); + } + this._noFinalSort = null; + + if(this.helper[0] === this.currentItem[0]) { + for(i in this._storedCSS) { + if(this._storedCSS[i] === "auto" || this._storedCSS[i] === "static") { + this._storedCSS[i] = ""; + } + } + this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper"); + } else { + this.currentItem.show(); + } + + if(this.fromOutside && !noPropagation) { + delayedTriggers.push(function(event) { this._trigger("receive", event, this._uiHash(this.fromOutside)); }); + } + if((this.fromOutside || this.domPosition.prev !== this.currentItem.prev().not(".ui-sortable-helper")[0] || this.domPosition.parent !== this.currentItem.parent()[0]) && !noPropagation) { + delayedTriggers.push(function(event) { this._trigger("update", event, this._uiHash()); }); //Trigger update callback if the DOM position has changed + } + + // Check if the items Container has Changed and trigger appropriate + // events. + if (this !== this.currentContainer) { + if(!noPropagation) { + delayedTriggers.push(function(event) { this._trigger("remove", event, this._uiHash()); }); + delayedTriggers.push((function(c) { return function(event) { c._trigger("receive", event, this._uiHash(this)); }; }).call(this, this.currentContainer)); + delayedTriggers.push((function(c) { return function(event) { c._trigger("update", event, this._uiHash(this)); }; }).call(this, this.currentContainer)); + } + } + + + //Post events to containers + for (i = this.containers.length - 1; i >= 0; i--){ + if(!noPropagation) { + delayedTriggers.push((function(c) { return function(event) { c._trigger("deactivate", event, this._uiHash(this)); }; }).call(this, this.containers[i])); + } + if(this.containers[i].containerCache.over) { + delayedTriggers.push((function(c) { return function(event) { c._trigger("out", event, this._uiHash(this)); }; }).call(this, this.containers[i])); + this.containers[i].containerCache.over = 0; + } + } + + //Do what was originally in plugins + if ( this.storedCursor ) { + this.document.find( "body" ).css( "cursor", this.storedCursor ); + this.storedStylesheet.remove(); + } + if(this._storedOpacity) { + this.helper.css("opacity", this._storedOpacity); + } + if(this._storedZIndex) { + this.helper.css("zIndex", this._storedZIndex === "auto" ? "" : this._storedZIndex); + } + + this.dragging = false; + if(this.cancelHelperRemoval) { + if(!noPropagation) { + this._trigger("beforeStop", event, this._uiHash()); + for (i=0; i < delayedTriggers.length; i++) { + delayedTriggers[i].call(this, event); + } //Trigger all delayed events + this._trigger("stop", event, this._uiHash()); + } + + this.fromOutside = false; + return false; + } + + if(!noPropagation) { + this._trigger("beforeStop", event, this._uiHash()); + } + + //$(this.placeholder[0]).remove(); would have been the jQuery way - unfortunately, it unbinds ALL events from the original node! + this.placeholder[0].parentNode.removeChild(this.placeholder[0]); + + if(this.helper[0] !== this.currentItem[0]) { + this.helper.remove(); + } + this.helper = null; + + if(!noPropagation) { + for (i=0; i < delayedTriggers.length; i++) { + delayedTriggers[i].call(this, event); + } //Trigger all delayed events + this._trigger("stop", event, this._uiHash()); + } + + this.fromOutside = false; + return true; + + }, + + _trigger: function() { + if ($.Widget.prototype._trigger.apply(this, arguments) === false) { + this.cancel(); + } + }, + + _uiHash: function(_inst) { + var inst = _inst || this; + return { + helper: inst.helper, + placeholder: inst.placeholder || $([]), + position: inst.position, + originalPosition: inst.originalPosition, + offset: inst.positionAbs, + item: inst.currentItem, + sender: _inst ? _inst.element : null + }; + } + +}); + +})(jQuery); diff --git a/ckan/public/base/vendor/jquery.ui.widget.js b/ckan/public/base/vendor/jquery.ui.widget.js old mode 100644 new mode 100755 index 9da8673a58b..916a6ad73c1 --- a/ckan/public/base/vendor/jquery.ui.widget.js +++ b/ckan/public/base/vendor/jquery.ui.widget.js @@ -1,58 +1,35 @@ -/* - * jQuery UI Widget 1.8.18+amd - * https://github.com/blueimp/jQuery-File-Upload +/*! + * jQuery UI Widget 1.10.3 + * http://jqueryui.com * - * Copyright 2011, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. + * Copyright 2013 jQuery Foundation and other contributors + * Released under the MIT license. * http://jquery.org/license * - * http://docs.jquery.com/UI/Widget + * http://api.jqueryui.com/jQuery.widget/ */ - -(function (factory) { - if (typeof define === "function" && define.amd) { - // Register as an anonymous AMD module: - define(["jquery"], factory); - } else { - // Browser globals: - factory(jQuery); - } -}(function( $, undefined ) { - -// jQuery 1.4+ -if ( $.cleanData ) { - var _cleanData = $.cleanData; - $.cleanData = function( elems ) { - for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { - try { - $( elem ).triggerHandler( "remove" ); - // http://bugs.jquery.com/ticket/8235 - } catch( e ) {} - } - _cleanData( elems ); - }; -} else { - var _remove = $.fn.remove; - $.fn.remove = function( selector, keepData ) { - return this.each(function() { - if ( !keepData ) { - if ( !selector || $.filter( selector, [ this ] ).length ) { - $( "*", this ).add( [ this ] ).each(function() { - try { - $( this ).triggerHandler( "remove" ); - // http://bugs.jquery.com/ticket/8235 - } catch( e ) {} - }); - } - } - return _remove.call( $(this), selector, keepData ); - }); - }; -} +(function( $, undefined ) { + +var uuid = 0, + slice = Array.prototype.slice, + _cleanData = $.cleanData; +$.cleanData = function( elems ) { + for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { + try { + $( elem ).triggerHandler( "remove" ); + // http://bugs.jquery.com/ticket/8235 + } catch( e ) {} + } + _cleanData( elems ); +}; $.widget = function( name, base, prototype ) { - var namespace = name.split( "." )[ 0 ], - fullName; + var fullName, existingConstructor, constructor, basePrototype, + // proxiedPrototype allows the provided prototype to remain unmodified + // so that it can be used as a mixin for multiple widgets (#8876) + proxiedPrototype = {}, + namespace = name.split( "." )[ 0 ]; + name = name.split( "." )[ 1 ]; fullName = namespace + "-" + name; @@ -62,81 +39,167 @@ $.widget = function( name, base, prototype ) { } // create selector for plugin - $.expr[ ":" ][ fullName ] = function( elem ) { - return !!$.data( elem, name ); + $.expr[ ":" ][ fullName.toLowerCase() ] = function( elem ) { + return !!$.data( elem, fullName ); }; $[ namespace ] = $[ namespace ] || {}; - $[ namespace ][ name ] = function( options, element ) { + existingConstructor = $[ namespace ][ name ]; + constructor = $[ namespace ][ name ] = function( options, element ) { + // allow instantiation without "new" keyword + if ( !this._createWidget ) { + return new constructor( options, element ); + } + // allow instantiation without initializing for simple inheritance + // must use "new" keyword (the code above always passes args) if ( arguments.length ) { this._createWidget( options, element ); } }; - - var basePrototype = new base(); + // extend with the existing constructor to carry over any static properties + $.extend( constructor, existingConstructor, { + version: prototype.version, + // copy the object used to create the prototype in case we need to + // redefine the widget later + _proto: $.extend( {}, prototype ), + // track widgets that inherit from this widget in case this widget is + // redefined after a widget inherits from it + _childConstructors: [] + }); + + basePrototype = new base(); // we need to make the options hash a property directly on the new instance // otherwise we'll modify the options hash on the prototype that we're // inheriting from -// $.each( basePrototype, function( key, val ) { -// if ( $.isPlainObject(val) ) { -// basePrototype[ key ] = $.extend( {}, val ); -// } -// }); - basePrototype.options = $.extend( true, {}, basePrototype.options ); - $[ namespace ][ name ].prototype = $.extend( true, basePrototype, { + basePrototype.options = $.widget.extend( {}, basePrototype.options ); + $.each( prototype, function( prop, value ) { + if ( !$.isFunction( value ) ) { + proxiedPrototype[ prop ] = value; + return; + } + proxiedPrototype[ prop ] = (function() { + var _super = function() { + return base.prototype[ prop ].apply( this, arguments ); + }, + _superApply = function( args ) { + return base.prototype[ prop ].apply( this, args ); + }; + return function() { + var __super = this._super, + __superApply = this._superApply, + returnValue; + + this._super = _super; + this._superApply = _superApply; + + returnValue = value.apply( this, arguments ); + + this._super = __super; + this._superApply = __superApply; + + return returnValue; + }; + })(); + }); + constructor.prototype = $.widget.extend( basePrototype, { + // TODO: remove support for widgetEventPrefix + // always use the name + a colon as the prefix, e.g., draggable:start + // don't prefix for widgets that aren't DOM-based + widgetEventPrefix: existingConstructor ? basePrototype.widgetEventPrefix : name + }, proxiedPrototype, { + constructor: constructor, namespace: namespace, widgetName: name, - widgetEventPrefix: $[ namespace ][ name ].prototype.widgetEventPrefix || name, - widgetBaseClass: fullName - }, prototype ); + widgetFullName: fullName + }); + + // If this widget is being redefined then we need to find all widgets that + // are inheriting from it and redefine all of them so that they inherit from + // the new version of this widget. We're essentially trying to replace one + // level in the prototype chain. + if ( existingConstructor ) { + $.each( existingConstructor._childConstructors, function( i, child ) { + var childPrototype = child.prototype; + + // redefine the child widget using the same prototype that was + // originally used, but inherit from the new version of the base + $.widget( childPrototype.namespace + "." + childPrototype.widgetName, constructor, child._proto ); + }); + // remove the list of existing child constructors from the old constructor + // so the old child constructors can be garbage collected + delete existingConstructor._childConstructors; + } else { + base._childConstructors.push( constructor ); + } - $.widget.bridge( name, $[ namespace ][ name ] ); + $.widget.bridge( name, constructor ); +}; + +$.widget.extend = function( target ) { + var input = slice.call( arguments, 1 ), + inputIndex = 0, + inputLength = input.length, + key, + value; + for ( ; inputIndex < inputLength; inputIndex++ ) { + for ( key in input[ inputIndex ] ) { + value = input[ inputIndex ][ key ]; + if ( input[ inputIndex ].hasOwnProperty( key ) && value !== undefined ) { + // Clone objects + if ( $.isPlainObject( value ) ) { + target[ key ] = $.isPlainObject( target[ key ] ) ? + $.widget.extend( {}, target[ key ], value ) : + // Don't extend strings, arrays, etc. with objects + $.widget.extend( {}, value ); + // Copy everything else by reference + } else { + target[ key ] = value; + } + } + } + } + return target; }; $.widget.bridge = function( name, object ) { + var fullName = object.prototype.widgetFullName || name; $.fn[ name ] = function( options ) { var isMethodCall = typeof options === "string", - args = Array.prototype.slice.call( arguments, 1 ), + args = slice.call( arguments, 1 ), returnValue = this; // allow multiple hashes to be passed on init options = !isMethodCall && args.length ? - $.extend.apply( null, [ true, options ].concat(args) ) : + $.widget.extend.apply( null, [ options ].concat(args) ) : options; - // prevent calls to internal methods - if ( isMethodCall && options.charAt( 0 ) === "_" ) { - return returnValue; - } - if ( isMethodCall ) { this.each(function() { - var instance = $.data( this, name ), - methodValue = instance && $.isFunction( instance[options] ) ? - instance[ options ].apply( instance, args ) : - instance; - // TODO: add this back in 1.9 and use $.error() (see #5972) -// if ( !instance ) { -// throw "cannot call methods on " + name + " prior to initialization; " + -// "attempted to call method '" + options + "'"; -// } -// if ( !$.isFunction( instance[options] ) ) { -// throw "no such method '" + options + "' for " + name + " widget instance"; -// } -// var methodValue = instance[ options ].apply( instance, args ); + var methodValue, + instance = $.data( this, fullName ); + if ( !instance ) { + return $.error( "cannot call methods on " + name + " prior to initialization; " + + "attempted to call method '" + options + "'" ); + } + if ( !$.isFunction( instance[options] ) || options.charAt( 0 ) === "_" ) { + return $.error( "no such method '" + options + "' for " + name + " widget instance" ); + } + methodValue = instance[ options ].apply( instance, args ); if ( methodValue !== instance && methodValue !== undefined ) { - returnValue = methodValue; + returnValue = methodValue && methodValue.jquery ? + returnValue.pushStack( methodValue.get() ) : + methodValue; return false; } }); } else { this.each(function() { - var instance = $.data( this, name ); + var instance = $.data( this, fullName ); if ( instance ) { instance.option( options || {} )._init(); } else { - $.data( this, name, new object( options, this ) ); + $.data( this, fullName, new object( options, this ) ); } }); } @@ -145,74 +208,123 @@ $.widget.bridge = function( name, object ) { }; }; -$.Widget = function( options, element ) { - // allow instantiation without initializing for simple inheritance - if ( arguments.length ) { - this._createWidget( options, element ); - } -}; +$.Widget = function( /* options, element */ ) {}; +$.Widget._childConstructors = []; $.Widget.prototype = { widgetName: "widget", widgetEventPrefix: "", + defaultElement: "
    ", options: { - disabled: false + disabled: false, + + // callbacks + create: null }, _createWidget: function( options, element ) { - // $.widget.bridge stores the plugin instance, but we do it anyway - // so that it's stored even before the _create function runs - $.data( element, this.widgetName, this ); + element = $( element || this.defaultElement || this )[ 0 ]; this.element = $( element ); - this.options = $.extend( true, {}, + this.uuid = uuid++; + this.eventNamespace = "." + this.widgetName + this.uuid; + this.options = $.widget.extend( {}, this.options, this._getCreateOptions(), options ); - var self = this; - this.element.bind( "remove." + this.widgetName, function() { - self.destroy(); - }); + this.bindings = $(); + this.hoverable = $(); + this.focusable = $(); + + if ( element !== this ) { + $.data( element, this.widgetFullName, this ); + this._on( true, this.element, { + remove: function( event ) { + if ( event.target === element ) { + this.destroy(); + } + } + }); + this.document = $( element.style ? + // element within the document + element.ownerDocument : + // element is window or document + element.document || element ); + this.window = $( this.document[0].defaultView || this.document[0].parentWindow ); + } this._create(); - this._trigger( "create" ); + this._trigger( "create", null, this._getCreateEventData() ); this._init(); }, - _getCreateOptions: function() { - return $.metadata && $.metadata.get( this.element[0] )[ this.widgetName ]; - }, - _create: function() {}, - _init: function() {}, + _getCreateOptions: $.noop, + _getCreateEventData: $.noop, + _create: $.noop, + _init: $.noop, destroy: function() { + this._destroy(); + // we can probably remove the unbind calls in 2.0 + // all event bindings should go through this._on() this.element - .unbind( "." + this.widgetName ) - .removeData( this.widgetName ); + .unbind( this.eventNamespace ) + // 1.9 BC for #7810 + // TODO remove dual storage + .removeData( this.widgetName ) + .removeData( this.widgetFullName ) + // support: jquery <1.6.3 + // http://bugs.jquery.com/ticket/9413 + .removeData( $.camelCase( this.widgetFullName ) ); this.widget() - .unbind( "." + this.widgetName ) + .unbind( this.eventNamespace ) .removeAttr( "aria-disabled" ) .removeClass( - this.widgetBaseClass + "-disabled " + + this.widgetFullName + "-disabled " + "ui-state-disabled" ); + + // clean up events and states + this.bindings.unbind( this.eventNamespace ); + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); }, + _destroy: $.noop, widget: function() { return this.element; }, option: function( key, value ) { - var options = key; + var options = key, + parts, + curOption, + i; if ( arguments.length === 0 ) { // don't return a reference to the internal hash - return $.extend( {}, this.options ); + return $.widget.extend( {}, this.options ); } - if (typeof key === "string" ) { - if ( value === undefined ) { - return this.options[ key ]; - } + if ( typeof key === "string" ) { + // handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } } options = {}; - options[ key ] = value; + parts = key.split( "." ); + key = parts.shift(); + if ( parts.length ) { + curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] ); + for ( i = 0; i < parts.length - 1; i++ ) { + curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {}; + curOption = curOption[ parts[ i ] ]; + } + key = parts.pop(); + if ( value === undefined ) { + return curOption[ key ] === undefined ? null : curOption[ key ]; + } + curOption[ key ] = value; + } else { + if ( value === undefined ) { + return this.options[ key ] === undefined ? null : this.options[ key ]; + } + options[ key ] = value; + } } this._setOptions( options ); @@ -220,10 +332,11 @@ $.Widget.prototype = { return this; }, _setOptions: function( options ) { - var self = this; - $.each( options, function( key, value ) { - self._setOption( key, value ); - }); + var key; + + for ( key in options ) { + this._setOption( key, options[ key ] ); + } return this; }, @@ -232,10 +345,10 @@ $.Widget.prototype = { if ( key === "disabled" ) { this.widget() - [ value ? "addClass" : "removeClass"]( - this.widgetBaseClass + "-disabled" + " " + - "ui-state-disabled" ) + .toggleClass( this.widgetFullName + "-disabled ui-state-disabled", !!value ) .attr( "aria-disabled", value ); + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); } return this; @@ -248,6 +361,97 @@ $.Widget.prototype = { return this._setOption( "disabled", true ); }, + _on: function( suppressDisabledCheck, element, handlers ) { + var delegateElement, + instance = this; + + // no suppressDisabledCheck flag, shuffle arguments + if ( typeof suppressDisabledCheck !== "boolean" ) { + handlers = element; + element = suppressDisabledCheck; + suppressDisabledCheck = false; + } + + // no element argument, shuffle and use this.element + if ( !handlers ) { + handlers = element; + element = this.element; + delegateElement = this.widget(); + } else { + // accept selectors, DOM elements + element = delegateElement = $( element ); + this.bindings = this.bindings.add( element ); + } + + $.each( handlers, function( event, handler ) { + function handlerProxy() { + // allow widgets to customize the disabled handling + // - disabled as an array instead of boolean + // - disabled class as method for disabling individual parts + if ( !suppressDisabledCheck && + ( instance.options.disabled === true || + $( this ).hasClass( "ui-state-disabled" ) ) ) { + return; + } + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + + // copy the guid so direct unbinding works + if ( typeof handler !== "string" ) { + handlerProxy.guid = handler.guid = + handler.guid || handlerProxy.guid || $.guid++; + } + + var match = event.match( /^(\w+)\s*(.*)$/ ), + eventName = match[1] + instance.eventNamespace, + selector = match[2]; + if ( selector ) { + delegateElement.delegate( selector, eventName, handlerProxy ); + } else { + element.bind( eventName, handlerProxy ); + } + }); + }, + + _off: function( element, eventName ) { + eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) + this.eventNamespace; + element.unbind( eventName ).undelegate( eventName ); + }, + + _delay: function( handler, delay ) { + function handlerProxy() { + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + var instance = this; + return setTimeout( handlerProxy, delay || 0 ); + }, + + _hoverable: function( element ) { + this.hoverable = this.hoverable.add( element ); + this._on( element, { + mouseenter: function( event ) { + $( event.currentTarget ).addClass( "ui-state-hover" ); + }, + mouseleave: function( event ) { + $( event.currentTarget ).removeClass( "ui-state-hover" ); + } + }); + }, + + _focusable: function( element ) { + this.focusable = this.focusable.add( element ); + this._on( element, { + focusin: function( event ) { + $( event.currentTarget ).addClass( "ui-state-focus" ); + }, + focusout: function( event ) { + $( event.currentTarget ).removeClass( "ui-state-focus" ); + } + }); + }, + _trigger: function( type, event, data ) { var prop, orig, callback = this.options[ type ]; @@ -272,11 +476,46 @@ $.Widget.prototype = { } this.element.trigger( event, data ); - - return !( $.isFunction(callback) && - callback.call( this.element[0], event, data ) === false || + return !( $.isFunction( callback ) && + callback.apply( this.element[0], [ event ].concat( data ) ) === false || event.isDefaultPrevented() ); } }; -})); +$.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) { + $.Widget.prototype[ "_" + method ] = function( element, options, callback ) { + if ( typeof options === "string" ) { + options = { effect: options }; + } + var hasOptions, + effectName = !options ? + method : + options === true || typeof options === "number" ? + defaultEffect : + options.effect || defaultEffect; + options = options || {}; + if ( typeof options === "number" ) { + options = { duration: options }; + } + hasOptions = !$.isEmptyObject( options ); + options.complete = callback; + if ( options.delay ) { + element.delay( options.delay ); + } + if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) { + element[ method ]( options ); + } else if ( effectName !== method && element[ effectName ] ) { + element[ effectName ]( options.duration, options.easing, callback ); + } else { + element.queue(function( next ) { + $( this )[ method ](); + if ( callback ) { + callback.call( element[ 0 ] ); + } + next(); + }); + } + }; +}); + +})( jQuery ); diff --git a/ckan/public/base/vendor/jquery.ui.widget.min.js b/ckan/public/base/vendor/jquery.ui.widget.min.js deleted file mode 100644 index 12d47c2f79c..00000000000 --- a/ckan/public/base/vendor/jquery.ui.widget.min.js +++ /dev/null @@ -1,13 +0,0 @@ -(function(factory){if(typeof define==="function"&&define.amd){define(["jquery"],factory);}else{factory(jQuery);}}(function($,undefined){if($.cleanData){var _cleanData=$.cleanData;$.cleanData=function(elems){for(var i=0,elem;(elem=elems[i])!=null;i++){try{$(elem).triggerHandler("remove");}catch(e){}} -_cleanData(elems);};}else{var _remove=$.fn.remove;$.fn.remove=function(selector,keepData){return this.each(function(){if(!keepData){if(!selector||$.filter(selector,[this]).length){$("*",this).add([this]).each(function(){try{$(this).triggerHandler("remove");}catch(e){}});}} -return _remove.call($(this),selector,keepData);});};} -$.widget=function(name,base,prototype){var namespace=name.split(".")[0],fullName;name=name.split(".")[1];fullName=namespace+"-"+name;if(!prototype){prototype=base;base=$.Widget;} -$.expr[":"][fullName]=function(elem){return!!$.data(elem,name);};$[namespace]=$[namespace]||{};$[namespace][name]=function(options,element){if(arguments.length){this._createWidget(options,element);}};var basePrototype=new base();basePrototype.options=$.extend(true,{},basePrototype.options);$[namespace][name].prototype=$.extend(true,basePrototype,{namespace:namespace,widgetName:name,widgetEventPrefix:$[namespace][name].prototype.widgetEventPrefix||name,widgetBaseClass:fullName},prototype);$.widget.bridge(name,$[namespace][name]);};$.widget.bridge=function(name,object){$.fn[name]=function(options){var isMethodCall=typeof options==="string",args=Array.prototype.slice.call(arguments,1),returnValue=this;options=!isMethodCall&&args.length?$.extend.apply(null,[true,options].concat(args)):options;if(isMethodCall&&options.charAt(0)==="_"){return returnValue;} -if(isMethodCall){this.each(function(){var instance=$.data(this,name),methodValue=instance&&$.isFunction(instance[options])?instance[options].apply(instance,args):instance;if(methodValue!==instance&&methodValue!==undefined){returnValue=methodValue;return false;}});}else{this.each(function(){var instance=$.data(this,name);if(instance){instance.option(options||{})._init();}else{$.data(this,name,new object(options,this));}});} -return returnValue;};};$.Widget=function(options,element){if(arguments.length){this._createWidget(options,element);}};$.Widget.prototype={widgetName:"widget",widgetEventPrefix:"",options:{disabled:false},_createWidget:function(options,element){$.data(element,this.widgetName,this);this.element=$(element);this.options=$.extend(true,{},this.options,this._getCreateOptions(),options);var self=this;this.element.bind("remove."+this.widgetName,function(){self.destroy();});this._create();this._trigger("create");this._init();},_getCreateOptions:function(){return $.metadata&&$.metadata.get(this.element[0])[this.widgetName];},_create:function(){},_init:function(){},destroy:function(){this.element.unbind("."+this.widgetName).removeData(this.widgetName);this.widget().unbind("."+this.widgetName).removeAttr("aria-disabled").removeClass(this.widgetBaseClass+"-disabled "+"ui-state-disabled");},widget:function(){return this.element;},option:function(key,value){var options=key;if(arguments.length===0){return $.extend({},this.options);} -if(typeof key==="string"){if(value===undefined){return this.options[key];} -options={};options[key]=value;} -this._setOptions(options);return this;},_setOptions:function(options){var self=this;$.each(options,function(key,value){self._setOption(key,value);});return this;},_setOption:function(key,value){this.options[key]=value;if(key==="disabled"){this.widget() -[value?"addClass":"removeClass"](this.widgetBaseClass+"-disabled"+" "+"ui-state-disabled").attr("aria-disabled",value);} -return this;},enable:function(){return this._setOption("disabled",false);},disable:function(){return this._setOption("disabled",true);},_trigger:function(type,event,data){var prop,orig,callback=this.options[type];data=data||{};event=$.Event(event);event.type=(type===this.widgetEventPrefix?type:this.widgetEventPrefix+type).toLowerCase();event.target=this.element[0];orig=event.originalEvent;if(orig){for(prop in orig){if(!(prop in event)){event[prop]=orig[prop];}}} -this.element.trigger(event,data);return!($.isFunction(callback)&&callback.call(this.element[0],event,data)===false||event.isDefaultPrevented());}};})); \ No newline at end of file diff --git a/ckan/public/base/vendor/resource.config b/ckan/public/base/vendor/resource.config index 8924b72606a..56bdf2a316a 100644 --- a/ckan/public/base/vendor/resource.config +++ b/ckan/public/base/vendor/resource.config @@ -25,6 +25,7 @@ block_html5_shim = vendor = jquery.js bootstrap = jquery.js fileupload = jquery.js +reorder = jquery.js [groups] @@ -39,8 +40,13 @@ bootstrap = font-awesome/css/font-awesome.css font-awesome/css/font-awesome-ie7.css - fileupload = jquery.ui.widget.js jquery-fileupload/jquery.iframe-transport.js jquery-fileupload/jquery.fileupload.js + +reorder = + jquery.ui.core.js + jquery.ui.widget.js + jquery.ui.mouse.js + jquery.ui.sortable.js diff --git a/ckan/templates/admin/config.html b/ckan/templates/admin/config.html index bfa9b3a2ee5..81a322b3997 100644 --- a/ckan/templates/admin/config.html +++ b/ckan/templates/admin/config.html @@ -10,7 +10,7 @@ {% endblock %} diff --git a/ckan/templates/base.html b/ckan/templates/base.html index 6012bf65928..9ab7a69d973 100644 --- a/ckan/templates/base.html +++ b/ckan/templates/base.html @@ -85,7 +85,7 @@ {# Allows custom attributes to be added to the tag #} - + {# The page block allows you to add content to the page. Most of the time it is @@ -102,17 +102,9 @@ {%- block page %}{% endblock -%} {# - The scripts block allows you to add additonal scripts to the page. Use the - super() function to load the default scripts before/after your own. - NOTE: Scripts should be loaded by the {% resource %} tag except - in very special circumstances DO NOT USE THIS METHOD FOR ADDING SCRIPTS. - - Example: - - {% block scripts %} - {{ super() }} - - {% endblock %} + DO NOT USE THIS BLOCK FOR ADDING SCRIPTS + Scripts should be loaded by the {% resource %} tag except in very special + circumstances #} {%- block scripts %} {% endblock -%} diff --git a/ckan/templates/group/about.html b/ckan/templates/group/about.html index 69259e22ec3..bec97ae26e6 100644 --- a/ckan/templates/group/about.html +++ b/ckan/templates/group/about.html @@ -1,14 +1,12 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('About') }} - {{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('About') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ c.group_dict.display_name }}{% endblock %}

    {% block group_description %} {% if c.group_dict.description %} {{ h.render_markdown(c.group_dict.description) }} - {% else %} -

    {{ _('There is no description for this group') }}

    {% endif %} {% endblock %} diff --git a/ckan/templates/group/activity_stream.html b/ckan/templates/group/activity_stream.html index 800ed24db9b..47a6d029bb4 100644 --- a/ckan/templates/group/activity_stream.html +++ b/ckan/templates/group/activity_stream.html @@ -1,6 +1,6 @@ {% extends "group/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ c.group_dict.title or c.group_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    diff --git a/ckan/templates/group/edit.html b/ckan/templates/group/edit.html index 57808056d54..ff5c0b96989 100644 --- a/ckan/templates/group/edit.html +++ b/ckan/templates/group/edit.html @@ -4,11 +4,9 @@
  • {% link_for _('Groups'), controller='group', action='index' %}
  • {% block breadcrumb_content_inner %}
  • {% link_for group.display_name|truncate(35), controller='group', action='read', id=group.name %}
  • -
  • {% link_for _('Edit'), controller='group', action='edit', id=group.name %}
  • +
  • {% link_for _('Manage'), controller='group', action='edit', id=group.name %}
  • {% endblock %} {% endblock %} -{% block subtitle %}{{ _('Edit Group') }}{% endblock %} - {% block page_heading_class %}hide-heading{% endblock %} {% block page_heading %}{{ _('Edit Group') }}{% endblock %} diff --git a/ckan/templates/group/edit_base.html b/ckan/templates/group/edit_base.html index a5f1203d529..566da231192 100644 --- a/ckan/templates/group/edit_base.html +++ b/ckan/templates/group/edit_base.html @@ -1,17 +1,19 @@ {% extends "page.html" %} +{% block subtitle %}{{ _('Manage') }} - {{ c.group_dict.display_name }} - {{ _('Groups') }}{% endblock %} + {% set group = c.group_dict %} {% block breadcrumb_content %}
  • {% link_for _('Groups'), controller='group', action='index' %}
  • {% block breadcrumb_content_inner %}
  • {% link_for group.display_name|truncate(35), controller='group', action='read', id=group.name %}
  • -
  • {% link_for _('Edit'), controller='group', action='edit', id=group.name %}
  • +
  • {% link_for _('Manage'), controller='group', action='edit', id=group.name %}
  • {% endblock %} {% endblock %} {% block content_action %} - {% link_for _('View group'), controller='group', action='read', id=c.group_dict.name, class_='btn', icon='eye-open' %} + {% link_for _('View'), controller='group', action='read', id=c.group_dict.name, class_='btn', icon='eye-open' %} {% endblock %} {% block content_primary_nav %} diff --git a/ckan/templates/group/index.html b/ckan/templates/group/index.html index 6298cb5a120..514c12bb961 100644 --- a/ckan/templates/group/index.html +++ b/ckan/templates/group/index.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ _('Groups of Datasets') }}{% endblock %} +{% block subtitle %}{{ _('Groups') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Groups'), controller='group', action='index' %}
  • @@ -17,11 +17,13 @@ {% block primary_content_inner %}

    {{ _('Groups') }}

    {% block groups_search_form %} - {% snippet 'snippets/search_form.html', type='group', query=c.q, sorting_selected=c.sort_by_selected, count=c.page.item_count, placeholder=_('Search groups...'), show_empty=request.params %} + {% snippet 'snippets/search_form.html', type='group', query=c.q, sorting_selected=c.sort_by_selected, count=c.page.item_count, placeholder=_('Search groups...'), show_empty=request.params, no_bottom_border=true if c.page.items %} {% endblock %} {% block groups_list %} {% if c.page.items or request.params %} - {% snippet "group/snippets/group_list.html", groups=c.page.items %} + {% if c.page.items %} + {% snippet "group/snippets/group_list.html", groups=c.page.items %} + {% endif %} {% else %}

    {{ _('There are currently no groups for this site') }}. diff --git a/ckan/templates/group/member_new.html b/ckan/templates/group/member_new.html index 6a8fa691c29..f7b24c236fe 100644 --- a/ckan/templates/group/member_new.html +++ b/ckan/templates/group/member_new.html @@ -50,29 +50,18 @@

    {{ form.select('role', label=_('Role'), options=c.roles, selected=c.user_role, error='', attrs=format_attrs) }}
    {% if user %} - - {% set format_attrs = {'disabled': true} %} - {{ form.input('username', label=_('User'), value=user.name, classes=['control-medium'], attrs=format_attrs) }} + {% set locale = h.dump_json({'content': _('Are you sure you want to delete this member?')}) %} + {{ _('Delete') }} + {% else %} - {% set format_attrs = {'data-module': 'autocomplete', 'data-module-source': '/api/2/util/user/autocomplete?q=?'} %} - {{ form.input('username', id='field-username', label=_('User'), placeholder=_('Username'), value='', error='', classes=['control-medium'], attrs=format_attrs) }} + {% endif %} - {% set format_attrs = {'data-module': 'autocomplete'} %} - {{ form.select('role', label=_('Role'), options=c.roles, selected=c.user_role, error='', attrs=format_attrs) }} -
    - {% if user %} - {% set locale = h.dump_json({'content': _('Are you sure you want to delete this member?')}) %} - {{ _('Delete') }} - - {% else %} - - {% endif %} -
    - +
    + {% endblock %} {% endblock %} @@ -85,12 +74,9 @@

    {% trans %} -

    Admin: Can add/edit and delete datasets, as well as - manage group members.

    -

    Editor: Can add and edit datasets, but not manage - group members.

    -

    Member: Can view the group's private - datasets, but not add new datasets.

    +

    Admin: Can edit group information, as well as + manage organization members.

    +

    Member: Can add/remove datasets from groups

    {% endtrans %}
    diff --git a/ckan/templates/group/members.html b/ckan/templates/group/members.html index 600c6d1bf54..210562903b7 100644 --- a/ckan/templates/group/members.html +++ b/ckan/templates/group/members.html @@ -1,9 +1,12 @@ {% extends "group/edit_base.html" %} -{% block subtitle %}{{ _('Members') }} - {{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('Members') }} - {{ c.group_dict.display_name }} - {{ _('Groups') }}{% endblock %} + +{% block page_primary_action %} + {% link_for _('Add Member'), controller='group', action='member_new', id=c.group_dict.id, class_='btn btn-primary', icon='plus-sign-alt' %} +{% endblock %} {% block primary_content_inner %} - {% link_for _('Add Member'), controller='group', action='member_new', id=c.group_dict.id, class_='btn pull-right', icon='plus-sign-alt' %}

    {{ _('{0} members'.format(c.members|length)) }}

    diff --git a/ckan/templates/group/new.html b/ckan/templates/group/new.html index 23d66facb05..1d78ac40010 100644 --- a/ckan/templates/group/new.html +++ b/ckan/templates/group/new.html @@ -2,7 +2,7 @@ {% block subtitle %}{{ _('Create a Group') }}{% endblock %} -{% block breadcrumb_link %}{{ h.nav_link(_('Create Group'), controller='group', action='edit', id=c.group.name) }}{% endblock %} +{% block breadcrumb_link %}{{ h.nav_link(_('Create a Group'), controller='group', action='edit', id=c.group.name) }}{% endblock %} {% block page_heading %}{{ _('Create a Group') }}{% endblock %} diff --git a/ckan/templates/group/read.html b/ckan/templates/group/read.html index 8218edb4b37..0fa87eb8d7b 100644 --- a/ckan/templates/group/read.html +++ b/ckan/templates/group/read.html @@ -2,10 +2,6 @@ {% block subtitle %}{{ c.group_dict.display_name }}{% endblock %} -{% block page_primary_action %} - {% link_for _('Add Dataset'), controller='package', action='new', group=c.group_dict.id, class_='btn btn-primary', icon='plus-sign-alt' %} -{% endblock %} - {% block primary_content_inner %} {% block groups_search_form %} {% set facets = { @@ -15,7 +11,14 @@ 'translated_fields': c.translated_fields, 'remove_field': c.remove_field } %} - {% snippet 'snippets/search_form.html', type='dataset', query=c.q, sorting_selected=c.sort_by_selected, count=c.page.item_count, facets=facets, placeholder=_('Search datasets...'), show_empty=request.params %} + {% set sorting = [ + (_('Relevance'), 'score desc, metadata_modified desc'), + (_('Name Ascending'), 'title_string asc'), + (_('Name Descending'), 'title_string desc'), + (_('Last Modified'), 'metadata_modified desc'), + (_('Popular'), 'views_recent desc') if g.tracking_enabled else (false, false) ] + %} + {% snippet 'snippets/search_form.html', type='dataset', query=c.q, sorting=sorting, sorting_selected=c.sort_by_selected, count=c.page.item_count, facets=facets, placeholder=_('Search datasets...'), show_empty=request.params %} {% endblock %} {% block packages_list %} {% if c.page.items %} diff --git a/ckan/templates/group/read_base.html b/ckan/templates/group/read_base.html index afa92f2038f..9164f28ed64 100644 --- a/ckan/templates/group/read_base.html +++ b/ckan/templates/group/read_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ c.group_dict.display_name }} - {{ _('Groups') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Groups'), controller='group', action='index' %}
  • @@ -9,7 +9,7 @@ {% block content_action %} {% if h.check_access('group_update', {'id': c.group_dict.id}) %} - {% link_for _('Edit'), controller='group', action='edit', id=c.group_dict.name, class_='btn', icon='wrench' %} + {% link_for _('Manage'), controller='group', action='edit', id=c.group_dict.name, class_='btn', icon='wrench' %} {% endif %} {% endblock %} diff --git a/ckan/templates/group/snippets/group_form.html b/ckan/templates/group/snippets/group_form.html index 6d4d3216b26..f8d69c7d0af 100644 --- a/ckan/templates/group/snippets/group_form.html +++ b/ckan/templates/group/snippets/group_form.html @@ -1,6 +1,6 @@ {% import 'macros/form.html' as form %} - + {% block error_summary %} {{ form.errors(error_summary) }} {% endblock %} @@ -19,7 +19,10 @@ {{ form.markdown('description', label=_('Description'), id='field-description', placeholder=_('A little information about my group...'), value=data.description, error=errors.description) }} - {{ form.input('image_url', label=_('Image URL'), id='field-image-url', type='url', placeholder=_('http://example.com/my-image.jpg'), value=data.image_url, error=errors.image_url, classes=['control-full']) }} + {% set is_upload = data.image_url and not data.image_url.startswith('http') %} + {% set is_url = data.image_url and data.image_url.startswith('http') %} + + {{ form.image_upload(data, errors, is_upload_enabled=h.uploads_enabled(), is_url=is_url, is_upload=is_upload) }} {% endblock %} @@ -48,6 +51,8 @@ ) }} {% endfor %} {% endblock %} + + {{ form.required_message() }} {# Do not update datasets here {% block dataset_fields %} {% if data.packages %} @@ -70,13 +75,12 @@ #}
    - {{ form.required_message() }} - {% block delete_button %} - {% if h.check_access('group_delete', {'id': data.id}) %} - {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Group?')}) %} - {% block delete_button_text %}{{ _('Delete') }}{% endblock %} - {% endif %} - {% endblock %} + {% block delete_button %} + {% if h.check_access('group_delete', {'id': data.id}) %} + {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Group?')}) %} + {% block delete_button_text %}{{ _('Delete') }}{% endblock %} + {% endif %} + {% endblock %}
    diff --git a/ckan/templates/group/snippets/group_item.html b/ckan/templates/group/snippets/group_item.html index 9e32ce86e6a..8d66988dd9e 100644 --- a/ckan/templates/group/snippets/group_item.html +++ b/ckan/templates/group/snippets/group_item.html @@ -11,10 +11,11 @@ {% endfor %} #} -{% set url = h.url_for(group.type ~ '_read', action='read', id=group.name) %} +{% set type = group.type or 'group' %} +{% set url = h.url_for(type ~ '_read', action='read', id=group.name) %}
  • {% block image %} - {{ group.name }} + {{ group.name }} {% endblock %} {% block title %}

    {{ group.display_name }}

    @@ -22,20 +23,21 @@

    {{ group.display_name }}

    {% block description %} {% if group.description %}

    {{ h.markdown_extract(group.description, extract_length=80) }}

    - {% else %} -

    {{ _('This group has no description') }}

    {% endif %} {% endblock %} {% block datasets %} {% if group.packages %} {{ ungettext('{num} Dataset', '{num} Datasets', group.packages).format(num=group.packages) }} - {% else %} + {% elif group.packages == 0 %} {{ _('0 Datasets') }} {% endif %} {% endblock %} {{ _('View {name}').format(name=group.display_name) }} + {% if group.user_member %} + + {% endif %}
  • {% if position is divisibleby 3 %}
  • diff --git a/ckan/templates/group/snippets/info.html b/ckan/templates/group/snippets/info.html index b7c749ab147..72db2cf8c5e 100644 --- a/ckan/templates/group/snippets/info.html +++ b/ckan/templates/group/snippets/info.html @@ -2,7 +2,7 @@

    {{ group.display_name }}

    @@ -11,8 +11,6 @@

    {{ group.display_name }}

    {{ h.markdown_extract(group.description, 180) }} {% link_for _('read more'), controller='group', action='about', id=group.name %}

    - {% else %} -

    {{ _('There is no description for this group') }}

    {% endif %} {% if show_nums %}
    diff --git a/ckan/templates/header.html b/ckan/templates/header.html index e8aa9321098..dd0d5c07c57 100644 --- a/ckan/templates/header.html +++ b/ckan/templates/header.html @@ -50,7 +50,9 @@
      {% block header_account_notlogged %}
    • {% link_for _('Log in'), controller='user', action='login' %}
    • -
    • {% link_for _('Register'), controller='user', action='register', class_='sub' %}
    • + {% if h.check_access('user_create') %} +
    • {% link_for _('Register'), controller='user', action='register', class_='sub' %}
    • + {% endif %} {% endblock %}
    @@ -103,8 +105,8 @@

    - - + +
    {% endblock %} diff --git a/ckan/templates/home/layout1.html b/ckan/templates/home/layout1.html index 53ac0d390bb..41e6c2024c9 100644 --- a/ckan/templates/home/layout1.html +++ b/ckan/templates/home/layout1.html @@ -2,10 +2,14 @@
    - {% snippet 'home/snippets/promoted.html' %} + {% block promoted %} + {% snippet 'home/snippets/promoted.html' %} + {% endblock %}
    - {% snippet 'home/snippets/search.html' %} + {% block search %} + {% snippet 'home/snippets/search.html' %} + {% endblock %}
    @@ -14,10 +18,19 @@
    - {% snippet 'home/snippets/featured_group.html' %} + {# Note: this featured_group block is used as an example in the theming + tutorial in the docs! If you change this code, be sure to check + whether you need to update the docs. #} + {# Start template block example. #} + {% block featured_group %} + {% snippet 'home/snippets/featured_group.html' %} + {% endblock %} + {# End template block example. #}
    - {% snippet 'home/snippets/featured_organization.html' %} + {% block featured_organization %} + {% snippet 'home/snippets/featured_organization.html' %} + {% endblock %}
    diff --git a/ckan/templates/home/layout2.html b/ckan/templates/home/layout2.html index e1b62bea09f..bab42f388b1 100644 --- a/ckan/templates/home/layout2.html +++ b/ckan/templates/home/layout2.html @@ -2,11 +2,17 @@
    - {% snippet 'home/snippets/search.html' %} - {% snippet 'home/snippets/stats.html' %} + {% block search %} + {% snippet 'home/snippets/search.html' %} + {% endblock %} + {% block stats %} + {% snippet 'home/snippets/stats.html' %} + {% endblock %}
    - {% snippet 'home/snippets/promoted.html' %} + {% block promoted %} + {% snippet 'home/snippets/promoted.html' %} + {% endblock %}
    @@ -15,10 +21,14 @@
    - {% snippet 'home/snippets/featured_organization.html' %} + {% block featured_organization %} + {% snippet 'home/snippets/featured_organization.html' %} + {% endblock %}
    - {% snippet 'home/snippets/featured_group.html' %} + {% block featured_group %} + {% snippet 'home/snippets/featured_group.html' %} + {% endblock %}
    diff --git a/ckan/templates/home/layout3.html b/ckan/templates/home/layout3.html index 80c10c22ba0..dc80b32581d 100644 --- a/ckan/templates/home/layout3.html +++ b/ckan/templates/home/layout3.html @@ -1,16 +1,22 @@
    - {% snippet 'home/snippets/search.html' %} + {% block search %} + {% snippet 'home/snippets/search.html' %} + {% endblock %}
    - {% snippet 'home/snippets/promoted.html' %} + {% block promoted %} + {% snippet 'home/snippets/promoted.html' %} + {% endblock %}
    - {% snippet 'home/snippets/stats.html' %} + {% block stats %} + {% snippet 'home/snippets/stats.html' %} + {% endblock %}
    diff --git a/ckan/templates/home/snippets/promoted.html b/ckan/templates/home/snippets/promoted.html index fc09d9a6300..d2b7e6448ba 100644 --- a/ckan/templates/home/snippets/promoted.html +++ b/ckan/templates/home/snippets/promoted.html @@ -13,10 +13,15 @@

    {{ _("Welcome to CKAN") }}

    {% endif %} - + + {% block home_image %} + + {% endblock %}
    diff --git a/ckan/templates/macros/form.html b/ckan/templates/macros/form.html index 497dea6bf02..522524350a7 100644 --- a/ckan/templates/macros/form.html +++ b/ckan/templates/macros/form.html @@ -395,3 +395,35 @@

    {% endmacro %} +{# +Builds a file upload for input + +Example + {% import 'macros/form.html' as form %} + {{ form.image_upload(data, errors, is_upload_enabled=true) }} + +#} +{% macro image_upload(data, errors, field_url='image_url', field_upload='image_upload', field_clear='clear_upload', + is_url=false, is_upload=false, is_upload_enabled=false, placeholder=false, + url_label='', upload_label='') %} + {% set placeholder = placeholder if placeholder else _('http://example.com/my-image.jpg') %} + {% set url_label = url_label or _('Image URL') %} + {% set upload_label = upload_label or _('Image') %} + + {% if is_upload_enabled %} +
    + {% endif %} + + {{ input(field_url, label=url_label, id='field-image-url', placeholder=placeholder, value=data.get(field_url), error=errors.get(field_url), classes=['control-full']) }} + + {% if is_upload_enabled %} + {{ input(field_upload, label=upload_label, id='field-image-upload', type='file', placeholder='', value='', error='', classes=['control-full']) }} + {% if is_upload %} + {{ checkbox(field_clear, label=_('Clear Upload'), id='field-clear-upload', value='true', error='', classes=['control-full']) }} + {% endif %} + {% endif %} + + {% if is_upload_enabled %}
    {% endif %} + +{% endmacro %} diff --git a/ckan/templates/organization/about.html b/ckan/templates/organization/about.html index 6848ed9ede7..ed0e7b718ae 100644 --- a/ckan/templates/organization/about.html +++ b/ckan/templates/organization/about.html @@ -1,14 +1,12 @@ {% extends "organization/read_base.html" %} -{% block subtitle %}{{ _('About') }} - {{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('About') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ c.group_dict.display_name }}{% endblock %}

    {% block organization_description %} {% if c.group_dict.description %} {{ h.render_markdown(c.group_dict.description) }} - {% else %} -

    {{ _('There is no description for this organization') }}

    {% endif %} {% endblock %} {% block organization_extras %} diff --git a/ckan/templates/organization/activity_stream.html b/ckan/templates/organization/activity_stream.html index dc8e711c5c8..ffa029d951b 100644 --- a/ckan/templates/organization/activity_stream.html +++ b/ckan/templates/organization/activity_stream.html @@ -1,6 +1,6 @@ {% extends "organization/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ c.group_dict.title or c.group_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    diff --git a/ckan/templates/organization/bulk_process.html b/ckan/templates/organization/bulk_process.html index 13c2001e45c..cc8a757fa26 100644 --- a/ckan/templates/organization/bulk_process.html +++ b/ckan/templates/organization/bulk_process.html @@ -32,17 +32,17 @@

    diff --git a/ckan/templates/organization/edit.html b/ckan/templates/organization/edit.html index 06f7c7ee8ed..3fde80a2ab0 100644 --- a/ckan/templates/organization/edit.html +++ b/ckan/templates/organization/edit.html @@ -1,6 +1,6 @@ {% extends "organization/base_form_page.html" %} -{% block subtitle %}{{ _('Edit Organization') }}{% endblock %} +{% block subtitle %}{{ _('Edit') }} - {{ super() }}{% endblock %} {% block page_heading_class %}hide-heading{% endblock %} {% block page_heading %}{{ _('Edit Organization') }}{% endblock %} diff --git a/ckan/templates/organization/edit_base.html b/ckan/templates/organization/edit_base.html index 6f29a82b1f8..daaeef556e5 100644 --- a/ckan/templates/organization/edit_base.html +++ b/ckan/templates/organization/edit_base.html @@ -2,21 +2,20 @@ {% set organization = c.group_dict %} -{% block subtitle %}{{ organization.display_name }}{% endblock %} +{% block subtitle %}{{ c.group_dict.display_name }} - {{ _('Organizations') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Organizations'), controller='organization', action='index' %}
  • {% block breadcrumb_content_inner %}
  • {% link_for organization.display_name|truncate(35), controller='organization', action='read', id=organization.name %}
  • -
  • {% link_for _('Admin'), controller='organization', action='edit', id=organization.name %}
  • +
  • {% link_for _('Manage'), controller='organization', action='edit', id=organization.name %}
  • {% endblock %} {% endblock %} {% block content_action %} {% if organization and h.check_access('organization_update', {'id': organization.id}) %} - {% link_for _('View organization'), controller='organization', action='read', id=organization.name, class_='btn', icon='eye-open' %} + {% link_for _('View'), controller='organization', action='read', id=organization.name, class_='btn', icon='eye-open' %} {% endif %} - {#
  • {% link_for _('History'), controller='organization', action='history', id=organization.name, class_='btn', icon='undo' %}
  • #} {% endblock %} {% block content_primary_nav %} diff --git a/ckan/templates/organization/index.html b/ckan/templates/organization/index.html index ae2fb2bc49b..3b317ec701c 100644 --- a/ckan/templates/organization/index.html +++ b/ckan/templates/organization/index.html @@ -17,11 +17,13 @@ {% block primary_content_inner %}

    {% block page_heading %}{{ _('Organizations') }}{% endblock %}

    {% block organizations_search_form %} - {% snippet 'snippets/search_form.html', type='organization', query=c.q, sorting_selected=c.sort_by_selected, count=c.page.item_count, placeholder=_('Search organizations...'), show_empty=request.params %} + {% snippet 'snippets/search_form.html', type='organization', query=c.q, sorting_selected=c.sort_by_selected, count=c.page.item_count, placeholder=_('Search organizations...'), show_empty=request.params, no_bottom_border=true if c.page.items %} {% endblock %} {% block organizations_list %} {% if c.page.items or request.params %} - {% snippet "organization/snippets/organization_list.html", organizations=c.page.items %} + {% if c.page.items %} + {% snippet "organization/snippets/organization_list.html", organizations=c.page.items %} + {% endif %} {% else %}

    {{ _('There are currently no organizations for this site') }}. diff --git a/ckan/templates/organization/member_new.html b/ckan/templates/organization/member_new.html index a121549dab4..cc4ac8c7dd4 100644 --- a/ckan/templates/organization/member_new.html +++ b/ckan/templates/organization/member_new.html @@ -4,7 +4,7 @@ {% set user = c.user_dict %} -{% block subtitle %}{{ _('Members') }} - {{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('Edit Member') if user else _('Add Member') }} - {{ super() }}{% endblock %} {% block primary_content_inner %} {% link_for _('Back to all members'), controller='organization', action='members', id=organization.name, class_='btn pull-right', icon='arrow-left' %} @@ -55,11 +55,11 @@

    {% set locale = h.dump_json({'content': _('Are you sure you want to delete this member?')}) %} {{ _('Delete') }} {% else %} {% endif %} diff --git a/ckan/templates/organization/members.html b/ckan/templates/organization/members.html index c5dcd0ebdae..6e3f4446b9f 100644 --- a/ckan/templates/organization/members.html +++ b/ckan/templates/organization/members.html @@ -1,6 +1,6 @@ {% extends "organization/edit_base.html" %} -{% block subtitle %}{{ _('Members') }} - {{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ _('Members') }} - {{ super() }}{% endblock %} {% block page_primary_action %} {% link_for _('Add Member'), controller='organization', action='member_new', id=c.group_dict.id, class_='btn btn-primary', icon='plus-sign-alt' %} diff --git a/ckan/templates/organization/read.html b/ckan/templates/organization/read.html index a702541fd66..e2a6a04b090 100644 --- a/ckan/templates/organization/read.html +++ b/ckan/templates/organization/read.html @@ -1,7 +1,7 @@ {% extends "organization/read_base.html" %} {% block page_primary_action %} - {% if h.check_access('package_create', {'organization_id': c.group_dict.id}) %} + {% if h.check_access('package_create', {'owner_org': c.group_dict.id}) %} {% link_for _('Add Dataset'), controller='package', action='new', group=c.group_dict.id, class_='btn btn-primary', icon='plus-sign-alt' %} {% endif %} {% endblock %} @@ -15,7 +15,14 @@ 'translated_fields': c.translated_fields, 'remove_field': c.remove_field } %} - {% snippet 'snippets/search_form.html', type='dataset', query=c.q, sorting_selected=c.sort_by_selected, count=c.page.item_count, facets=facets, placeholder=_('Search datasets...'), show_empty=request.params %} + {% set sorting = [ + (_('Relevance'), 'score desc, metadata_modified desc'), + (_('Name Ascending'), 'title_string asc'), + (_('Name Descending'), 'title_string desc'), + (_('Last Modified'), 'metadata_modified desc'), + (_('Popular'), 'views_recent desc') if g.tracking_enabled else (false, false) ] + %} + {% snippet 'snippets/search_form.html', type='dataset', query=c.q, sorting=sorting, sorting_selected=c.sort_by_selected, count=c.page.item_count, facets=facets, placeholder=_('Search datasets...'), show_empty=request.params %} {% endblock %} {% block packages_list %} {% if c.page.items %} diff --git a/ckan/templates/organization/read_base.html b/ckan/templates/organization/read_base.html index d5a0f8120d5..1c9454f4729 100644 --- a/ckan/templates/organization/read_base.html +++ b/ckan/templates/organization/read_base.html @@ -1,6 +1,6 @@ {% extends "page.html" %} -{% block subtitle %}{{ c.group_dict.display_name }}{% endblock %} +{% block subtitle %}{{ c.group_dict.display_name }} - {{ _('Organizations') }}{% endblock %} {% block breadcrumb_content %}
  • {% link_for _('Organizations'), controller='organization', action='index' %}
  • @@ -9,7 +9,7 @@ {% block content_action %} {% if h.check_access('organization_update', {'id': c.group_dict.id}) %} - {% link_for _('Admin'), controller='organization', action='edit', id=c.group_dict.name, class_='btn', icon='wrench' %} + {% link_for _('Manage'), controller='organization', action='edit', id=c.group_dict.name, class_='btn', icon='wrench' %} {% endif %} {% endblock %} diff --git a/ckan/templates/organization/snippets/organization_form.html b/ckan/templates/organization/snippets/organization_form.html index 7b05e7d1542..f98cb3d9b09 100644 --- a/ckan/templates/organization/snippets/organization_form.html +++ b/ckan/templates/organization/snippets/organization_form.html @@ -1,6 +1,6 @@ {% import 'macros/form.html' as form %} -
    + {% block error_summary %} {{ form.errors(error_summary) }} {% endblock %} @@ -19,7 +19,10 @@ {{ form.markdown('description', label=_('Description'), id='field-description', placeholder=_('A little information about my organization...'), value=data.description, error=errors.description) }} - {{ form.input('image_url', label=_('Image URL'), id='field-image-url', type='url', placeholder=_('http://example.com/my-image.jpg'), value=data.image_url, error=errors.image_url, classes=['control-full']) }} + {% set is_upload = data.image_url and not data.image_url.startswith('http') %} + {% set is_url = data.image_url and data.image_url.startswith('http') %} + + {{ form.image_upload(data, errors, is_upload_enabled=h.uploads_enabled(), is_url=is_url, is_upload=is_upload) }} {% endblock %} @@ -71,14 +74,14 @@ {% endblock %} #} -
    {{ form.required_message() }} - {% block delete_button %} - {% if h.check_access('organization_delete', {'id': data.id}) %} - {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Organization? This will delete all the public and private datasets belonging to this organization.')}) %} - {% block delete_button_text %}{{ _('Delete') }}{% endblock %} - {% endif %} - {% endblock %} +
    + {% block delete_button %} + {% if h.check_access('organization_delete', {'id': data.id}) %} + {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Organization? This will delete all the public and private datasets belonging to this organization.')}) %} + {% block delete_button_text %}{{ _('Delete') }}{% endblock %} + {% endif %} + {% endblock %}
    diff --git a/ckan/templates/organization/snippets/organization_item.html b/ckan/templates/organization/snippets/organization_item.html index 818d5cc338a..1d7ca32e308 100644 --- a/ckan/templates/organization/snippets/organization_item.html +++ b/ckan/templates/organization/snippets/organization_item.html @@ -14,7 +14,7 @@ {% set url = h.url_for(organization.type ~ '_read', action='read', id=organization.name) %}
  • {% block image %} - {{ organization.name }} + {{ organization.name }} {% endblock %} {% block title %}

    {{ organization.display_name }}

    @@ -22,8 +22,6 @@

    {{ organization.display_name }}

    {% block description %} {% if organization.description %}

    {{ h.markdown_extract(organization.description, extract_length=80) }}

    - {% else %} -

    {{ _('This organization has no description') }}

    {% endif %} {% endblock %} {% block datasets %} diff --git a/ckan/templates/package/activity.html b/ckan/templates/package/activity.html index 52c0f8eade7..3941a6dda02 100644 --- a/ckan/templates/package/activity.html +++ b/ckan/templates/package/activity.html @@ -1,6 +1,6 @@ {% extends "package/read_base.html" %} -{% block subtitle %}{{ _('Activity Stream') }} - {{ c.pkg_dict.title or c.pkg_dict.name }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    diff --git a/ckan/templates/package/activity_stream.html b/ckan/templates/package/activity_stream.html deleted file mode 100644 index af67b063a7b..00000000000 --- a/ckan/templates/package/activity_stream.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "package/read.html" %} - -{% block subtitle %}{{ _('Activity Stream') }}{% endblock %} - -{% block package_content %} -

    {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

    - {% block activity_stream %} - {{ c.package_activity_stream | safe }} - {% endblock %} -{% endblock %} diff --git a/ckan/templates/package/base.html b/ckan/templates/package/base.html index b51f78faaa6..20dd0b1db27 100644 --- a/ckan/templates/package/base.html +++ b/ckan/templates/package/base.html @@ -4,6 +4,8 @@ {% block breadcrumb_content_selected %} class="active"{% endblock %} +{% block subtitle %}{{ _('Datasets') }}{% endblock %} + {% block breadcrumb_content %} {% if pkg %} {% set dataset = pkg.title or pkg.name %} diff --git a/ckan/templates/package/base_form_page.html b/ckan/templates/package/base_form_page.html index b4d7dc440c0..832fbbb2554 100644 --- a/ckan/templates/package/base_form_page.html +++ b/ckan/templates/package/base_form_page.html @@ -1,4 +1,4 @@ -{% extends "package/base.html" %} +{% extends "package/edit_base.html" %} {% block primary_content %}
    diff --git a/ckan/templates/package/group_list.html b/ckan/templates/package/group_list.html new file mode 100644 index 00000000000..34b29496c11 --- /dev/null +++ b/ckan/templates/package/group_list.html @@ -0,0 +1,26 @@ +{% extends "package/read_base.html" %} +{% import 'macros/form.html' as form %} + +{% block primary_content_inner %} +

    {{ _('Groups') }}

    + + {% if c.group_dropdown %} +
    + + + + {% endif %} + + {% if c.pkg_dict.groups %} +
    + {% snippet 'group/snippets/group_list.html', groups=c.pkg_dict.groups %} + + {% else %} +

    {{ _('There are no groups associated with this dataset') }}

    + {% endif %} + +{% endblock %} diff --git a/ckan/templates/package/new.html b/ckan/templates/package/new.html index a3e7bae8e1c..a8eea92ca48 100644 --- a/ckan/templates/package/new.html +++ b/ckan/templates/package/new.html @@ -1,3 +1,3 @@ {% extends "package/base_form_page.html" %} -{% block subtitle %}{{ _('Create dataset') }}{% endblock %} +{% block subtitle %}{{ _('Create Dataset') }}{% endblock %} diff --git a/ckan/templates/package/read.html b/ckan/templates/package/read.html index d70cc3e5bf2..9f178b198ec 100644 --- a/ckan/templates/package/read.html +++ b/ckan/templates/package/read.html @@ -2,8 +2,6 @@ {% set pkg = c.pkg_dict %} -{% block subtitle %}{{ pkg.title or pkg.name }}{% endblock %} - {% block primary_content_inner %} {{ super() }} {% block package_description %} diff --git a/ckan/templates/package/read_base.html b/ckan/templates/package/read_base.html index f0a58a21018..8cd14492cd9 100644 --- a/ckan/templates/package/read_base.html +++ b/ckan/templates/package/read_base.html @@ -1,6 +1,6 @@ {% extends "package/base.html" %} -{% block subtitle %}{{ pkg.title or pkg.name }}{% endblock %} +{% block subtitle %}{{ pkg.title or pkg.name }} - {{ super() }}{% endblock %} {% block links -%} {{ super() }} @@ -16,12 +16,13 @@ {% block content_action %} {% if h.check_access('package_update', {'id':pkg.id }) %} - {% link_for _('Edit'), controller='package', action='edit', id=pkg.name, class_='btn', icon='wrench' %} + {% link_for _('Manage'), controller='package', action='edit', id=pkg.name, class_='btn', icon='wrench' %} {% endif %} {% endblock %} {% block content_primary_nav %} {{ h.build_nav_icon('dataset_read', _('Dataset'), id=pkg.name) }} + {{ h.build_nav_icon('dataset_groups', _('Groups'), id=pkg.name) }} {{ h.build_nav_icon('dataset_activity', _('Activity Stream'), id=pkg.name) }} {{ h.build_nav_icon('related_list', _('Related'), id=pkg.name) }} {% endblock %} @@ -59,23 +60,6 @@ {% endif %} {% endblock %} - {% block package_groups %} - {% if pkg.groups %} -
    -

    {{ _('Groups') }}

    - -
    - {% endif %} - {% endblock %} - {% block package_social %} {% snippet "snippets/social.html" %} {% endblock %} diff --git a/ckan/templates/package/related_list.html b/ckan/templates/package/related_list.html index 0f31cb0e790..affdd974372 100644 --- a/ckan/templates/package/related_list.html +++ b/ckan/templates/package/related_list.html @@ -2,6 +2,8 @@ {% set pkg = c.pkg %} +{% block subtitle %}{{ _('Related') }} - {{ super() }}{% endblock %} + {% block primary_content_inner %}

    {% block page_heading %}{{ _('Related Media for {dataset}').format(dataset=h.dataset_display_name(c.pkg)) }}{% endblock %}

    {% block related_list %} diff --git a/ckan/templates/package/resource_data.html b/ckan/templates/package/resource_data.html index 05bd66fdfee..531d9575c44 100644 --- a/ckan/templates/package/resource_data.html +++ b/ckan/templates/package/resource_data.html @@ -9,7 +9,7 @@
    @@ -32,11 +32,15 @@
  • - + - + {% if status.status %} + + {% else %} + + {% endif %}
    - -
    - @@ -77,8 +77,6 @@

    {% if notes %}

    {{ notes|urlize }}

    - {% else %} -

    {{ _("This dataset has no description") }}

    {% endif %}
    {{ _('Status') }}{{ status.status.capitalize() if status.status else _('Not Uploaded Yet') }}{{ h.datapusher_status_description(status) }}
    {{ _('Last updated') }}{{ h.time_ago_from_timestamp(status.last_updated) if status.status else _('Never') }}{{ h.time_ago_from_timestamp(status.last_updated) }}{{ _('Never') }}
    @@ -51,9 +55,9 @@

    {{ _('Upload Log') }}

    {{ item.message | urlize }}
    - + {{ h.time_ago_from_timestamp(item.timestamp) }} - more info + {{ _('Details') }}

    diff --git a/ckan/templates/package/resource_edit_base.html b/ckan/templates/package/resource_edit_base.html index 760ee91e7db..fc7043e4703 100644 --- a/ckan/templates/package/resource_edit_base.html +++ b/ckan/templates/package/resource_edit_base.html @@ -21,7 +21,7 @@ {% block content_primary_nav %} {{ h.build_nav_icon('resource_edit', _('Edit resource'), id=pkg.name, resource_id=res.id) }} {% if 'datapusher' in g.plugins %} - {{ h.build_nav_icon('resource_data', _('Resource Data'), id=pkg.name, resource_id=res.id) }} + {{ h.build_nav_icon('resource_data', _('DataStore'), id=pkg.name, resource_id=res.id) }} {% endif %} {{ h.build_nav_icon('views', _('Views'), id=pkg.name, resource_id=res.id) }} {% endblock %} diff --git a/ckan/templates/package/resource_read.html b/ckan/templates/package/resource_read.html index c3f5a032443..fac697fd830 100644 --- a/ckan/templates/package/resource_read.html +++ b/ckan/templates/package/resource_read.html @@ -27,7 +27,7 @@
      {% block resource_actions_inner %} {% if h.check_access('package_update', {'id':pkg.id }) %} -
    • {% link_for _('Edit'), controller='package', action='resource_edit', id=pkg.name, resource_id=res.id, class_='btn', icon='wrench' %}
    • +
    • {% link_for _('Manage'), controller='package', action='resource_edit', id=pkg.name, resource_id=res.id, class_='btn', icon='wrench' %}
    • {% endif %} {% if res.url %}
    • @@ -61,8 +61,6 @@
      {% if res.description %} {{ h.render_markdown(res.description) }} - {% else %} -

      {{ _('There is no description for this resource') }}

      {% endif %} {% if not res.description and c.package.notes %}

      {{ _('From the dataset abstract') }}

      diff --git a/ckan/templates/package/resources.html b/ckan/templates/package/resources.html index e353d03f10b..0e02b3846dd 100644 --- a/ckan/templates/package/resources.html +++ b/ckan/templates/package/resources.html @@ -1,5 +1,7 @@ {% extends "package/edit_base.html" %} +{% set has_reorder = c.pkg_dict and c.pkg_dict.resources and c.pkg_dict.resources|length > 0 %} + {% block subtitle %}{{ _('Resources') }} - {{ h.dataset_display_name(pkg) }}{% endblock %} {% block page_primary_action %} @@ -8,7 +10,7 @@ {% block primary_content_inner %} {% if pkg.resources %} -
        +
          {% for resource in pkg.resources %} {% snippet 'package/snippets/resource_item.html', pkg=pkg, res=resource, url_is_edit=true %} {% endfor %} @@ -19,3 +21,10 @@ {% endtrans %} {% endif %} {% endblock %} + +{% block scripts %} + {{ super() }} + {% if has_reorder %} + {% resource 'vendor/reorder' %} + {% endif %} +{% endblock %} diff --git a/ckan/templates/package/search.html b/ckan/templates/package/search.html index 6274ecc0798..6c8173eb772 100644 --- a/ckan/templates/package/search.html +++ b/ckan/templates/package/search.html @@ -1,7 +1,7 @@ {% extends "page.html" %} {% import 'macros/form.html' as form %} -{% block subtitle %}{{ _("Search for a Dataset") }}{% endblock %} +{% block subtitle %}{{ _("Datasets") }}{% endblock %} {% block breadcrumb_content %}
        • {{ h.nav_link(_('Datasets'), controller='package', action='search', highlight_actions = 'new index') }}
        • diff --git a/ckan/templates/package/snippets/package_basic_fields.html b/ckan/templates/package/snippets/package_basic_fields.html index d1ac7e25577..0418e3be2f8 100644 --- a/ckan/templates/package/snippets/package_basic_fields.html +++ b/ckan/templates/package/snippets/package_basic_fields.html @@ -64,12 +64,14 @@ {% endif %} {% if show_organizations_selector %} - {% set existing_org = data.owner_org %} + {% set existing_org = data.owner_org or data.group_id %}
          + + + +
          +
          + {% endif %} {% endblock %} diff --git a/ckan/templates/package/snippets/package_context.html b/ckan/templates/package/snippets/package_context.html index 3640468c51e..110bbd5201b 100644 --- a/ckan/templates/package/snippets/package_context.html +++ b/ckan/templates/package/snippets/package_context.html @@ -6,8 +6,6 @@

          {{ pkg.title or pkg.name }}

          {{ h.markdown_extract(pkg.notes, 180) }} {% link_for _('read more'), controller='package', action='about', id=pkg.name %}

          - {% else %} -

          {{ _('There is no description for this dataset') }}

          {% endif %}
          diff --git a/ckan/templates/package/snippets/package_form.html b/ckan/templates/package/snippets/package_form.html index 33d726639f1..0c3d2872fbb 100644 --- a/ckan/templates/package/snippets/package_form.html +++ b/ckan/templates/package/snippets/package_form.html @@ -33,15 +33,13 @@

          {% endblock %} {% block delete_button %} - {% if h.check_access('package_delete', {'id': data.id}) %} + {% if h.check_access('package_delete', {'id': data.id}) and not data.state == 'deleted' %} {% set locale = h.dump_json({'content': _('Are you sure you want to delete this dataset?')}) %} {% block delete_button_text %}{{ _('Delete') }}{% endblock %} {% endif %} {% endblock %} - {% block cancel_button %} - {% block cancel_button_text %}{{ _('Cancel') }}{% endblock %} - {% endblock %} + {{ form.required_message() }}
          {% endblock %} diff --git a/ckan/templates/package/snippets/package_metadata_fields.html b/ckan/templates/package/snippets/package_metadata_fields.html index 4805e6637f9..b398818b297 100644 --- a/ckan/templates/package/snippets/package_metadata_fields.html +++ b/ckan/templates/package/snippets/package_metadata_fields.html @@ -29,22 +29,6 @@ {% endblock %} {% block dataset_fields %} - {% if data.groups %} -
          - -
          - {% for group in data.groups %} - - {% endfor %} -
          -
          - {% endif %} - {% set group_name = 'groups__%s__id' % data.groups|length %} - {% set group_attrs = {'data-module': 'autocomplete', 'data-module-source': '/api/2/util/group/autocomplete?q=?', 'data-module-key': 'id', 'data-module-label': 'title'} %} - {{ form.input(group_name, label=_('Add Group'), id="field-group", value=data[group_name], classes=['control-medium'], attrs=group_attrs) }} {% endblock %} {% endblock %} diff --git a/ckan/templates/package/snippets/resource_form.html b/ckan/templates/package/snippets/resource_form.html index f3bb2bddc91..3c5cb06da28 100644 --- a/ckan/templates/package/snippets/resource_form.html +++ b/ckan/templates/package/snippets/resource_form.html @@ -4,7 +4,7 @@ {% set errors = errors or {} %} {% set action = form_action or h.url_for(controller='package', action='new_resource', id=pkg_name) %} -
          + {% block stages %} {# An empty stages variable will not show the stages #} {% if stage %} @@ -16,29 +16,12 @@ -
          - {% block basic_fields %} - - {% block basic_fields_data %} -
          - {# - This block uses a slightly odd pattern. Unlike the rest of the radio - buttons which are wrapped _inside_ the labels here we place the label - after the input. This enables us to style the label based on the state - of the radio using css. eg. input[type=radio]+label {} - #} - - - - - - -
          -
          - {% endblock %} {% block basic_fields_url %} - {{ form.input('url', id='field-url', label=_('Resource'), placeholder=_('eg. http://example.com/gold-prices-jan-2011.json'), value=data.url, error=errors.url, classes=['control-full', 'control-large'], is_required=true) }} + {% set is_upload = (data.url_type == 'upload') %} + {{ form.image_upload(data, errors, field_url='url', field_upload='upload', field_clear='clear_upload', + is_upload_enabled=h.uploads_enabled(), is_url=data.url and not is_upload, is_upload=is_upload, + upload_label=_('File'), url_label=_('URL')) }} {% endblock %} {% block basic_fields_name %} @@ -52,16 +35,9 @@ {% block basic_fields_format %} {% set format_attrs = {'data-module': 'autocomplete', 'data-module-source': '/api/2/util/resource/format_autocomplete?incomplete=?'} %} {% call form.input('format', id='field-format', label=_('Format'), placeholder=_('eg. CSV, XML or JSON'), value=data.format, error=errors.format, classes=['control-medium'], attrs=format_attrs) %} - - - {{ _('This is generated automatically. You can edit if you wish') }} - {% endcall %} {% endblock %} - {{ form.required_message() }} - - {% endblock %} {% block metadata_fields %} {% if include_metadata %} diff --git a/ckan/templates/package/snippets/resource_item.html b/ckan/templates/package/snippets/resource_item.html index d543282e025..2ea90787e10 100644 --- a/ckan/templates/package/snippets/resource_item.html +++ b/ckan/templates/package/snippets/resource_item.html @@ -2,7 +2,7 @@ {% set url_action = 'resource_edit' if url_is_edit and can_edit else 'resource_read' %} {% set url = h.url_for(controller='package', action=url_action, id=pkg.name, resource_id=res.id) %} -
        • +
        • {% block resource_item_title %} {{ h.resource_display_name(res) | truncate(50) }}{{ res.format }} @@ -12,8 +12,6 @@

          {% if res.description %} {{ h.markdown_extract(res.description, extract_length=80) }} - {% else %} - {{ _('No description for this resource') }} {% endif %}

          {% block resource_item_explore %} diff --git a/ckan/templates/related/snippets/related_item.html b/ckan/templates/related/snippets/related_item.html index 39231a7a217..2053f7c0406 100644 --- a/ckan/templates/related/snippets/related_item.html +++ b/ckan/templates/related/snippets/related_item.html @@ -11,16 +11,14 @@ #} {% set placeholder_map = { -'application':'/base/images/placeholder-application.png' +'application': h.url_for_static('/base/images/placeholder-application.png') } %} {% set tooltip = _('Go to {related_item_type}').format(related_item_type=related.type|replace('_', ' ')|title) %}
      {% if package.resources and not hide_resources %} diff --git a/ckan/templates/snippets/package_list.html b/ckan/templates/snippets/package_list.html index 99aed748242..75213d535a3 100644 --- a/ckan/templates/snippets/package_list.html +++ b/ckan/templates/snippets/package_list.html @@ -15,7 +15,7 @@ #} {% if packages %} -
        +
          {% for package in packages %} {% snippet 'snippets/package_item.html', package=package, item_class=item_class, hide_resources=hide_resources, banner=banner, truncate=truncate, truncate_title=truncate_title %} {% endfor %} diff --git a/ckan/templates/snippets/search_form.html b/ckan/templates/snippets/search_form.html index 6fdd9e1638e..d0c6bd95887 100644 --- a/ckan/templates/snippets/search_form.html +++ b/ckan/templates/snippets/search_form.html @@ -1,10 +1,11 @@ {% import 'macros/form.html' as form %} -{% set placeholder = placeholder if placeholder else _('Search...') %} +{% set placeholder = placeholder if placeholder else _('Search datasets...') %} {% set sorting = sorting if sorting else [(_('Name Ascending'), 'name asc'), (_('Name Descending'), 'name desc')] %} {% set search_class = search_class if search_class else 'search-giant' %} +{% set no_bottom_border = no_bottom_border if no_bottom_border else false %} - + {% block search_input %}
          diff --git a/ckan/templates/snippets/search_result_text.html b/ckan/templates/snippets/search_result_text.html index 0cd48bf2d06..2cdc1f0859a 100644 --- a/ckan/templates/snippets/search_result_text.html +++ b/ckan/templates/snippets/search_result_text.html @@ -13,21 +13,21 @@ #} {% if type == 'dataset' %} {% set text_query = ungettext('{number} dataset found for "{query}"', '{number} datasets found for "{query}"', count) %} - {% set text_query_none = _('Sorry no datasets found for "{query}"') %} + {% set text_query_none = _('No datasets found for "{query}"') %} {% set text_no_query = ungettext('{number} dataset found', '{number} datasets found', count) %} - {% set text_no_query_none = _('Sorry no datasets found') %} + {% set text_no_query_none = _('No datasets found') %} {% elif type == 'group' %} {% set text_query = ungettext('{number} group found for "{query}"', '{number} groups found for "{query}"', count) %} - {% set text_query_none = _('Sorry no groups found for "{query}"') %} + {% set text_query_none = _('No groups found for "{query}"') %} {% set text_no_query = ungettext('{number} group found', '{number} groups found', count) %} - {% set text_no_query_none = _('Sorry no groups found') %} + {% set text_no_query_none = _('No groups found') %} {% elif type == 'organization' %} {% set text_query = ungettext('{number} organization found for "{query}"', '{number} organizations found for "{query}"', count) %} - {% set text_query_none = _('Sorry no organizations found for "{query}"') %} + {% set text_query_none = _('No organizations found for "{query}"') %} {% set text_no_query = ungettext('{number} organization found', '{number} organizations found', count) %} - {% set text_no_query_none = _('Sorry no organizations found') %} + {% set text_no_query_none = _('No organizations found') %} {%- endif -%} {% if query %} diff --git a/ckan/templates/user/activity_stream.html b/ckan/templates/user/activity_stream.html index 092e6ea8485..290d34d5fa6 100644 --- a/ckan/templates/user/activity_stream.html +++ b/ckan/templates/user/activity_stream.html @@ -1,6 +1,6 @@ {% extends "user/read.html" %} -{% block subtitle %}{{ _('Activity Stream') }}{% endblock %} +{% block subtitle %}{{ _('Activity Stream') }} - {{ super() }}{% endblock %} {% block primary_content_inner %}

          {% block page_heading %}{{ _('Activity Stream') }}{% endblock %}

          diff --git a/ckan/templates/user/dashboard.html b/ckan/templates/user/dashboard.html index 3109a632036..dd6c346e6ec 100644 --- a/ckan/templates/user/dashboard.html +++ b/ckan/templates/user/dashboard.html @@ -1,5 +1,7 @@ {% extends "user/edit_base.html" %} +{% set user = c.userobj %} + {% block breadcrumb_content %}
        • {{ _('Dashboard') }}
        • {% endblock %} diff --git a/ckan/templates/user/edit.html b/ckan/templates/user/edit.html index 3845684f78d..1f5b7251b7a 100644 --- a/ckan/templates/user/edit.html +++ b/ckan/templates/user/edit.html @@ -3,14 +3,9 @@ {% block actions_content %}{% endblock %} {% block breadcrumb_content %} - {% if c.userobj.name == c.user_dict.name %} -
        • {{ _('Dashboard') }}
        • -
        • {{ _('Edit settings') }}
        • - {% else %} -
        • {{ _('Users') }}
        • -
        • {{ c.user_dict.display_name }}
        • -
        • {{ _('Edit') }}
        • - {% endif %} +
        • {{ _('Users') }}
        • +
        • {{ c.user_dict.display_name }}
        • +
        • {{ _('Manage') }}
        • {% endblock %} {% block primary_content_inner %} diff --git a/ckan/templates/user/edit_base.html b/ckan/templates/user/edit_base.html index 7e29720a60d..5ca813fe31d 100644 --- a/ckan/templates/user/edit_base.html +++ b/ckan/templates/user/edit_base.html @@ -1,8 +1,6 @@ {% extends "page.html" %} -{% set user = c.userobj %} - -{% block subtitle %}{{ _('Dashboard') }}{% endblock %} +{% block subtitle %}{{ _('Manage') }} - {{ c.user_dict.display_name }} - {{ _('Users') }}{% endblock %} {% block primary_content %}
          diff --git a/ckan/templates/user/edit_user_form.html b/ckan/templates/user/edit_user_form.html index ce31e34b583..b62f5fd9f08 100644 --- a/ckan/templates/user/edit_user_form.html +++ b/ckan/templates/user/edit_user_form.html @@ -4,7 +4,7 @@ {{ form.errors(error_summary) }}
          - {{ _('Change your details') }} + {{ _('Change details') }} {{ form.input('name', label=_('Username'), id='field-username', value=data.name, error=errors.name, classes=['control-medium'], is_required=true) }} @@ -20,12 +20,10 @@ {% endcall %} {% endif %} - {{ form.required_message() }} -
          - {{ _('Change your password') }} + {{ _('Change password') }} {{ form.input('password1', type='password', label=_('Password'), id='field-password', value=data.password1, error=errors.password1, classes=['control-medium'], attrs={'autocomplete': 'off'} ) }} @@ -39,6 +37,7 @@ {% block delete_button_text %}{{ _('Delete') }}{% endblock %} {% endif %} {% endblock %} + {{ form.required_message() }}
          diff --git a/ckan/templates/user/login.html b/ckan/templates/user/login.html index a00796b4586..03131dfe5f4 100644 --- a/ckan/templates/user/login.html +++ b/ckan/templates/user/login.html @@ -18,15 +18,17 @@

          {% block page_heading %}{{ _('Login') }}{% endblock %}< {% endblock %} {% block secondary_content %} -
          -

          {{ _('Need an Account?') }}

          -
          -

          {% trans %}Then sign right up, it only takes a minute.{% endtrans %}

          -

          - {{ _('Create an Account') }} -

          -
          -
          + {% if h.check_access('user_create') %} +
          +

          {{ _('Need an Account?') }}

          +
          +

          {% trans %}Then sign right up, it only takes a minute.{% endtrans %}

          +

          + {{ _('Create an Account') }} +

          +
          +
          + {% endif %}

          {{ _('Forgotten your password?') }}

          diff --git a/ckan/templates/user/read.html b/ckan/templates/user/read.html index 13f58fc3054..2ca0a920f41 100644 --- a/ckan/templates/user/read.html +++ b/ckan/templates/user/read.html @@ -1,7 +1,5 @@ {% extends "user/read_base.html" %} -{% block subtitle %}{{ user.display_name }}{% endblock %} - {% block primary_content_inner %}

          {% block page_heading %}{{ _('Datasets') }}{% endblock %} diff --git a/ckan/templates/user/read_base.html b/ckan/templates/user/read_base.html index 3a24c21c5a1..9fb39fd2998 100644 --- a/ckan/templates/user/read_base.html +++ b/ckan/templates/user/read_base.html @@ -2,7 +2,7 @@ {% set user = c.user_dict %} -{% block subtitle %}{{ user.display_name }}{% endblock %} +{% block subtitle %}{{ user.display_name }} - {{ _('Users') }}{% endblock %} {% block breadcrumb_content %} {{ h.build_nav('user_index', _('Users')) }} @@ -10,9 +10,9 @@ {% endblock %} {% block content_action %} -{% if h.check_access('user_update', user) %} - {% link_for _('Edit'), controller='user', action='edit', id=user.name, class_='btn', icon='wrench' %} -{% endif %} + {% if h.check_access('user_update', user) %} + {% link_for _('Manage'), controller='user', action='edit', id=user.name, class_='btn', icon='wrench' %} + {% endif %} {% endblock %} {% block content_primary_nav %} diff --git a/ckan/templates/user/snippets/back_to_user_action.html b/ckan/templates/user/snippets/back_to_user_action.html deleted file mode 100644 index 8ca7494916f..00000000000 --- a/ckan/templates/user/snippets/back_to_user_action.html +++ /dev/null @@ -1 +0,0 @@ -
        • {% link_for _('View my profile'), controller='user', action='read', id=user.name, class_='btn', icon='eye-open' %}
        • diff --git a/ckan/templates/user/snippets/login_form.html b/ckan/templates/user/snippets/login_form.html index d1b54251137..e6a88d95902 100644 --- a/ckan/templates/user/snippets/login_form.html +++ b/ckan/templates/user/snippets/login_form.html @@ -24,6 +24,6 @@ {{ form.checkbox('remember', label=_("Remember me"), id='field-remember', checked=true, value="63072000") }}
          - +
          diff --git a/ckan/tests/functional/api/test_activity.py b/ckan/tests/functional/api/test_activity.py index bd42409edd6..f093a200a7c 100644 --- a/ckan/tests/functional/api/test_activity.py +++ b/ckan/tests/functional/api/test_activity.py @@ -573,17 +573,17 @@ def _update_extra(self, package_dict, user): [group['name'] for group in package_dict['groups']], apikey=apikey) - extras_before = package_dict['extras'] + import copy + extras_before = copy.deepcopy(package_dict['extras']) assert len(extras_before) > 0, ( "Can't update an extra if the package doesn't have any") # Update the package's first extra. - extras = list(extras_before) - if extras[0]['value'] != '"edited"': - extras[0]['value'] = '"edited"' + if package_dict['extras'][0]['value'] != '"edited"': + package_dict['extras'][0]['value'] = '"edited"' else: - assert extras[0]['value'] != '"edited again"' - extras[0]['value'] = '"edited again"' + assert package_dict['extras'][0]['value'] != '"edited again"' + package_dict['extras'][0]['value'] = '"edited again"' updated_package = package_update(self.app, package_dict, user['apikey']) diff --git a/ckan/tests/functional/api/test_user.py b/ckan/tests/functional/api/test_user.py index 317428de865..875e8a668ab 100644 --- a/ckan/tests/functional/api/test_user.py +++ b/ckan/tests/functional/api/test_user.py @@ -1,20 +1,27 @@ +import paste +from pylons import config from nose.tools import assert_equal import ckan.logic as logic +import ckan.new_authz as new_authz from ckan import model from ckan.lib.create_test_data import CreateTestData from ckan.tests import TestController as ControllerTestCase +from ckan.tests.pylons_controller import PylonsTestCase from ckan.tests import url_for +import ckan.config.middleware +from ckan.common import json + class TestUserApi(ControllerTestCase): @classmethod def setup_class(cls): CreateTestData.create() - + @classmethod def teardown_class(cls): model.repo.rebuild_db() - + def test_autocomplete(self): response = self.app.get( url=url_for(controller='api', action='user_autocomplete', ver=2), @@ -51,8 +58,186 @@ def test_autocomplete_limit(self): print response.json assert_equal(len(response.json), 1) + +class TestCreateUserApiDisabled(PylonsTestCase): + ''' + Tests for the creating user when create_user_via_api is disabled. + ''' + + @classmethod + def setup_class(cls): + CreateTestData.create() + cls._original_config = config.copy() + new_authz.clear_auth_functions_cache() + wsgiapp = ckan.config.middleware.make_app( + config['global_conf'], **config) + cls.app = paste.fixture.TestApp(wsgiapp) + cls.sysadmin_user = model.User.get('testsysadmin') + PylonsTestCase.setup_class() + + @classmethod + def teardown_class(cls): + config.clear() + config.update(cls._original_config) + new_authz.clear_auth_functions_cache() + PylonsTestCase.teardown_class() + + model.repo.rebuild_db() + + def test_user_create_api_enabled_sysadmin(self): + params = { + 'name': 'testinganewusersysadmin', + 'email': 'testinganewuser@ckan.org', + 'password': 'random', + } + res = self.app.post( + '/api/3/action/user_create', + json.dumps(params), + extra_environ={'Authorization': str(self.sysadmin_user.apikey)}, + expect_errors=True) + res_dict = res.json + assert res_dict['success'] is True + + def test_user_create_api_disabled_anon(self): + params = { + 'name': 'testinganewuseranon', + 'email': 'testinganewuser@ckan.org', + 'password': 'random', + } + res = self.app.post('/api/3/action/user_create', json.dumps(params), + expect_errors=True) + res_dict = res.json + assert res_dict['success'] is False + + +class TestCreateUserApiEnabled(PylonsTestCase): + ''' + Tests for the creating user when create_user_via_api is enabled. + ''' + + @classmethod + def setup_class(cls): + CreateTestData.create() + cls._original_config = config.copy() + config['ckan.auth.create_user_via_api'] = True + new_authz.clear_auth_functions_cache() + wsgiapp = ckan.config.middleware.make_app( + config['global_conf'], **config) + cls.app = paste.fixture.TestApp(wsgiapp) + PylonsTestCase.setup_class() + cls.sysadmin_user = model.User.get('testsysadmin') + + @classmethod + def teardown_class(cls): + config.clear() + config.update(cls._original_config) + new_authz.clear_auth_functions_cache() + PylonsTestCase.teardown_class() + + model.repo.rebuild_db() + + def test_user_create_api_enabled_sysadmin(self): + params = { + 'name': 'testinganewusersysadmin', + 'email': 'testinganewuser@ckan.org', + 'password': 'random', + } + res = self.app.post( + '/api/3/action/user_create', + json.dumps(params), + extra_environ={'Authorization': str(self.sysadmin_user.apikey)}) + res_dict = res.json + assert res_dict['success'] is True + + def test_user_create_api_enabled_anon(self): + params = { + 'name': 'testinganewuseranon', + 'email': 'testinganewuser@ckan.org', + 'password': 'random', + } + res = self.app.post('/api/3/action/user_create', json.dumps(params)) + res_dict = res.json + assert res_dict['success'] is True + + +class TestCreateUserWebDisabled(PylonsTestCase): + ''' + Tests for the creating user by create_user_via_web is disabled. + ''' + + @classmethod + def setup_class(cls): + CreateTestData.create() + cls._original_config = config.copy() + config['ckan.auth.create_user_via_web'] = False + new_authz.clear_auth_functions_cache() + wsgiapp = ckan.config.middleware.make_app( + config['global_conf'], **config) + cls.app = paste.fixture.TestApp(wsgiapp) + cls.sysadmin_user = model.User.get('testsysadmin') + PylonsTestCase.setup_class() + + @classmethod + def teardown_class(cls): + config.clear() + config.update(cls._original_config) + new_authz.clear_auth_functions_cache() + PylonsTestCase.teardown_class() + + model.repo.rebuild_db() + + def test_user_create_api_disabled(self): + params = { + 'name': 'testinganewuser', + 'email': 'testinganewuser@ckan.org', + 'password': 'random', + } + res = self.app.post('/api/3/action/user_create', json.dumps(params), + expect_errors=True) + res_dict = res.json + assert res_dict['success'] is False + + +class TestCreateUserWebEnabled(PylonsTestCase): + ''' + Tests for the creating user by create_user_via_web is enabled. + ''' + + @classmethod + def setup_class(cls): + CreateTestData.create() + cls._original_config = config.copy() + config['ckan.auth.create_user_via_web'] = True + new_authz.clear_auth_functions_cache() + wsgiapp = ckan.config.middleware.make_app( + config['global_conf'], **config) + cls.app = paste.fixture.TestApp(wsgiapp) + cls.sysadmin_user = model.User.get('testsysadmin') + PylonsTestCase.setup_class() + + @classmethod + def teardown_class(cls): + config.clear() + config.update(cls._original_config) + new_authz.clear_auth_functions_cache() + PylonsTestCase.teardown_class() + + model.repo.rebuild_db() + + def test_user_create_api_disabled(self): + params = { + 'name': 'testinganewuser', + 'email': 'testinganewuser@ckan.org', + 'password': 'random', + } + res = self.app.post('/api/3/action/user_create', json.dumps(params), + expect_errors=True) + res_dict = res.json + assert res_dict['success'] is False + + class TestUserActions(object): - + @classmethod def setup_class(cls): CreateTestData.create() diff --git a/ckan/tests/functional/test_group.py b/ckan/tests/functional/test_group.py index c5dc63b1097..f8a968f09cd 100644 --- a/ckan/tests/functional/test_group.py +++ b/ckan/tests/functional/test_group.py @@ -3,7 +3,6 @@ from nose.tools import assert_equal import mock -import ckan.tests.test_plugins as test_plugins import ckan.model as model import ckan.lib.search as search @@ -16,7 +15,6 @@ from ckan.tests import is_search_supported - class TestGroup(FunctionalTestCase): @classmethod @@ -25,6 +23,10 @@ def setup_class(self): model.Session.remove() CreateTestData.create() + # reduce extraneous logging + from ckan.lib import activity_streams_session_extension + activity_streams_session_extension.logger.level = 100 + @classmethod def teardown_class(self): model.repo.rebuild_db() @@ -56,14 +58,14 @@ def test_atom_feed_page_negative(self): assert '"page" parameter must be a positive integer' in res, res def test_children(self): - if model.engine_is_sqlite() : + if model.engine_is_sqlite(): from nose import SkipTest raise SkipTest("Can't use CTE for sqlite") group_name = 'deletetest' CreateTestData.create_groups([{'name': group_name, 'packages': []}, - {'name': "parent_group", + {'name': "parent_group", 'packages': []}], admin_user_name='testsysadmin') @@ -73,7 +75,7 @@ def test_children(self): rev = model.repo.new_revision() rev.author = "none" - member = model.Member(group_id=parent.id, table_id=group.id, + member = model.Member(group_id=group.id, table_id=parent.id, table_name='group', capacity='member') model.Session.add(member) model.repo.commit_and_remove() @@ -103,17 +105,21 @@ def test_children(self): def test_sorting(self): model.repo.rebuild_db() + testsysadmin = model.User(name=u'testsysadmin') + testsysadmin.sysadmin = True + model.Session.add(testsysadmin) + pkg1 = model.Package(name="pkg1") pkg2 = model.Package(name="pkg2") model.Session.add(pkg1) model.Session.add(pkg2) CreateTestData.create_groups([{'name': "alpha", 'packages': []}, - {'name': "beta", - 'packages': ["pkg1", "pkg2"]}, - {'name': "delta", - 'packages': ["pkg1"]}, - {'name': "gamma", 'packages': []}], + {'name': "beta", + 'packages': ["pkg1", "pkg2"]}, + {'name': "delta", + 'packages': ["pkg1"]}, + {'name': "gamma", 'packages': []}], admin_user_name='testsysadmin') context = {'model': model, 'session': model.Session, @@ -148,7 +154,6 @@ def test_sorting(self): assert results[0]['name'] == u'beta', results[0]['name'] assert results[1]['name'] == u'delta', results[1]['name'] - def test_mainmenu(self): # the home page does a package search so have to skip this test if # search is not supported @@ -198,14 +203,16 @@ def test_read_and_authorized_to_edit(self): title = u'Dave\'s books' pkgname = u'warandpeace' offset = url_for(controller='group', action='read', id=name) - res = self.app.get(offset, extra_environ={'REMOTE_USER': 'testsysadmin'}) + res = self.app.get(offset, + extra_environ={'REMOTE_USER': 'testsysadmin'}) assert title in res, res assert 'edit' in res assert name in res def test_new_page(self): offset = url_for(controller='group', action='new') - res = self.app.get(offset, extra_environ={'REMOTE_USER': 'testsysadmin'}) + res = self.app.get(offset, + extra_environ={'REMOTE_USER': 'testsysadmin'}) assert 'Add A Group' in res, res @@ -258,7 +265,6 @@ def setup_class(self): model.Session.add(model.Package(name=self.packagename)) model.repo.commit_and_remove() - @classmethod def teardown_class(self): model.Session.remove() @@ -662,6 +668,7 @@ def test_2_atom_feed(self): assert 'xmlns="http://www.w3.org/2005/Atom"' in res, res assert '' in res, res + class TestMemberInvite(FunctionalTestCase): @classmethod def setup_class(self): diff --git a/ckan/tests/functional/test_package.py b/ckan/tests/functional/test_package.py index 662ad0c2458..23a8bb48247 100644 --- a/ckan/tests/functional/test_package.py +++ b/ckan/tests/functional/test_package.py @@ -6,6 +6,7 @@ from nose.tools import assert_equal from ckan.tests import * +import ckan.tests as tests from ckan.tests.html_check import HtmlCheckMethods from ckan.tests.pylons_controller import PylonsTestCase from base import FunctionalTestCase @@ -1505,3 +1506,43 @@ def test_package_autocomplete(self): expected = ['A Wonderful Story (warandpeace)|warandpeace','annakarenina|annakarenina'] received = sorted(res.body.split('\n')) assert expected == received + +class TestResourceListing(TestPackageBase): + @classmethod + def setup_class(cls): + + CreateTestData.create() + cls.tester_user = model.User.by_name(u'tester') + cls.extra_environ_admin = {'REMOTE_USER': 'testsysadmin'} + cls.extra_environ_tester = {'REMOTE_USER': 'tester'} + cls.extra_environ_someone_else = {'REMOTE_USER': 'someone_else'} + + tests.call_action_api(cls.app, 'organization_create', + name='test_org_2', + apikey=cls.tester_user.apikey) + + tests.call_action_api(cls.app, 'package_create', + name='crimeandpunishment', + owner_org='test_org_2', + apikey=cls.tester_user.apikey) + + @classmethod + def teardown_class(cls): + model.repo.rebuild_db() + + def test_resource_listing_premissions_sysadmin(self): + # sysadmin 200 + self.app.get('/dataset/resources/crimeandpunishment', extra_environ=self.extra_environ_admin, status=200) + + def test_resource_listing_premissions_auth_user(self): + # auth user 200 + self.app.get('/dataset/resources/crimeandpunishment', extra_environ=self.extra_environ_tester, status=200) + + def test_resource_listing_premissions_non_auth_user(self): + # non auth user 401 + self.app.get('/dataset/resources/crimeandpunishment', extra_environ=self.extra_environ_someone_else, status=[302,401]) + + def test_resource_listing_premissions_not_logged_in(self): + # not logged in 401 + self.app.get('/dataset/resources/crimeandpunishment', status=[302,401]) + diff --git a/ckan/tests/functional/test_related.py b/ckan/tests/functional/test_related.py index 6fa2fed4266..9e65917fb2a 100644 --- a/ckan/tests/functional/test_related.py +++ b/ckan/tests/functional/test_related.py @@ -367,8 +367,13 @@ def test_related_list_missing_id_and_name(self): usr = logic.get_action('get_site_user')({'model':model,'ignore_auth': True},{}) context = dict(model=model, user=usr['name'], session=model.Session) data_dict = {} - from sqlalchemy.orm.query import Query - assert type(logic.get_action('related_list')(context, data_dict)) == Query + related_list = logic.get_action('related_list')(context, data_dict) + assert len(related_list) == 8 + related_keys = set(['view_count', 'description', 'title', 'url', + 'created', 'featured', 'image_url', 'type', 'id', 'owner_id']) + for related in related_list: + assert set(related.keys()) == related_keys + def test_related_list(self): p = model.Package.get('warandpeace') diff --git a/ckan/tests/lib/test_accept.py b/ckan/tests/lib/test_accept.py index 8234ab9a087..13c065e4b8c 100644 --- a/ckan/tests/lib/test_accept.py +++ b/ckan/tests/lib/test_accept.py @@ -56,10 +56,3 @@ def test_accept_valid6(self): assert_equal( ct, "application/rdf+xml; charset=utf-8") assert_equal( markup, True) assert_equal( ext, "rdf") - - def test_accept_valid7(self): - a = "text/turtle,application/turtle,application/rdf+xml;q=0.8,text/plain;q=0.9,*/*;q=.5" - ct, markup, ext = accept.parse_header(a) - assert_equal( ct, "text/plain; charset=utf-8") - assert_equal( markup, False) - assert_equal( ext, "txt") diff --git a/ckan/tests/lib/test_dictization.py b/ckan/tests/lib/test_dictization.py index ff376e628b8..954e3d5d4aa 100644 --- a/ckan/tests/lib/test_dictization.py +++ b/ckan/tests/lib/test_dictization.py @@ -48,6 +48,8 @@ def setup_class(cls): 'name': u'david', 'capacity': 'public', 'image_url': u'', + 'image_display_url': u'', + 'display_name': u"Dave's books", 'type': u'group', 'state': u'active', 'is_organization': False, @@ -57,6 +59,8 @@ def setup_class(cls): 'name': u'roger', 'capacity': 'public', 'image_url': u'', + 'image_display_url': u'', + 'display_name': u"Roger's books", 'type': u'group', 'state': u'active', 'is_organization': False, @@ -877,8 +881,6 @@ def test_16_group_dictized(self): context = {"model": model, "session": model.Session} - pkg = model.Session.query(model.Package).filter_by(name='annakarenina3').first() - simple_group_dict = {'name': 'simple', 'title': 'simple', 'type': 'organization', @@ -896,7 +898,7 @@ def test_16_group_dictized(self): 'approval_status': 'approved', 'extras': [{'key': 'genre', 'value': u'"horror"'}, {'key': 'media', 'value': u'"dvd"'}], - 'packages':[{'name': 'annakarenina2'}, {'id': pkg.id, 'capacity': 'in'}], + 'packages':[{'name': 'annakarenina2'}], 'users':[{'name': 'annafan'}], 'groups':[{'name': 'simple'}], 'tags':[{'name': 'russian'}] @@ -923,6 +925,7 @@ def test_16_group_dictized(self): 'capacity' : 'public', 'display_name': u'simple', 'image_url': u'', + 'image_display_url': u'', 'name': u'simple', 'packages': 0, 'state': u'active', @@ -945,41 +948,43 @@ def test_16_group_dictized(self): 'name': u'help', 'display_name': u'help', 'image_url': u'', - 'package_count': 2, + 'image_display_url': u'', + 'package_count': 1, 'is_organization': False, - 'packages': [{'author': None, - 'author_email': None, - 'license_id': u'other-open', - 'maintainer': None, - 'maintainer_email': None, - 'type': u'dataset', - 'name': u'annakarenina3', - 'notes': u'Some test notes\n\n### A 3rd level heading\n\n**Some bolded text.**\n\n*Some italicized text.*\n\nForeign characters:\nu with umlaut \xfc\n66-style quote \u201c\nforeign word: th\xfcmb\n\nNeeds escaping:\nleft arrow <\n\n\n\n', - 'state': u'active', - 'capacity' : 'in', - 'title': u'A Novel By Tolstoy', - 'private': False, - 'creator_user_id': None, - 'owner_org': None, - 'url': u'http://www.annakarenina.com', - 'version': u'0.7a'}, - {'author': None, - 'author_email': None, - 'capacity' : 'public', - 'title': u'A Novel By Tolstoy', - 'license_id': u'other-open', - 'maintainer': None, - 'maintainer_email': None, - 'type': u'dataset', - 'name': u'annakarenina2', - 'notes': u'Some test notes\n\n### A 3rd level heading\n\n**Some bolded text.**\n\n*Some italicized text.*\n\nForeign characters:\nu with umlaut \xfc\n66-style quote \u201c\nforeign word: th\xfcmb\n\nNeeds escaping:\nleft arrow <\n\n\n\n', - 'state': u'active', - 'title': u'A Novel By Tolstoy', - 'private': False, - 'creator_user_id': None, - 'owner_org': None, - 'url': u'http://www.annakarenina.com', - 'version': u'0.7a'}], + 'packages': [{u'author': None, + u'author_email': None, + u'creator_user_id': None, + u'extras': [], + u'groups':[ + {u'title': u'help', + u'display_name': u'help', + u'description': u'', + u'name': u'help', + u'image_display_url': u''} + ], + u'isopen': True, + u'license_id': u'other-open', + u'license_title': u'Other (Open)', + u'maintainer': None, + u'maintainer_email': None, + u'name': u'annakarenina2', + u'notes': u'Some test notes\n\n### A 3rd level heading\n\n**Some bolded text.**\n\n*Some italicized text.*\n\nForeign characters:\nu with umlaut \xfc\n66-style quote \u201c\nforeign word: th\xfcmb\n\nNeeds escaping:\nleft arrow <\n\n\n\n', + u'num_resources': 0, + u'num_tags': 0, + u'organization': None, + u'owner_org': None, + u'private': False, + u'relationships_as_object': [], + u'relationships_as_subject': [], + u'resources': [], + u'state': u'active', + u'tags': [], + u'title': u'A Novel By Tolstoy', + u'tracking_summary': {u'recent': 0, u'total': 0}, + u'type': u'dataset', + u'url': u'http://www.annakarenina.com', + u'version': u'0.7a'}, + ], 'state': u'active', 'approval_status': u'approved', 'title': u'help', diff --git a/ckan/tests/lib/test_dictization_schema.py b/ckan/tests/lib/test_dictization_schema.py index 5a94d2e54e8..9507be2806f 100644 --- a/ckan/tests/lib/test_dictization_schema.py +++ b/ckan/tests/lib/test_dictization_schema.py @@ -160,6 +160,7 @@ def test_2_group_schema(self): 'is_organization': False, 'type': u'group', 'image_url': u'', + 'image_display_url': u'', 'packages': sorted([{'id': group_pack[0].id, 'name': group_pack[0].name, 'title': group_pack[0].title}, diff --git a/ckan/tests/lib/test_solr_schema_version.py b/ckan/tests/lib/test_solr_schema_version.py index 2dbc06f5bc7..344603274c4 100644 --- a/ckan/tests/lib/test_solr_schema_version.py +++ b/ckan/tests/lib/test_solr_schema_version.py @@ -1,5 +1,4 @@ import os -from pylons import config from ckan.tests import TestController class TestSolrSchemaVersionCheck(TestController): @@ -11,11 +10,7 @@ def setup_class(cls): def _get_current_schema(self): - from ckan.lib.search import SUPPORTED_SCHEMA_VERSIONS - - current_version = sorted(SUPPORTED_SCHEMA_VERSIONS).pop() - - current_schema = os.path.join(self.root_dir,'..','..','config','solr','schema-%s.xml' % current_version) + current_schema = os.path.join(self.root_dir,'..','..','config','solr','schema.xml') return current_schema diff --git a/ckan/tests/logic/test_action.py b/ckan/tests/logic/test_action.py index 87e23ad390c..a219d395afb 100644 --- a/ckan/tests/logic/test_action.py +++ b/ckan/tests/logic/test_action.py @@ -84,6 +84,36 @@ def test_01_package_list(self): assert 'warandpeace' in res['result'] assert 'annakarenina' in res['result'] + def test_01_package_list_private(self): + tests.call_action_api(self.app, 'organization_create', + name='test_org_2', + apikey=self.sysadmin_user.apikey) + + tests.call_action_api(self.app, 'package_create', + name='public_dataset', + owner_org='test_org_2', + apikey=self.sysadmin_user.apikey) + + res = tests.call_action_api(self.app, 'package_list') + + assert len(res) == 3 + assert 'warandpeace' in res + assert 'annakarenina' in res + assert 'public_dataset' in res + + tests.call_action_api(self.app, 'package_create', + name='private_dataset', + owner_org='test_org_2', + private=True, + apikey=self.sysadmin_user.apikey) + + res = tests.call_action_api(self.app, 'package_list') + assert len(res) == 3 + assert 'warandpeace' in res + assert 'annakarenina' in res + assert 'public_dataset' in res + assert not 'private_dataset' in res + def test_01_current_package_list_with_resources(self): url = '/api/action/current_package_list_with_resources' @@ -802,10 +832,10 @@ def test_19_update_resource(self): resource_updated.pop('url') resource_updated.pop('revision_id') - resource_updated.pop('revision_timestamp') + resource_updated.pop('revision_timestamp', None) resource_created.pop('url') resource_created.pop('revision_id') - resource_created.pop('revision_timestamp') + resource_created.pop('revision_timestamp', None) assert_equal(resource_updated, resource_created) def test_20_task_status_update(self): @@ -2263,3 +2293,69 @@ def _assert_we_can_add_user_to_group(self, user_id, group_id): group_ids = [g.id for g in groups] assert res['success'] is True, res assert group.id in group_ids, (group, user_groups) + + +class TestRelatedAction(WsgiAppCase): + + sysadmin_user = None + + normal_user = None + + @classmethod + def setup_class(cls): + search.clear() + CreateTestData.create() + cls.sysadmin_user = model.User.get('testsysadmin') + + @classmethod + def teardown_class(cls): + model.repo.rebuild_db() + + def _add_basic_package(self, package_name=u'test_package', **kwargs): + package = { + 'name': package_name, + 'title': u'A Novel By Tolstoy', + 'resources': [{ + 'description': u'Full text.', + 'format': u'plain text', + 'url': u'http://www.annakarenina.com/download/' + }] + } + package.update(kwargs) + + postparams = '%s=1' % json.dumps(package) + res = self.app.post('/api/action/package_create', params=postparams, + extra_environ={'Authorization': 'tester'}) + return json.loads(res.body)['result'] + + def test_update_add_related_item(self): + package = self._add_basic_package() + related_item = { + "description": "Testing a Description", + "url": "http://example.com/image.png", + "title": "Testing", + "featured": 0, + "image_url": "http://example.com/image.png", + "type": "idea", + "dataset_id": package['id'], + } + related_item_json = json.dumps(related_item) + res_create = self.app.post('/api/action/related_create', + params=related_item_json, + extra_environ={'Authorization': 'tester'}) + assert res_create.json['success'] + + related_update = res_create.json['result'] + related_update = {'id': related_update['id'], 'title': 'Updated'} + related_update_json = json.dumps(related_update) + res_update = self.app.post('/api/action/related_update', + params=related_update_json, + extra_environ={'Authorization': 'tester'}) + assert res_update.json['success'] + res_update_json = res_update.json['result'] + assert res_update_json['title'] == related_update['title'] + + related_item.pop('title') + related_item.pop('dataset_id') + for field in related_item: + assert related_item[field] == res_update_json[field] diff --git a/ckan/tests/logic/test_auth.py b/ckan/tests/logic/test_auth.py index f532c57d03a..e44cf60efd3 100644 --- a/ckan/tests/logic/test_auth.py +++ b/ckan/tests/logic/test_auth.py @@ -4,6 +4,7 @@ from ckan.logic import get_action import ckan.model as model import ckan.new_authz as new_authz +from ckan.lib.create_test_data import CreateTestData import json INITIAL_TEST_CONFIG_PERMISSIONS = { @@ -13,8 +14,10 @@ 'user_create_organizations': False, 'user_delete_groups': False, 'user_delete_organizations': False, - 'create_user_via_api': False, 'create_unowned_dataset': False, + 'create_user_via_api': False, + 'create_user_via_web': True, + 'roles_that_cascade_to_sub_groups': ['admin'], } @@ -35,19 +38,26 @@ def teardown_class(cls): new_authz.CONFIG_PERMISSIONS.update(cls.old_perm) model.repo.rebuild_db() - def _call_api(self, action, data, user, status=None): + @classmethod + def _call_api(cls, action, data, user, status=None): params = '%s=1' % json.dumps(data) - return self.app.post('/api/action/%s' % action, - params=params, - extra_environ={'Authorization': self.apikeys[user]}, - status=status) + res = cls.app.post('/api/action/%s' % action, + params=params, + extra_environ={'Authorization': cls.apikeys[user]}, + status=[200, 403, 409]) + if res.status != (status or 200): + error = json.loads(res.body)['error'] + raise AssertionError('Status was %s but should be %s. Error: %s' % + (res.status, status, error)) + return res - def create_user(self, name): + @classmethod + def create_user(cls, name): user = {'name': name, 'password': 'pass', 'email': 'moo@moo.com'} - res = self._call_api('user_create', user, 'sysadmin', 200) - self.apikeys[name] = str(json.loads(res.body)['result']['apikey']) + res = cls._call_api('user_create', user, 'sysadmin', 200) + cls.apikeys[name] = str(json.loads(res.body)['result']['apikey']) class TestAuthUsers(TestAuth): @@ -80,9 +90,7 @@ def test_auth_deleted_users_are_always_unauthorized(self): self.create_user(username) user = model.User.get(username) user.delete() - assert not new_authz.is_authorized_boolean('always_success', {'user': username}) - del new_authz._AuthFunctions._functions['always_success'] @@ -116,6 +124,7 @@ def test_02_create_orgs(self): def test_03_create_dataset_no_org(self): + # no owner_org supplied dataset = {'name': 'admin_create_no_org'} self._call_api('package_create', dataset, 'sysadmin', 409) @@ -134,10 +143,8 @@ def test_04_create_dataset_with_org(self): dataset = {'name': 'sysadmin_create_no_user', 'owner_org': org_no_user.json['result']['id']} self._call_api('package_create', dataset, 'sysadmin', 200) - dataset = {'name': 'user_create_with_org', 'owner_org': org_with_user.json['result']['id']} - self._call_api('package_create', dataset, 'no_org', 403) def test_05_add_users_to_org(self): @@ -230,6 +237,214 @@ def test_11_delete_org(self): self._call_api('organization_delete', org, 'org_editor', 403) self._call_api('organization_delete', org, 'org_admin', 403) +ORG_HIERARCHY_PERMISSIONS = { + 'roles_that_cascade_to_sub_groups': ['admin'], + } + +class TestAuthOrgHierarchy(TestAuth): + # Tests are in the same vein as TestAuthOrgs, testing the cases where the + # group hierarchy provides extra permissions through cascading + + @classmethod + def setup_class(cls): + TestAuth.setup_class() + CreateTestData.create_group_hierarchy_test_data() + for user in model.Session.query(model.User): + cls.apikeys[user.name] = str(user.apikey) + new_authz.CONFIG_PERMISSIONS.update(ORG_HIERARCHY_PERMISSIONS) + CreateTestData.create_arbitrary( + package_dicts= [{'name': 'adataset', + 'groups': ['national-health-service']}], + extra_user_names=['john']) + + def _reset_a_datasets_owner_org(self): + rev = model.repo.new_revision() + get_action('package_owner_org_update')( + {'model': model, 'ignore_auth': True}, + {'id': 'adataset', + 'organization_id': 'national-health-service'}) + + def _undelete_package_if_needed(self, package_name): + pkg = model.Package.by_name(package_name) + if pkg and pkg.state == 'deleted': + rev = model.repo.new_revision() + pkg.state = 'active' + model.repo.commit_and_remove() + + def test_05_add_users_to_org_1(self): + member = {'username': 'john', 'role': 'admin', + 'id': 'department-of-health'} + self._call_api('organization_member_create', member, 'nhsadmin', 403) + def test_05_add_users_to_org_2(self): + member = {'username': 'john', 'role': 'editor', + 'id': 'department-of-health'} + self._call_api('organization_member_create', member, 'nhsadmin', 403) + def test_05_add_users_to_org_3(self): + member = {'username': 'john', 'role': 'admin', + 'id': 'national-health-service'} + self._call_api('organization_member_create', member, 'nhsadmin', 200) + def test_05_add_users_to_org_4(self): + member = {'username': 'john', 'role': 'editor', + 'id': 'national-health-service'} + self._call_api('organization_member_create', member, 'nhsadmin', 200) + def test_05_add_users_to_org_5(self): + member = {'username': 'john', 'role': 'admin', + 'id': 'nhs-wirral-ccg'} + self._call_api('organization_member_create', member, 'nhsadmin', 200) + def test_05_add_users_to_org_6(self): + member = {'username': 'john', 'role': 'editor', + 'id': 'nhs-wirral-ccg'} + self._call_api('organization_member_create', member, 'nhsadmin', 200) + def test_05_add_users_to_org_7(self): + member = {'username': 'john', 'role': 'editor', + 'id': 'national-health-service'} + self._call_api('organization_member_create', member, 'nhseditor', 403) + + def test_07_add_datasets_1(self): + dataset = {'name': 't1', 'owner_org': 'department-of-health'} + self._call_api('package_create', dataset, 'nhsadmin', 403) + + def test_07_add_datasets_2(self): + dataset = {'name': 't2', 'owner_org': 'national-health-service'} + self._call_api('package_create', dataset, 'nhsadmin', 200) + + def test_07_add_datasets_3(self): + dataset = {'name': 't3', 'owner_org': 'nhs-wirral-ccg'} + self._call_api('package_create', dataset, 'nhsadmin', 200) + + def test_07_add_datasets_4(self): + dataset = {'name': 't4', 'owner_org': 'department-of-health'} + self._call_api('package_create', dataset, 'nhseditor', 403) + + def test_07_add_datasets_5(self): + dataset = {'name': 't5', 'owner_org': 'national-health-service'} + self._call_api('package_create', dataset, 'nhseditor', 200) + + def test_07_add_datasets_6(self): + dataset = {'name': 't6', 'owner_org': 'nhs-wirral-ccg'} + self._call_api('package_create', dataset, 'nhseditor', 403) + + def test_08_update_datasets_1(self): + dataset = {'name': 'adataset', 'owner_org': 'department-of-health'} + self._call_api('package_update', dataset, 'nhsadmin', 409) + + def test_08_update_datasets_2(self): + dataset = {'name': 'adataset', 'owner_org': 'national-health-service'} + self._call_api('package_update', dataset, 'nhsadmin', 200) + + def test_08_update_datasets_3(self): + dataset = {'name': 'adataset', 'owner_org': 'nhs-wirral-ccg'} + try: + self._call_api('package_update', dataset, 'nhsadmin', 200) + finally: + self._reset_a_datasets_owner_org() + + def test_08_update_datasets_4(self): + dataset = {'name': 'adataset', 'owner_org': 'department-of-health'} + self._call_api('package_update', dataset, 'nhseditor', 409) + + def test_08_update_datasets_5(self): + dataset = {'name': 'adataset', 'owner_org': 'national-health-service'} + try: + self._call_api('package_update', dataset, 'nhseditor', 200) + finally: + self._reset_a_datasets_owner_org() + + def test_08_update_datasets_6(self): + dataset = {'name': 'adataset', 'owner_org': 'nhs-wirral-ccg'} + self._call_api('package_update', dataset, 'nhseditor', 409) + + def test_09_delete_datasets_1(self): + dataset = {'id': 'doh-spend'} + try: + self._call_api('package_delete', dataset, 'nhsadmin', 403) + finally: + self._undelete_package_if_needed(dataset['id']) + + def test_09_delete_datasets_2(self): + dataset = {'id': 'nhs-spend'} + try: + self._call_api('package_delete', dataset, 'nhsadmin', 200) + finally: + self._undelete_package_if_needed(dataset['id']) + + def test_09_delete_datasets_3(self): + dataset = {'id': 'wirral-spend'} + try: + self._call_api('package_delete', dataset, 'nhsadmin', 200) + finally: + self._undelete_package_if_needed(dataset['id']) + + def test_09_delete_datasets_4(self): + dataset = {'id': 'nhs-spend'} + try: + self._call_api('package_delete', dataset, 'nhseditor', 200) + finally: + self._undelete_package_if_needed(dataset['id']) + + def test_09_delete_datasets_5(self): + dataset = {'id': 'wirral-spend'} + try: + self._call_api('package_delete', dataset, 'nhseditor', 403) + finally: + self._undelete_package_if_needed(dataset['id']) + + def _flesh_out_organization(self, org): + # When calling organization_update, unless you include the list of + # editor and admin users and parent groups, it will remove them. So + # get the current list + existing_org = get_action('organization_show')( + {'model': model, 'ignore_auth': True}, {'id': org['id']}) + org.update(existing_org) + + def test_10_edit_org_1(self): + org = {'id': 'department-of-health', 'title': 'test'} + self._flesh_out_organization(org) + self._call_api('organization_update', org, 'nhsadmin', 403) + + def test_10_edit_org_2(self): + org = {'id': 'national-health-service', 'title': 'test'} + self._flesh_out_organization(org) + import pprint; pprint.pprint(org) + print model.Session.query(model.Member).filter_by(state='deleted').all() + self._call_api('organization_update', org, 'nhsadmin', 200) + print model.Session.query(model.Member).filter_by(state='deleted').all() + + def test_10_edit_org_3(self): + org = {'id': 'nhs-wirral-ccg', 'title': 'test'} + self._flesh_out_organization(org) + self._call_api('organization_update', org, 'nhsadmin', 200) + + def test_10_edit_org_4(self): + org = {'id': 'department-of-health', 'title': 'test'} + self._flesh_out_organization(org) + self._call_api('organization_update', org, 'nhseditor', 403) + + def test_10_edit_org_5(self): + org = {'id': 'national-health-service', 'title': 'test'} + self._flesh_out_organization(org) + self._call_api('organization_update', org, 'nhseditor', 403) + + def test_10_edit_org_6(self): + org = {'id': 'nhs-wirral-ccg', 'title': 'test'} + self._flesh_out_organization(org) + self._call_api('organization_update', org, 'nhseditor', 403) + + def test_11_delete_org_1(self): + org = {'id': 'department-of-health'} + self._call_api('organization_delete', org, 'nhsadmin', 403) + self._call_api('organization_delete', org, 'nhseditor', 403) + + def test_11_delete_org_2(self): + org = {'id': 'national-health-service'} + self._call_api('organization_delete', org, 'nhsadmin', 403) + self._call_api('organization_delete', org, 'nhseditor', 403) + + def test_11_delete_org_3(self): + org = {'id': 'nhs-wirral-ccg'} + self._call_api('organization_delete', org, 'nhsadmin', 403) + self._call_api('organization_delete', org, 'nhseditor', 403) + class TestAuthGroups(TestAuth): diff --git a/ckan/tests/logic/test_member.py b/ckan/tests/logic/test_member.py index 39f67edd2db..70487181cb5 100644 --- a/ckan/tests/logic/test_member.py +++ b/ckan/tests/logic/test_member.py @@ -11,10 +11,18 @@ def setup_class(cls): model.repo.new_revision() create_test_data.CreateTestData.create() cls.user = model.User.get('testsysadmin') + cls.tester = model.User.get('tester') cls.group = model.Group.get('david') + cls.roger = model.Group.get('roger') cls.pkgs = [model.Package.by_name('warandpeace'), model.Package.by_name('annakarenina')] + # 'Tester' becomes an admin for the 'roger' group + model.repo.new_revision() + model.Member(group=cls.roger, table_id=cls.tester.id, + table_name='user', capacity='admin') + model.repo.commit_and_remove() + @classmethod def teardown_class(cls): model.repo.rebuild_db() @@ -37,6 +45,20 @@ def test_member_create_raises_if_user_unauthorized_to_update_group(self): assert_raises(logic.NotAuthorized, logic.get_action('member_create'), ctx, dd) + def test_member_create_with_child_group_permission(self): + # 'tester' has admin priviledge for roger, so anyone can make it + # a child group. + self._member_create_group_hierarchy(parent_group=self.group, + child_group=self.roger, + user=self.tester) + + def test_member_create_raises_when_only_have_parent_group_permission(self): + assert_raises(logic.NotAuthorized, + self._member_create_group_hierarchy, + self.roger, # parent + self.group, # child + self.tester) + def test_member_create_accepts_group_name_or_id(self): by_name = self._member_create_in_group(self.pkgs[0].id, 'package', 'public', self.group.name) @@ -139,14 +161,18 @@ def test_member_delete_raises_if_object_type_is_invalid(self): self._member_delete, 'obj_id', 'invalid_obj_type') def _member_create(self, obj, obj_type, capacity): + '''Makes the given object a member of cls.group.''' ctx, dd = self._build_context(obj, obj_type, capacity) return logic.get_action('member_create')(ctx, dd) def _member_create_in_group(self, obj, obj_type, capacity, group_id): + '''Makes the given object a member of the given group.''' ctx, dd = self._build_context(obj, obj_type, capacity, group_id) return logic.get_action('member_create')(ctx, dd) def _member_create_as_user(self, obj, obj_type, capacity, user): + '''Makes the given object a member of cls.group using privileges of + the given user.''' ctx, dd = self._build_context(obj, obj_type, capacity, user=user) return logic.get_action('member_create')(ctx, dd) @@ -162,11 +188,17 @@ def _member_delete_in_group(self, obj, obj_type, group_id): ctx, dd = self._build_context(obj, obj_type, group_id=group_id) return logic.get_action('member_delete')(ctx, dd) + def _member_create_group_hierarchy(self, parent_group, child_group, user): + ctx, dd = self._build_context(parent_group.name, 'group', 'parent', + group_id=child_group.name, user=user.id) + return logic.get_action('member_create')(ctx, dd) + def _build_context(self, obj, obj_type, capacity='public', group_id=None, user=None): ctx = {'model': model, 'session': model.Session, 'user': user or self.user.id} + ctx['auth_user_obj'] = model.User.get(ctx['user']) dd = {'id': group_id or self.group.name, 'object': obj, 'object_type': obj_type, diff --git a/ckan/tests/models/test_group.py b/ckan/tests/models/test_group.py index 88d1e63d9be..c59d6ae8048 100644 --- a/ckan/tests/models/test_group.py +++ b/ckan/tests/models/test_group.py @@ -1,7 +1,6 @@ -from nose.tools import assert_equal +from ckan.tests import assert_equal, assert_in, assert_not_in, CreateTestData import ckan.model as model -from ckan.tests import * class TestGroup(object): @@ -92,6 +91,103 @@ def _search_results(self, query, is_org=False): results = model.Group.search_by_name_or_title(query,is_org=is_org) return set([group.name for group in results]) +name_set_from_dicts = lambda groups: set([group['name'] for group in groups]) +name_set_from_group_tuple = lambda tuples: set([t[1] for t in tuples]) +name_set_from_groups = lambda groups: set([group.name for group in groups]) +names_from_groups = lambda groups: [group.name for group in groups] + +group_type = 'organization' + +class TestHierarchy: + @classmethod + def setup_class(self): + CreateTestData.create_group_hierarchy_test_data() + + def test_get_children_groups(self): + res = model.Group.by_name(u'department-of-health').\ + get_children_groups(type=group_type) + # check groups + assert_equal(name_set_from_groups(res), + set(('national-health-service', + 'food-standards-agency'))) + # check each group is a Group + assert isinstance(res[0], model.Group) + assert_in(res[0].name, ('national-health-service', 'food-standards-agency')) + assert_in(res[0].title, ('National Health Service', 'Food Standards Agency')) + + def test_get_children_group_hierarchy__from_top_2(self): + groups = model.Group.by_name(u'department-of-health').\ + get_children_group_hierarchy(type=group_type) + # the first group must be NHS or Food Standards Agency - i.e. on the + # first level down + nhs = groups[0] + assert_in(nhs[1], ('national-health-service', 'food-standards-agency')) + assert_equal(model.Group.get(nhs[3]).name, 'department-of-health') + + def test_get_children_group_hierarchy__from_top(self): + assert_equal(name_set_from_group_tuple(model.Group.by_name(u'department-of-health').\ + get_children_group_hierarchy(type=group_type)), + set(('national-health-service', 'food-standards-agency', + 'nhs-wirral-ccg', 'nhs-southwark-ccg'))) + # i.e. not cabinet-office + + def test_get_children_group_hierarchy__from_tier_two(self): + assert_equal(name_set_from_group_tuple(model.Group.by_name(u'national-health-service').\ + get_children_group_hierarchy(type=group_type)), + set(('nhs-wirral-ccg', + 'nhs-southwark-ccg'))) + # i.e. not department-of-health or food-standards-agency + + def test_get_children_group_hierarchy__from_bottom_tier(self): + assert_equal(name_set_from_group_tuple(model.Group.by_name(u'nhs-wirral-ccg').\ + get_children_group_hierarchy(type=group_type)), + set()) + + def test_get_parents__top(self): + assert_equal(names_from_groups(model.Group.by_name(u'department-of-health').\ + get_parent_groups(type=group_type)), + []) + + def test_get_parents__tier_two(self): + assert_equal(names_from_groups(model.Group.by_name(u'national-health-service').\ + get_parent_groups(type=group_type)), + ['department-of-health']) + + def test_get_parents__tier_three(self): + assert_equal(names_from_groups(model.Group.by_name(u'nhs-wirral-ccg').\ + get_parent_groups(type=group_type)), + ['national-health-service']) + + def test_get_parent_groups_up_hierarchy__from_top(self): + assert_equal(names_from_groups(model.Group.by_name(u'department-of-health').\ + get_parent_group_hierarchy(type=group_type)), + []) + + def test_get_parent_groups_up_hierarchy__from_tier_two(self): + assert_equal(names_from_groups(model.Group.by_name(u'national-health-service').\ + get_parent_group_hierarchy(type=group_type)), + ['department-of-health']) + + def test_get_parent_groups_up_hierarchy__from_tier_three(self): + assert_equal(names_from_groups(model.Group.by_name(u'nhs-wirral-ccg').\ + get_parent_group_hierarchy(type=group_type)), + ['department-of-health', + 'national-health-service']) + + def test_get_top_level_groups(self): + assert_equal(names_from_groups(model.Group.by_name(u'nhs-wirral-ccg').\ + get_top_level_groups(type=group_type)), + ['cabinet-office', 'department-of-health']) + + def test_groups_allowed_to_be_its_parent(self): + groups = model.Group.by_name(u'national-health-service').\ + groups_allowed_to_be_its_parent(type=group_type) + names = names_from_groups(groups) + assert_in('department-of-health', names) + assert_in('cabinet-office', names) + assert_not_in('natonal-health-service', names) + assert_not_in('nhs-wirral-ccg', names) + class TestGroupRevisions: @classmethod def setup_class(self): diff --git a/ckan/tests/test_coding_standards.py b/ckan/tests/test_coding_standards.py index 0583ef91c4c..c41bf609ba8 100644 --- a/ckan/tests/test_coding_standards.py +++ b/ckan/tests/test_coding_standards.py @@ -472,7 +472,6 @@ class TestImportStar(object): 'ckan/tests/lib/test_tag_search.py', 'ckan/tests/misc/test_sync.py', 'ckan/tests/models/test_extras.py', - 'ckan/tests/models/test_group.py', 'ckan/tests/models/test_misc.py', 'ckan/tests/models/test_package.py', 'ckan/tests/models/test_package_relationships.py', @@ -751,7 +750,6 @@ class TestPep8(object): 'ckan/tests/functional/test_cors.py', 'ckan/tests/functional/test_error.py', 'ckan/tests/functional/test_follow.py', - 'ckan/tests/functional/test_group.py', 'ckan/tests/functional/test_home.py', 'ckan/tests/functional/test_package.py', 'ckan/tests/functional/test_package_relationships.py', @@ -828,7 +826,6 @@ class TestPep8(object): 'ckanext/example_idatasetform/plugin.py', 'ckanext/example_itemplatehelpers/plugin.py', 'ckanext/multilingual/plugin.py', - 'ckanext/multilingual/tests/test_multilingual_plugin.py', 'ckanext/resourceproxy/plugin.py', 'ckanext/stats/controller.py', 'ckanext/stats/plugin.py', @@ -907,9 +904,7 @@ class TestActionAuth(object): 'create: follow_dataset', 'create: follow_group', 'create: follow_user', - 'create: member_create', 'create: package_relationship_create_rest', - 'delete: member_delete', 'delete: package_relationship_delete_rest', 'delete: unfollow_dataset', 'delete: unfollow_group', diff --git a/ckan_deb/DEBIAN/control.template b/ckan_deb/DEBIAN/control.template deleted file mode 100644 index 0485e5e6022..00000000000 --- a/ckan_deb/DEBIAN/control.template +++ /dev/null @@ -1,11 +0,0 @@ -# This get's set by the build script -# Version: XXX -Package: ckan -Architecture: all -Maintainer: James Gardner -Section: main/web -Priority: extra -Depends: python-ckan, apache2, libapache2-mod-wsgi, python-virtualenv, python-pip -Homepage: http://ckan.org -Description: ckan - Common files useful for managing CKAN installations diff --git a/ckan_deb/usr/bin/ckan-create-instance b/ckan_deb/usr/bin/ckan-create-instance deleted file mode 100755 index a735758b91b..00000000000 --- a/ckan_deb/usr/bin/ckan-create-instance +++ /dev/null @@ -1,160 +0,0 @@ -#!/bin/bash -. /usr/lib/ckan/common.sh - - -# Check we are root -if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root" - exit 1 -fi - -if [ $# -lt 3 ] -then - echo "ERROR: Wrong number of arguments. Expected: (instance name, hostname, local database [yes or no]) e.g." - echo " $0 std std.ckan.org yes" - exit 1 -fi - -INSTANCE=$1 -HOSTNAME=$2 -LOCAL_DB=$3 - -# Check the LOCAL_DB variable - -if ! [[ ( "$LOCAL_DB" == "yes" ) || ("$LOCAL_DB" == "no" ) ]] -then - echo "Expceted the LOCAL_DB variable to be 'yes' or 'no', not '$LOCAL_DB'" - exit 1 -fi - -# Create an install settings file if it doesn't exist -if ! [ -f /etc/ckan/${INSTANCE}/install_settings.sh ] ; then - mkdir -p /etc/ckan/${INSTANCE}/ - cat < /etc/ckan/${INSTANCE}/install_settings.sh -#!/bin/bash -EOF - chmod +x /etc/ckan/${INSTANCE}/install_settings.sh -fi - -# Parse the settings -. /etc/ckan/${INSTANCE}/install_settings.sh - -# See which settings are set, and create any we need that aren't -if [ "X${CKAN_DB_PASSWORD}" = "X" ] ; then - # Create a password - CKAN_DB_PASSWORD=`< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c10` - cat <> /etc/ckan/${INSTANCE}/install_settings.sh -CKAN_DB_PASSWORD=${CKAN_DB_PASSWORD} -EOF -fi - -error() { - echo -e "${@}" 1>&2 - exit 1 -} - -echo "Installing or upgrading CKAN ${INSTANCE} ..." - -echo "Ensuring users and groups are set up correctly ..." -ckan_ensure_users_and_groups ${INSTANCE} - -chown ckan${INSTANCE}:ckan${INSTANCE} /etc/ckan/${INSTANCE}/install_settings.sh - -echo "Ensuring directories exist for ${INSTANCE} CKAN INSTANCE ..." -ckan_make_ckan_directories ${INSTANCE} - -# Disable any existing crontabs during the upgrade, we don't want -# scripts running when things are still changing -echo "Disabling the crontab for the ckan${INSTANCE} user ..." -PACKAGED_CRONJOB="/tmp/${INSTANCE}-cronjob" -cat < ${PACKAGED_CRONJOB} -# m h dom mon dow command -EOF -crontab -u ckan${INSTANCE} ${PACKAGED_CRONJOB} - -# Try to put CKAN into maintenance mode, if it is installed -if [ -f /etc/apache2/sites-available/${INSTANCE}.maint ] ; then - # We have a maintence mode available - echo "Putting CKAN into maintenance mode ..." - ckan_maintenance_on ${INSTANCE} -fi - -echo "Setting log file permissions so that both Apache and cron jobs can log to the same place ..." -ckan_set_log_file_permissions ${INSTANCE} - -echo "Ensuring who.ini file exists ..." -ckan_create_who_ini ${INSTANCE} - -echo "Ensuring wsgi.py file exists ..." -ckan_create_wsgi_handler ${INSTANCE} - -if [[ ( "$LOCAL_DB" == "yes" ) ]] -then - # Replace any existing user with a new one with this password - echo "Making sure PostgreSQL is running ..." - /etc/init.d/postgresql-8.4 start - - echo "Setting the password of the ${INSTANCE} user in PostgreSQL" - ckan_add_or_replace_database_user ${INSTANCE} ${CKAN_DB_PASSWORD} -fi - -if ! [ -f /etc/ckan/${INSTANCE}/${INSTANCE}.ini ] ; then - # Create the config file - echo "Creating/overwriting the config for CKAN ... " - ckan_create_config_file ${INSTANCE} ${CKAN_DB_PASSWORD} ${LOCAL_DB} - # Now that the file exists, make some customisations - sed \ - -e "s,^\(ckan.dump_dir\)[ =].*,\1 = /var/lib/ckan/${INSTANCE}/static/dump," \ - -i /etc/ckan/${INSTANCE}/${INSTANCE}.ini - #echo "Ensuring the latest plugins are configured ..." - #sed -e "s/^\(ckan.plugins\)[ =].*/\1 = ${INSTANCE}/" \ - # -i /etc/ckan/${INSTANCE}/${INSTANCE}.ini -fi - -if [[ ( "$LOCAL_DB" == "yes" ) ]] -then - echo "Ensuring the ${INSTANCE} database exists ..." - ckan_ensure_db_exists ${INSTANCE} -fi - -# Overwrite the existing Apache config -if [ -f /etc/apache2/sites-enabled/000-default ] ; then - echo "Disabling the default Apache site ..." - a2dissite 000-default -fi - -echo "Overwriting the existing Apache config ..." -ckan_overwrite_apache_config ${INSTANCE} ${HOSTNAME} - -# Make sure mod_rewrite is enabled -if ! [ -f /etc/apache2/mods-enabled/rewrite.load ] ; then - echo "Enabling Apache mod_rewite ..." - a2enmod rewrite -fi - -if [[ ( "$LOCAL_DB" == "yes" ) ]] -then - # Standard paster db upgrade - echo "Performing any database upgrades ..." - paster --plugin=ckan db upgrade --config=/etc/ckan/${INSTANCE}/${INSTANCE}.ini -fi - -# Make sure our INSTANCE is enabled -echo "Bringing the ${INSTANCE} INSTANCE out of maintenance mode ..." -ckan_maintenance_off ${INSTANCE} - -# Restart Apache so it is aware of any changes -echo "Reloading apache ..." -/etc/init.d/apache2 reload - -# Install the new crontab -echo "Enabling crontab for the ckan${INSTANCE} user ..." -PACKAGED_CRONJOB="/tmp/${INSTANCE}-cronjob" -cat << EOF > ${PACKAGED_CRONJOB} -# WARNING: Do not edit these cron tabs, they will be overwritten any time -# the ckan INSTANCE package is upgraded -# QUESTION: Should email reports be sent to root? -EOF -crontab -u ckan${INSTANCE} ${PACKAGED_CRONJOB} - -exit 0 diff --git a/ckan_deb/usr/bin/ckan-instance-maintenance b/ckan_deb/usr/bin/ckan-instance-maintenance deleted file mode 100755 index cd019b27a96..00000000000 --- a/ckan_deb/usr/bin/ckan-instance-maintenance +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -. /usr/lib/ckan/common.sh - -set -e - -# Un-comment for debugging - -# Check we are root -if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root" - exit 1 -fi - -error() { - echo -e "${@}" 1>&2 - exit 1 -} - -usage() { - error "ERROR: call this script with instance name and \"on\" or \"off\", e.g.\n $0 dgu on" -} - -[ "X$1" = "X" ] || [ "X$2" = "X" ] && usage -instance=$1 -command=$2 - -for conf in /etc/apache2/sites-available/${instance}{,.maint} ; do - [ -f ${conf} ] || error "ERROR: ${conf} not found" -done - -case $command in - on) - echo "Putting CKAN site \"${instance}\" into maintenance mode ..." - maintenance_on $instance - echo "done." - ;; - - off) - echo "Disabling maintenance mode for CKAN site \"${instance}\" ..." - maintenance_off $instance - echo "done." - ;; - *) - usage - ;; -esac - -exit 0 diff --git a/ckan_deb/usr/bin/ckan-setup-solr b/ckan_deb/usr/bin/ckan-setup-solr deleted file mode 100755 index 0642a4c16d9..00000000000 --- a/ckan_deb/usr/bin/ckan-setup-solr +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -. /usr/lib/ckan/common.sh - -# Check we are root -if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root" - exit 1 -fi - -# Install solr-jetty if needed -if ! [ -f /etc/default/jetty ] ; then - echo "Installing solr-jetty ..." - apt-get update - apt-get install -y solr-jetty - echo "done." -fi - -echo "Configuring Solr for use with CKAN ..." -sed \ - -e "s,NO_START=1,NO_START=0," \ - -e "s,#JETTY_HOST=\$(uname -n),JETTY_HOST=127.0.0.1," \ - -e "s,#JETTY_PORT=8080,JETTY_PORT=8983," \ - -i /etc/default/jetty -mv /usr/share/solr/conf/schema.xml /usr/share/solr/conf/schema.xml.`date --utc "+%Y-%m-%d_%T"`.bak -ln -s /usr/lib/pymodules/python2.6/ckan/config/solr/schema-1.4.xml /usr/share/solr/conf/schema.xml -service jetty stop -service jetty start -echo "done." diff --git a/ckan_deb/usr/lib/ckan/common.sh b/ckan_deb/usr/lib/ckan/common.sh deleted file mode 100644 index 0458296e2c9..00000000000 --- a/ckan_deb/usr/lib/ckan/common.sh +++ /dev/null @@ -1,294 +0,0 @@ -#!/bin/bash - -ckan_log () { - echo "ckan: " $1 -} - -#_echo="echo" -ckan_maintenance_on () { - local INSTANCE - INSTANCE=$1 - $_echo a2dissite ${INSTANCE} - $_echo a2ensite ${INSTANCE}.maint - $_echo service apache2 reload -} - -ckan_maintenance_off () { - local INSTANCE - INSTANCE=$1 - $_echo a2dissite ${INSTANCE}.maint - $_echo a2ensite ${INSTANCE} - $_echo service apache2 reload -} - -ckan_set_log_file_permissions () { - local INSTANCE - INSTANCE=$1 - sudo chown www-data:ckan${INSTANCE} /var/log/ckan/${INSTANCE} - sudo chmod g+w /var/log/ckan/${INSTANCE} - sudo touch /var/log/ckan/${INSTANCE}/${INSTANCE}.log - sudo touch /var/log/ckan/${INSTANCE}/${INSTANCE}1.log - sudo touch /var/log/ckan/${INSTANCE}/${INSTANCE}2.log - sudo touch /var/log/ckan/${INSTANCE}/${INSTANCE}3.log - sudo touch /var/log/ckan/${INSTANCE}/${INSTANCE}4.log - sudo touch /var/log/ckan/${INSTANCE}/${INSTANCE}5.log - sudo touch /var/log/ckan/${INSTANCE}/${INSTANCE}6.log - sudo touch /var/log/ckan/${INSTANCE}/${INSTANCE}7.log - sudo touch /var/log/ckan/${INSTANCE}/${INSTANCE}8.log - sudo touch /var/log/ckan/${INSTANCE}/${INSTANCE}9.log - sudo chmod g+w /var/log/ckan/${INSTANCE}/${INSTANCE}*.log - sudo chown www-data:ckan${INSTANCE} /var/log/ckan/${INSTANCE}/${INSTANCE}*.log -} - -ckan_ensure_users_and_groups () { - local INSTANCE - INSTANCE=$1 - COMMAND_OUTPUT=`cat /etc/group | grep "ckan${INSTANCE}:"` - if ! [[ "$COMMAND_OUTPUT" =~ "ckan${INSTANCE}:" ]] ; then - echo "Creating the 'ckan${INSTANCE}' group ..." - sudo groupadd --system "ckan${INSTANCE}" - fi - COMMAND_OUTPUT=`cat /etc/passwd | grep "ckan${INSTANCE}:"` - if ! [[ "$COMMAND_OUTPUT" =~ "ckan${INSTANCE}:" ]] ; then - echo "Creating the 'ckan${INSTANCE}' user ..." - sudo useradd --system --gid "ckan${INSTANCE}" --home /var/lib/ckan/${INSTANCE} -M --shell /usr/sbin/nologin ckan${INSTANCE} - fi -} - -ckan_make_ckan_directories () { - local INSTANCE - if [ "X$1" = "X" ] ; then - echo "ERROR: call the function make_ckan_directories with an INSTANCE name, e.g." - echo " dgu" - exit 1 - else - INSTANCE=$1 - mkdir -p -m 0755 /etc/ckan/${INSTANCE} - mkdir -p -m 0750 /var/lib/ckan/${INSTANCE}{,/static} - mkdir -p -m 0770 /var/{backup,log}/ckan/${INSTANCE} /var/lib/ckan/${INSTANCE}/{data,sstore,static/dump} - sudo chown ckan${INSTANCE}:ckan${INSTANCE} /etc/ckan/${INSTANCE} - sudo chown www-data:ckan${INSTANCE} /var/{backup,log}/ckan/${INSTANCE} /var/lib/ckan/${INSTANCE} /var/lib/ckan/${INSTANCE}/{data,sstore,static/dump} - sudo chmod g+w /var/log/ckan/${INSTANCE} /var/lib/ckan/${INSTANCE}/{data,sstore,static/dump} - fi -} - -ckan_create_who_ini () { - local INSTANCE - if [ "X$1" = "X" ] ; then - echo "ERROR: call the function create_who_ini function with an INSTANCE name, e.g." - echo " dgu" - exit 1 - else - INSTANCE=$1 - if ! [ -f /etc/ckan/$0/who.ini ] ; then - cp -n /usr/share/pyshared/ckan/config/who.ini /etc/ckan/${INSTANCE}/who.ini - sed -e "s,%(here)s,/var/lib/ckan/${INSTANCE}," \ - -i /etc/ckan/${INSTANCE}/who.ini - chown ckan${INSTANCE}:ckan${INSTANCE} /etc/ckan/${INSTANCE}/who.ini - fi - fi -} - -ckan_create_config_file () { - local INSTANCE password LOCAL_DB - if [ "X$1" = "X" ] || [ "X$2" = "X" ] ; then - echo "ERROR: call the function create_config_file function with an INSTANCE name, and a password for postgresql e.g." - echo " dgu 1U923hjkh8" - exit 1 - else - INSTANCE=$1 - password=$2 - LOCAL_DB=$3 - # Create an install settings file if it doesn't exist - if [ -f /etc/ckan/${INSTANCE}/${INSTANCE}.ini ] ; then - mv /etc/ckan/${INSTANCE}/${INSTANCE}.ini "/etc/ckan/${INSTANCE}/${INSTANCE}.ini.`date +%F_%T`.bak" - fi - paster make-config ckan /etc/ckan/${INSTANCE}/${INSTANCE}.ini - - if [[ ( "$LOCAL_DB" == "yes" ) ]] - then - sed -e "s,^\(sqlalchemy.url\)[ =].*,\1 = postgresql://${INSTANCE}:${password}@localhost/${INSTANCE}," \ - -i /etc/ckan/${INSTANCE}/${INSTANCE}.ini - fi - sed -e "s,^\(email_to\)[ =].*,\1 = root," \ - -e "s,^\(error_email_from\)[ =].*,\1 = ckan-${INSTANCE}@`hostname`," \ - -e "s,# ckan\.site_id = ckan.net,ckan.site_id = ${INSTANCE}," \ - -e "s,^\(cache_dir\)[ =].*,\1 = /var/lib/ckan/${INSTANCE}/data," \ - -e "s,^\(who\.config_file\)[ =].*,\1 = /etc/ckan/${INSTANCE}/who.ini," \ - -e "s,\"ckan\.log\",\"/var/log/ckan/${INSTANCE}/${INSTANCE}.log\"," \ - -e "s,#solr_url = http://127.0.0.1:8983/solr,solr_url = http://127.0.0.1:8983/solr," \ - -i /etc/ckan/${INSTANCE}/${INSTANCE}.ini - sudo chown ckan${INSTANCE}:ckan${INSTANCE} /etc/ckan/${INSTANCE}/${INSTANCE}.ini - fi -} - -ckan_add_or_replace_database_user () { - local INSTANCE password - if [ "X$1" = "X" ] || [ "X$2" = "X" ] ; then - echo "ERROR: call the function ckan_add_or_replace_database_user function with an INSTANCE name, and a password for postgresql e.g." - echo " dgu 1U923hjkh8" - echo " You can generate a password like this: " - echo " < /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c10" - exit 1 - else - INSTANCE=$1 - password=$2 - COMMAND_OUTPUT=`sudo -u postgres psql -c "SELECT 'True' FROM pg_user WHERE usename='${INSTANCE}'"` - if ! [[ "$COMMAND_OUTPUT" =~ True ]] ; then - echo "Creating the ${INSTANCE} user ..." - sudo -u postgres psql -c "CREATE USER \"${INSTANCE}\" WITH PASSWORD '${password}'" - else - echo "Setting the ${INSTANCE} user password ..." - sudo -u postgres psql -c "ALTER USER \"${INSTANCE}\" WITH PASSWORD '${password}'" - fi - fi -} - -ckan_ensure_db_exists () { - local INSTANCE - if [ "X$1" = "X" ] ; then - echo "ERROR: call the function ensure_db_exists function with an INSTANCE name, e.g." - echo " dgu" - exit 1 - else - INSTANCE=$1 - COMMAND_OUTPUT=`sudo -u postgres psql -c "select datname from pg_database where datname='$INSTANCE'"` - if ! [[ "$COMMAND_OUTPUT" =~ ${INSTANCE} ]] ; then - echo "Creating the database ..." - sudo -u postgres createdb -O ${INSTANCE} ${INSTANCE} -E utf-8 - paster --plugin=ckan db init --config=/etc/ckan/${INSTANCE}/${INSTANCE}.ini - fi - fi -} - -ckan_create_wsgi_handler () { - local INSTANCE - if [ "X$1" = "X" ] ; then - echo "ERROR: call the function create_wsgi_handler function with an INSTANCE name, e.g." - echo " dgu" - exit 1 - else - INSTANCE=$1 - if [ ! -f "/var/lib/ckan/${INSTANCE}/wsgi.py" ] - then - sudo mkdir /var/lib/ckan/${INSTANCE}/pyenv - sudo chown -R ckan${INSTANCE}:ckan${INSTANCE} /var/lib/ckan/${INSTANCE}/pyenv - sudo -u ckan${INSTANCE} virtualenv --setuptools /var/lib/ckan/${INSTANCE}/pyenv - echo "Attempting to install 'pip' 1.0 from pypi.python.org into pyenv to be used for extensions ..." - sudo -u ckan${INSTANCE} /var/lib/ckan/${INSTANCE}/pyenv/bin/easy_install --upgrade "pip>=1.0" "pip<=1.0.99" - echo "done." - echo "Attempting to install 'paster' pypi.python.org into pyenv ..." - sudo -u ckan${INSTANCE} /var/lib/ckan/${INSTANCE}/pyenv/bin/pip install --ignore-installed "pastescript" - echo "done." - cat <<- EOF > /var/lib/ckan/${INSTANCE}/packaging_version.txt - 1.5 - EOF - cat <<- EOF > /var/lib/ckan/${INSTANCE}/wsgi.py - import os - instance_dir = '/var/lib/ckan/${INSTANCE}' - config_dir = '/etc/ckan/${INSTANCE}' - config_file = '${INSTANCE}.ini' - pyenv_bin_dir = os.path.join(instance_dir, 'pyenv', 'bin') - activate_this = os.path.join(pyenv_bin_dir, 'activate_this.py') - execfile(activate_this, dict(__file__=activate_this)) - # this is werid but without importing ckanext first import of paste.deploy will fail - #import ckanext - config_filepath = os.path.join(config_dir, config_file) - if not os.path.exists(config_filepath): - raise Exception('No such file %r'%config_filepath) - from paste.deploy import loadapp - from paste.script.util.logging_config import fileConfig - fileConfig(config_filepath) - application = loadapp('config:%s' % config_filepath) - from apachemiddleware import MaintenanceResponse - application = MaintenanceResponse(application) - EOF - sudo chmod +x /var/lib/ckan/${INSTANCE}/wsgi.py - fi - fi -} - -ckan_overwrite_apache_config () { - local INSTANCE ServerName - if [ "X$1" = "X" ] ; then - echo "ERROR: call the function overwrite_apache_config function with an INSTANCE name, the server name and a server aliase e.g." - echo " dgu catalogue.data.gov.uk dgu-live.okfn.org" - exit 1 - else - INSTANCE=$1 - ServerName=$2 - #rm /etc/apache2/sites-available/${INSTANCE}.common - cat < /etc/apache2/sites-available/${INSTANCE}.common - - # WARNING: Do not manually edit this file, it is designed to be - # overwritten at any time by the postinst script of - # dependent packages - - # These are common settings used for both the normal and maintenance modes - - DocumentRoot /var/lib/ckan/${INSTANCE}/static - ServerName ${ServerName} - - - # XXX Should this be deny? We get a "Client denied by server configuration" without it - allow from all - - - - allow from all - - - - allow from all - - - Alias /dump /var/lib/ckan/${INSTANCE}/static/dump - - # Disable the mod_python handler for static files - - SetHandler None - Options +Indexes - - - # this is our app - WSGIScriptAlias / /var/lib/ckan/${INSTANCE}/wsgi.py - - # pass authorization info on (needed for rest api) - WSGIPassAuthorization On - - # Deploy as a daemon (avoids conflicts between CKAN instances) - # WSGIDaemonProcess ${INSTANCE} display-name=${INSTANCE} processes=4 threads=15 maximum-requests=10000 - # WSGIProcessGroup ${INSTANCE} - - ErrorLog /var/log/apache2/${INSTANCE}.error.log - CustomLog /var/log/apache2/${INSTANCE}.custom.log combined -EOF - #rm /etc/apache2/sites-available/${INSTANCE} - cat < /etc/apache2/sites-available/${INSTANCE} - - # WARNING: Do not manually edit this file, it is designed to be - # overwritten at any time by the postinst script of - # dependent packages - Include /etc/apache2/sites-available/${INSTANCE}.common - -EOF - #rm /etc/apache2/sites-available/${INSTANCE}.maint - cat < /etc/apache2/sites-available/${INSTANCE}.maint - - # WARNING: Do not manually edit this file, it is designed to be - # overwritten at any time by the postinst script of - # dependent packages - Include /etc/apache2/sites-available/${INSTANCE}.common - - # Maintenance mode - RewriteEngine On - RewriteRule ^(.*)/new /return_503 [PT,L] - RewriteRule ^(.*)/create /return_503 [PT,L] - RewriteRule ^(.*)/authz /return_503 [PT,L] - RewriteRule ^(.*)/edit /return_503 [PT,L] - RewriteCond %{REQUEST_METHOD} !^GET$ [NC] - RewriteRule (.*) /return_503 [PT,L] - -EOF - fi -} diff --git a/ckanext/datapusher/helpers.py b/ckanext/datapusher/helpers.py index d5291425b28..535e6d57635 100644 --- a/ckanext/datapusher/helpers.py +++ b/ckanext/datapusher/helpers.py @@ -9,3 +9,19 @@ def datapusher_status(resource_id): return { 'status': 'unknown' } + + +def datapusher_status_description(status): + _ = toolkit._ + + if status.get('status'): + captions = { + 'complete': _('Complete'), + 'pending': _('Pending'), + 'submitting': _('Submitting'), + 'error': _('Error'), + } + + return captions.get(status['status'], status['status'].capitalize()) + else: + return _('Not Uploaded Yet') diff --git a/ckanext/datapusher/logic/action.py b/ckanext/datapusher/logic/action.py index f618a886fd6..683ac036145 100644 --- a/ckanext/datapusher/logic/action.py +++ b/ckanext/datapusher/logic/action.py @@ -45,9 +45,12 @@ def datapusher_submit(context, data_dict): datapusher_url = pylons.config.get('ckan.datapusher.url') - callback_url = p.toolkit.url_for( - controller='api', action='action', logic_function='datapusher_hook', - ver=3, qualified=True) + site_url = pylons.config['ckan.site_url'] + + callback_url = site_url.rstrip('/') + p.toolkit.url_for( + controller='api', action='action', + logic_function='datapusher_hook', ver=3 + ) user = p.toolkit.get_action('user_show')(context, {'id': context['user']}) @@ -86,7 +89,7 @@ def datapusher_submit(context, data_dict): 'job_type': 'push_to_datastore', 'result_url': callback_url, 'metadata': { - 'ckan_url': pylons.config['ckan.site_url'], + 'ckan_url': site_url, 'resource_id': res_id, 'set_url_type': data_dict.get('set_url_type', False) } @@ -165,6 +168,7 @@ def datapusher_hook(context, data_dict): view_list = p.toolkit.get_action('resource_view_create')(context, view) + context['ignore_auth'] = True p.toolkit.get_action('task_status_update')(context, task) diff --git a/ckanext/datapusher/plugin.py b/ckanext/datapusher/plugin.py index c53dd14790d..56fb44d2122 100644 --- a/ckanext/datapusher/plugin.py +++ b/ckanext/datapusher/plugin.py @@ -80,10 +80,11 @@ def configure(self, config): datapusher_formats = config.get('ckan.datapusher.formats', '').lower() self.datapusher_formats = datapusher_formats.split() or DEFAULT_FORMATS - datapusher_url = config.get('ckan.datapusher.url') - if not datapusher_url: - raise Exception( - 'Config option `ckan.datapusher.url` has to be set.') + for config_option in ('ckan.site_url', 'ckan.datapusher.url',): + if not config.get(config_option): + raise Exception( + 'Config option `{0}` must be set to use the DataPusher.' + .format(config_option)) def notify(self, entity, operation=None): if isinstance(entity, model.Resource): @@ -94,10 +95,7 @@ def notify(self, entity, operation=None): # 1 parameter context = {'model': model, 'ignore_auth': True, 'defer_commit': True} - package = p.toolkit.get_action('package_show')(context, { - 'id': entity.get_package_id() - }) - if (not package['private'] and entity.format and + if (entity.format and entity.format.lower() in self.datapusher_formats and entity.url_type != 'datapusher'): try: @@ -128,4 +126,7 @@ def get_auth_functions(self): def get_helpers(self): return { - 'datapusher_status': helpers.datapusher_status} + 'datapusher_status': helpers.datapusher_status, + 'datapusher_status_description': + helpers.datapusher_status_description, + } diff --git a/ckanext/datapusher/tests/test.py b/ckanext/datapusher/tests/test.py index 2ec2df42d21..af7488736f6 100644 --- a/ckanext/datapusher/tests/test.py +++ b/ckanext/datapusher/tests/test.py @@ -138,7 +138,7 @@ def test_send_datapusher_creates_task(self): assert task['state'] == 'pending', task - def test_datapusher_hook(self): + def _call_datapusher_hook(self, user): package = model.Package.get('annakarenina') resource = package.resources[0] @@ -163,7 +163,7 @@ def test_datapusher_hook(self): } } postparams = '%s=1' % json.dumps(data) - auth = {'Authorization': str(self.sysadmin_user.apikey)} + auth = {'Authorization': str(user.apikey)} res = self.app.post('/api/action/datapusher_hook', params=postparams, extra_environ=auth, status=200) print res.body @@ -182,3 +182,11 @@ def test_datapusher_hook(self): task_type='datapusher', key='datapusher') assert task['state'] == 'success', task + + def test_datapusher_hook_sysadmin(self): + + self._call_datapusher_hook(self.sysadmin_user) + + def test_datapusher_hook_normal_user(self): + + self._call_datapusher_hook(self.normal_user) diff --git a/ckanext/datastore/db.py b/ckanext/datastore/db.py index 4dc41ff974d..53775000752 100644 --- a/ckanext/datastore/db.py +++ b/ckanext/datastore/db.py @@ -292,6 +292,8 @@ def convert(data, type_name): if type_name.startswith('_'): sub_type = type_name[1:] return [convert(item, sub_type) for item in data] + if type_name == 'tsvector': + return unicode(data, 'utf-8') if isinstance(data, datetime.datetime): return data.isoformat() if isinstance(data, (int, float)): @@ -835,7 +837,10 @@ def _insert_links(data_dict, limit, offset): data_dict['_links'] = {} # get the url from the request - urlstring = toolkit.request.environ['CKAN_CURRENT_URL'] + try: + urlstring = toolkit.request.environ['CKAN_CURRENT_URL'] + except TypeError: + return # no links required for local actions # change the offset in the url parsed = list(urlparse.urlparse(urlstring)) diff --git a/ckanext/datastore/logic/action.py b/ckanext/datastore/logic/action.py index 804fec1a197..1b90193a1bb 100644 --- a/ckanext/datastore/logic/action.py +++ b/ckanext/datastore/logic/action.py @@ -380,7 +380,7 @@ def datastore_search_sql(context, data_dict): '(; equals chr(59)) and string concatenation (||). ')] }) - p.toolkit.check_access('datastore_search', context, data_dict) + p.toolkit.check_access('datastore_search_sql', context, data_dict) data_dict['connection_url'] = pylons.config['ckan.datastore.read_url'] diff --git a/ckanext/datastore/logic/auth.py b/ckanext/datastore/logic/auth.py index f99d7f45ae3..d002d106fda 100644 --- a/ckanext/datastore/logic/auth.py +++ b/ckanext/datastore/logic/auth.py @@ -35,5 +35,10 @@ def datastore_search(context, data_dict): return datastore_auth(context, data_dict, 'resource_show') +@p.toolkit.auth_allow_anonymous_access +def datastore_search_sql(context, data_dict): + return {'success': True} + + def datastore_change_permissions(context, data_dict): return datastore_auth(context, data_dict) diff --git a/ckanext/datastore/plugin.py b/ckanext/datastore/plugin.py index f91310ecdec..73103e67121 100644 --- a/ckanext/datastore/plugin.py +++ b/ckanext/datastore/plugin.py @@ -221,6 +221,7 @@ def get_auth_functions(self): 'datastore_upsert': auth.datastore_upsert, 'datastore_delete': auth.datastore_delete, 'datastore_search': auth.datastore_search, + 'datastore_search_sql': auth.datastore_search_sql, 'datastore_change_permissions': auth.datastore_change_permissions} def before_map(self, m): diff --git a/ckanext/example_iauthfunctions/plugin.py b/ckanext/example_iauthfunctions/plugin_v4.py similarity index 97% rename from ckanext/example_iauthfunctions/plugin.py rename to ckanext/example_iauthfunctions/plugin_v4.py index 469b2c627b0..5bf972b07d0 100644 --- a/ckanext/example_iauthfunctions/plugin.py +++ b/ckanext/example_iauthfunctions/plugin_v4.py @@ -3,7 +3,6 @@ def group_create(context, data_dict=None): -# from nose.tools import set_trace; set_trace() # Get the user name of the logged-in user. user_name = context['user'] diff --git a/ckanext/example_iauthfunctions/plugin_v5_custom_config_setting.py b/ckanext/example_iauthfunctions/plugin_v5_custom_config_setting.py new file mode 100644 index 00000000000..44d43cf9d3d --- /dev/null +++ b/ckanext/example_iauthfunctions/plugin_v5_custom_config_setting.py @@ -0,0 +1,29 @@ +import pylons.config as config + +import ckan.plugins as plugins +import ckan.plugins.toolkit as toolkit + + +def group_create(context, data_dict=None): + + # Get the value of the ckan.iauthfunctions.users_can_create_groups + # setting from the CKAN config file as a string, or False if the setting + # isn't in the config file. + users_can_create_groups = config.get( + 'ckan.iauthfunctions.users_can_create_groups', False) + + # Convert the value from a string to a boolean. + users_can_create_groups = toolkit.asbool(users_can_create_groups) + + if users_can_create_groups: + return {'success': True} + else: + return {'success': False, + 'msg': 'Only sysadmins can create groups'} + + +class ExampleIAuthFunctionsPlugin(plugins.SingletonPlugin): + plugins.implements(plugins.IAuthFunctions) + + def get_auth_functions(self): + return {'group_create': group_create} diff --git a/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py b/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py index 7031867df27..25d2501ea77 100644 --- a/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py +++ b/ckanext/example_iauthfunctions/tests/test_example_iauthfunctions.py @@ -3,14 +3,88 @@ ''' import paste.fixture import pylons.test +import pylons.config as config +import webtest import ckan.model as model import ckan.tests as tests -import ckan.plugins as plugins -import ckan.plugins.toolkit as toolkit +import ckan.plugins +import ckan.new_tests.factories as factories -class TestExampleIAuthFunctionsPlugin(object): +class TestExampleIAuthFunctionsCustomConfigSetting(object): + '''Tests for the plugin_v5_custom_config_setting module. + + ''' + def _get_app(self, users_can_create_groups): + + # Set the custom config option in pylons.config. + config['ckan.iauthfunctions.users_can_create_groups'] = ( + users_can_create_groups) + + # Return a test app with the custom config. + app = ckan.config.middleware.make_app(config['global_conf'], **config) + app = webtest.TestApp(app) + + ckan.plugins.load('example_iauthfunctions_v5_custom_config_setting') + + return app + + def teardown(self): + + # Remove the custom config option from pylons.config. + del config['ckan.iauthfunctions.users_can_create_groups'] + + # Delete any stuff that's been created in the db, so it doesn't + # interfere with the next test. + model.repo.rebuild_db() + + @classmethod + def teardown_class(cls): + ckan.plugins.unload('example_iauthfunctions_v5_custom_config_setting') + + def test_sysadmin_can_create_group_when_config_is_False(self): + app = self._get_app(users_can_create_groups=False) + sysadmin = factories.Sysadmin() + + tests.call_action_api(app, 'group_create', name='test-group', + apikey=sysadmin['apikey']) + + def test_user_cannot_create_group_when_config_is_False(self): + app = self._get_app(users_can_create_groups=False) + user = factories.User() + + tests.call_action_api(app, 'group_create', name='test-group', + apikey=user['apikey'], status=403) + + def test_visitor_cannot_create_group_when_config_is_False(self): + app = self._get_app(users_can_create_groups=False) + + tests.call_action_api(app, 'group_create', name='test-group', + status=403) + + def test_sysadmin_can_create_group_when_config_is_True(self): + app = self._get_app(users_can_create_groups=True) + sysadmin = factories.Sysadmin() + + tests.call_action_api(app, 'group_create', name='test-group', + apikey=sysadmin['apikey']) + + def test_user_can_create_group_when_config_is_True(self): + app = self._get_app(users_can_create_groups=True) + user = factories.User() + + tests.call_action_api(app, 'group_create', name='test-group', + apikey=user['apikey']) + + def test_visitor_cannot_create_group_when_config_is_True(self): + app = self._get_app(users_can_create_groups=True) + + tests.call_action_api(app, 'group_create', name='test-group', + status=403) + + +class TestExampleIAuthFunctionsPluginV4(object): '''Tests for the ckanext.example_iauthfunctions.plugin module. ''' @@ -24,17 +98,7 @@ def setup_class(cls): # Test code should use CKAN's plugins.load() function to load plugins # to be tested. - plugins.load('example_iauthfunctions') - - def setup(self): - '''Nose runs this method before each test method in our test class.''' - - # Access CKAN's model directly (bad) to create a sysadmin user and save - # it against self for all test methods to access. - self.sysadmin = model.User(name='test_sysadmin', sysadmin=True) - model.Session.add(self.sysadmin) - model.Session.commit() - model.Session.remove() + ckan.plugins.load('example_iauthfunctions_v4') def teardown(self): '''Nose runs this method after each test method in our test class.''' @@ -51,31 +115,25 @@ def teardown_class(cls): ''' # We have to unload the plugin we loaded, so it doesn't affect any # tests that run after ours. - plugins.unload('example_iauthfunctions') + ckan.plugins.unload('example_iauthfunctions_v4') def _make_curators_group(self): '''This is a helper method for test methods to call when they want the 'curators' group to be created. ''' + sysadmin = factories.Sysadmin() + # Create a user who will *not* be a member of the curators group. - noncurator = tests.call_action_api(self.app, 'user_create', - apikey=self.sysadmin.apikey, - name='noncurator', - email='email', - password='password') + noncurator = factories.User() # Create a user who will be a member of the curators group. - curator = tests.call_action_api(self.app, 'user_create', - apikey=self.sysadmin.apikey, - name='curator', - email='email', - password='password') + curator = factories.User() # Create the curators group, with the 'curator' user as a member. users = [{'name': curator['name'], 'capacity': 'member'}] curators_group = tests.call_action_api(self.app, 'group_create', - apikey=self.sysadmin.apikey, + apikey=sysadmin['apikey'], name='curators', users=users) @@ -85,12 +143,14 @@ def test_group_create_with_no_curators_group(self): '''Test that group_create doesn't crash when there's no curators group. ''' + sysadmin = factories.Sysadmin() + # Make sure there's no curators group. assert 'curators' not in tests.call_action_api(self.app, 'group_list') # Make our sysadmin user create a group. CKAN should not crash. tests.call_action_api(self.app, 'group_create', name='test-group', - apikey=self.sysadmin.apikey) + apikey=sysadmin['apikey']) def test_group_create_with_visitor(self): '''A visitor (not logged in) should not be able to create a group. @@ -129,18 +189,18 @@ def test_group_create_with_curator(self): assert result['name'] == name -class TestExampleIAuthFunctionsPluginV3(TestExampleIAuthFunctionsPlugin): +class TestExampleIAuthFunctionsPluginV3(TestExampleIAuthFunctionsPluginV4): '''Tests for the ckanext.example_iauthfunctions.plugin_v3 module. ''' @classmethod def setup_class(cls): cls.app = paste.fixture.TestApp(pylons.test.pylonsapp) - plugins.load('example_iauthfunctions_v3') + ckan.plugins.load('example_iauthfunctions_v3') @classmethod def teardown_class(cls): - plugins.unload('example_iauthfunctions_v3') + ckan.plugins.unload('example_iauthfunctions_v3') def test_group_create_with_no_curators_group(self): '''Test that group_create returns a 404 when there's no curators group. @@ -150,11 +210,9 @@ def test_group_create_with_no_curators_group(self): ''' assert 'curators' not in tests.call_action_api(self.app, 'group_list') - user = tests.call_action_api(self.app, 'user_create', - apikey=self.sysadmin.apikey, - name='test-user', - email='email', - password='password') + + user = factories.User() + response = tests.call_action_api(self.app, 'group_create', name='test_group', apikey=user['apikey'], status=404) @@ -175,18 +233,18 @@ def test_group_create_with_visitor(self): assert response['__type'] == 'Authorization Error' -class TestExampleIAuthFunctionsPluginV2(TestExampleIAuthFunctionsPlugin): +class TestExampleIAuthFunctionsPluginV2(TestExampleIAuthFunctionsPluginV4): '''Tests for the ckanext.example_iauthfunctions.plugin_v2 module. ''' @classmethod def setup_class(cls): cls.app = paste.fixture.TestApp(pylons.test.pylonsapp) - plugins.load('example_iauthfunctions_v2') + ckan.plugins.load('example_iauthfunctions_v2') @classmethod def teardown_class(cls): - plugins.unload('example_iauthfunctions_v2') + ckan.plugins.unload('example_iauthfunctions_v2') def test_group_create_with_curator(self): '''Test that a curator can*not* create a group. diff --git a/ckanext/example_theme/__init__.py b/ckanext/example_theme/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/custom_config_setting/__init__.py b/ckanext/example_theme/custom_config_setting/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/custom_config_setting/plugin.py b/ckanext/example_theme/custom_config_setting/plugin.py new file mode 100644 index 00000000000..6985a5e64e9 --- /dev/null +++ b/ckanext/example_theme/custom_config_setting/plugin.py @@ -0,0 +1,65 @@ +import pylons.config as config + +import ckan.plugins as plugins +import ckan.plugins.toolkit as toolkit + + +def show_most_popular_groups(): + '''Return the value of the most_popular_groups config setting. + + To enable showing the most popular groups, add this line to the + [app:main] section of your CKAN config file:: + + ckan.example_theme.show_most_popular_groups = True + + Returns ``False`` by default, if the setting is not in the config file. + + :rtype: boolean + + ''' + value = config.get('ckan.example_theme.show_most_popular_groups', False) + value = toolkit.asbool(value) + return value + + +def most_popular_groups(): + '''Return a sorted list of the groups with the most datasets.''' + + # Get a list of all the site's groups from CKAN, sorted by number of + # datasets. + groups = toolkit.get_action('group_list')( + data_dict={'sort': 'packages desc', 'all_fields': True}) + + # Truncate the list to the 10 most popular groups only. + groups = groups[:10] + + return groups + + +class ExampleThemePlugin(plugins.SingletonPlugin): + '''An example theme plugin. + + ''' + plugins.implements(plugins.IConfigurer) + + # Declare that this plugin will implement ITemplateHelpers. + plugins.implements(plugins.ITemplateHelpers) + + def update_config(self, config): + + # Add this plugin's templates dir to CKAN's extra_template_paths, so + # that CKAN will use this plugin's custom templates. + toolkit.add_template_directory(config, 'templates') + + def get_helpers(self): + '''Register the most_popular_groups() function above as a template + helper function. + + ''' + # Template helper function names should begin with the name of the + # extension they belong to, to avoid clashing with functions from + # other extensions. + return {'example_theme_most_popular_groups': most_popular_groups, + 'example_theme_show_most_popular_groups': + show_most_popular_groups, + } diff --git a/ckanext/example_theme/custom_config_setting/templates/home/layout1.html b/ckanext/example_theme/custom_config_setting/templates/home/layout1.html new file mode 100644 index 00000000000..2756915852c --- /dev/null +++ b/ckanext/example_theme/custom_config_setting/templates/home/layout1.html @@ -0,0 +1,22 @@ +{% ckan_extends %} + +{% block featured_group %} +
          +
          +

          Recent activity

          +
          +
          + {{ h.recently_changed_packages_activity_stream(limit=4) }} +
          +
          +{% endblock %} + +{# Show the most popular groups if the show_most_popular_groups config setting + is True, otherwise call the super block. #} +{% block featured_organization %} + {% if h.example_theme_show_most_popular_groups() %} + {% snippet 'snippets/example_theme_most_popular_groups.html' %} + {% else %} + {{ super() }} + {% endif %} +{% endblock %} diff --git a/ckanext/example_theme/custom_config_setting/templates/snippets b/ckanext/example_theme/custom_config_setting/templates/snippets new file mode 120000 index 00000000000..3b8b13dd149 --- /dev/null +++ b/ckanext/example_theme/custom_config_setting/templates/snippets @@ -0,0 +1 @@ +../../v11_HTML_and_CSS/templates/snippets/ \ No newline at end of file diff --git a/ckanext/example_theme/v01_empty_extension/__init__.py b/ckanext/example_theme/v01_empty_extension/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v01_empty_extension/plugin.py b/ckanext/example_theme/v01_empty_extension/plugin.py new file mode 100644 index 00000000000..43686f7e315 --- /dev/null +++ b/ckanext/example_theme/v01_empty_extension/plugin.py @@ -0,0 +1,8 @@ +import ckan.plugins as plugins + + +class ExampleThemePlugin(plugins.SingletonPlugin): + '''An example theme plugin. + + ''' + pass diff --git a/ckanext/example_theme/v02_empty_template/__init__.py b/ckanext/example_theme/v02_empty_template/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v02_empty_template/plugin.py b/ckanext/example_theme/v02_empty_template/plugin.py new file mode 100644 index 00000000000..4484b6a0fe8 --- /dev/null +++ b/ckanext/example_theme/v02_empty_template/plugin.py @@ -0,0 +1,21 @@ +'''plugin.py + +''' +import ckan.plugins as plugins +import ckan.plugins.toolkit as toolkit + + +class ExampleThemePlugin(plugins.SingletonPlugin): + '''An example theme plugin. + + ''' + # Declare that this class implements IConfigurer. + plugins.implements(plugins.IConfigurer) + + def update_config(self, config): + + # Add this plugin's templates dir to CKAN's extra_template_paths, so + # that CKAN will use this plugin's custom templates. + # 'templates' is the path to the templates dir, relative to this + # plugin.py file. + toolkit.add_template_directory(config, 'templates') diff --git a/ckanext/example_theme/v02_empty_template/templates/home/index.html b/ckanext/example_theme/v02_empty_template/templates/home/index.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v03_jinja/__init__.py b/ckanext/example_theme/v03_jinja/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v03_jinja/plugin.py b/ckanext/example_theme/v03_jinja/plugin.py new file mode 120000 index 00000000000..b3fbefb50a3 --- /dev/null +++ b/ckanext/example_theme/v03_jinja/plugin.py @@ -0,0 +1 @@ +../v02_empty_template/plugin.py \ No newline at end of file diff --git a/ckanext/example_theme/v03_jinja/templates/home/index.html b/ckanext/example_theme/v03_jinja/templates/home/index.html new file mode 100644 index 00000000000..1762034752e --- /dev/null +++ b/ckanext/example_theme/v03_jinja/templates/home/index.html @@ -0,0 +1,24 @@ +{# Jinja variable example #} +

          The title of this site is: {{ app_globals.site_title }}.

          +{# End example #} + +{# Jinja for-loop example #} +

          The currently enabled plugins are:

          +
            + {% for plugin in app_globals.plugins %} +
          • {{ plugin }}
          • + {% endfor %} +
          +{# End example #} + +{# Jinja if example #} +{% if g.tracking_enabled %} +

          CKAN's page-view tracking feature is enabled.

          +{% else %} +

          CKAN's page-view tracking feature is not enabled.

          +{% endif %} +{# End example #} + +{# Jinja comment example #} +{# This text will not appear in the output when this template is rendered. #} +{# End example #} diff --git a/ckanext/example_theme/v04_ckan_extends/__init__.py b/ckanext/example_theme/v04_ckan_extends/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v04_ckan_extends/plugin.py b/ckanext/example_theme/v04_ckan_extends/plugin.py new file mode 120000 index 00000000000..03c7748d253 --- /dev/null +++ b/ckanext/example_theme/v04_ckan_extends/plugin.py @@ -0,0 +1 @@ +../v03_jinja/plugin.py \ No newline at end of file diff --git a/ckanext/example_theme/v04_ckan_extends/templates/home/index.html b/ckanext/example_theme/v04_ckan_extends/templates/home/index.html new file mode 100644 index 00000000000..c58cded7bfa --- /dev/null +++ b/ckanext/example_theme/v04_ckan_extends/templates/home/index.html @@ -0,0 +1 @@ +{% ckan_extends %} diff --git a/ckanext/example_theme/v05_block/__init__.py b/ckanext/example_theme/v05_block/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v05_block/plugin.py b/ckanext/example_theme/v05_block/plugin.py new file mode 120000 index 00000000000..8fd4d455da5 --- /dev/null +++ b/ckanext/example_theme/v05_block/plugin.py @@ -0,0 +1 @@ +../v04_ckan_extends/plugin.py \ No newline at end of file diff --git a/ckanext/example_theme/v05_block/templates/home/layout1.html b/ckanext/example_theme/v05_block/templates/home/layout1.html new file mode 100644 index 00000000000..0d30ad905df --- /dev/null +++ b/ckanext/example_theme/v05_block/templates/home/layout1.html @@ -0,0 +1,5 @@ +{% ckan_extends %} + +{% block featured_group %} + Hello block world! +{% endblock %} diff --git a/ckanext/example_theme/v06_super/__init__.py b/ckanext/example_theme/v06_super/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v06_super/plugin.py b/ckanext/example_theme/v06_super/plugin.py new file mode 120000 index 00000000000..2a362269bdb --- /dev/null +++ b/ckanext/example_theme/v06_super/plugin.py @@ -0,0 +1 @@ +../v05_block/plugin.py \ No newline at end of file diff --git a/ckanext/example_theme/v06_super/templates/home/layout1.html b/ckanext/example_theme/v06_super/templates/home/layout1.html new file mode 100644 index 00000000000..8661e1fb28c --- /dev/null +++ b/ckanext/example_theme/v06_super/templates/home/layout1.html @@ -0,0 +1,14 @@ +{% ckan_extends %} + +{% block featured_group %} + +

          This paragraph will be added to the top of the + featured_group block.

          + + {# Insert the contents of the original featured_group block: #} + {{ super() }} + +

          This paragraph will be added to the bottom of the + featured_group block.

          + +{% endblock %} diff --git a/ckanext/example_theme/v07_helper_function/__init__.py b/ckanext/example_theme/v07_helper_function/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v07_helper_function/plugin.py b/ckanext/example_theme/v07_helper_function/plugin.py new file mode 120000 index 00000000000..99eb7cdc156 --- /dev/null +++ b/ckanext/example_theme/v07_helper_function/plugin.py @@ -0,0 +1 @@ +../v06_super/plugin.py \ No newline at end of file diff --git a/ckanext/example_theme/v07_helper_function/templates/home/layout1.html b/ckanext/example_theme/v07_helper_function/templates/home/layout1.html new file mode 100644 index 00000000000..fd5c967a932 --- /dev/null +++ b/ckanext/example_theme/v07_helper_function/templates/home/layout1.html @@ -0,0 +1,5 @@ +{% ckan_extends %} + +{% block featured_group %} + {{ h.recently_changed_packages_activity_stream(limit=4) }} +{% endblock %} diff --git a/ckanext/example_theme/v08_custom_helper_function/__init__.py b/ckanext/example_theme/v08_custom_helper_function/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v08_custom_helper_function/plugin.py b/ckanext/example_theme/v08_custom_helper_function/plugin.py new file mode 100644 index 00000000000..5b3707b23b6 --- /dev/null +++ b/ckanext/example_theme/v08_custom_helper_function/plugin.py @@ -0,0 +1,42 @@ +import ckan.plugins as plugins +import ckan.plugins.toolkit as toolkit + + +def most_popular_groups(): + '''Return a sorted list of the groups with the most datasets.''' + + # Get a list of all the site's groups from CKAN, sorted by number of + # datasets. + groups = toolkit.get_action('group_list')( + data_dict={'sort': 'packages desc', 'all_fields': True}) + + # Truncate the list to the 10 most popular groups only. + groups = groups[:10] + + return groups + + +class ExampleThemePlugin(plugins.SingletonPlugin): + '''An example theme plugin. + + ''' + plugins.implements(plugins.IConfigurer) + + # Declare that this plugin will implement ITemplateHelpers. + plugins.implements(plugins.ITemplateHelpers) + + def update_config(self, config): + + # Add this plugin's templates dir to CKAN's extra_template_paths, so + # that CKAN will use this plugin's custom templates. + toolkit.add_template_directory(config, 'templates') + + def get_helpers(self): + '''Register the most_popular_groups() function above as a template + helper function. + + ''' + # Template helper function names should begin with the name of the + # extension they belong to, to avoid clashing with functions from + # other extensions. + return {'example_theme_most_popular_groups': most_popular_groups} diff --git a/ckanext/example_theme/v08_custom_helper_function/templates/home/layout1.html b/ckanext/example_theme/v08_custom_helper_function/templates/home/layout1.html new file mode 100644 index 00000000000..591ead7b3f8 --- /dev/null +++ b/ckanext/example_theme/v08_custom_helper_function/templates/home/layout1.html @@ -0,0 +1,17 @@ +{% ckan_extends %} + +{% block featured_group %} + {{ h.recently_changed_packages_activity_stream(limit=4) }} +{% endblock %} + +{% block featured_organization %} + + {# Show a list of the site's most popular groups. #} +

          Most popular groups

          +
            + {% for group in h.example_theme_most_popular_groups() %} +
          • {{ group.display_name }}
          • + {% endfor %} +
          + +{% endblock %} diff --git a/ckanext/example_theme/v09_snippet/__init__.py b/ckanext/example_theme/v09_snippet/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v09_snippet/plugin.py b/ckanext/example_theme/v09_snippet/plugin.py new file mode 120000 index 00000000000..ec82ac7f5fd --- /dev/null +++ b/ckanext/example_theme/v09_snippet/plugin.py @@ -0,0 +1 @@ +../v08_custom_helper_function/plugin.py \ No newline at end of file diff --git a/ckanext/example_theme/v09_snippet/templates/home/layout1.html b/ckanext/example_theme/v09_snippet/templates/home/layout1.html new file mode 100644 index 00000000000..ad7ca5825b7 --- /dev/null +++ b/ckanext/example_theme/v09_snippet/templates/home/layout1.html @@ -0,0 +1,17 @@ +{% ckan_extends %} + +{% block featured_group %} + + {{ h.recently_changed_packages_activity_stream(limit=4) }} + +{% endblock %} + +{% block featured_organization %} + +

          Most popular groups

          + + {# Call the group_list.html snippet. #} + {% snippet 'group/snippets/group_list.html', + groups=h.example_theme_most_popular_groups() %} + +{% endblock %} diff --git a/ckanext/example_theme/v10_custom_snippet/__init__.py b/ckanext/example_theme/v10_custom_snippet/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v10_custom_snippet/plugin.py b/ckanext/example_theme/v10_custom_snippet/plugin.py new file mode 120000 index 00000000000..ea164c3a9f4 --- /dev/null +++ b/ckanext/example_theme/v10_custom_snippet/plugin.py @@ -0,0 +1 @@ +../v09_snippet/plugin.py \ No newline at end of file diff --git a/ckanext/example_theme/v10_custom_snippet/templates/home/layout1.html b/ckanext/example_theme/v10_custom_snippet/templates/home/layout1.html new file mode 100644 index 00000000000..162690717bc --- /dev/null +++ b/ckanext/example_theme/v10_custom_snippet/templates/home/layout1.html @@ -0,0 +1,10 @@ +{% ckan_extends %} + +{% block featured_group %} + {{ h.recently_changed_packages_activity_stream(limit=4) }} +{% endblock %} + +{% block featured_organization %} + {% snippet 'snippets/example_theme_most_popular_groups.html', + groups=h.example_theme_most_popular_groups() %} +{% endblock %} diff --git a/ckanext/example_theme/v10_custom_snippet/templates/snippets/example_theme_most_popular_groups.html b/ckanext/example_theme/v10_custom_snippet/templates/snippets/example_theme_most_popular_groups.html new file mode 100644 index 00000000000..0dbe75a6517 --- /dev/null +++ b/ckanext/example_theme/v10_custom_snippet/templates/snippets/example_theme_most_popular_groups.html @@ -0,0 +1,29 @@ +{# + +Renders a list of the site's most popular groups. + +groups - the list of groups to render + +#} +

          Most popular groups

          +
            + {% for group in groups %} +
          • + +

            {{ group.display_name }}

            +
            + {% if group.description %} +

            + {{ h.markdown_extract(group.description, extract_length=80) }} +

            + {% else %} +

            {{ _('This group has no description') }}

            + {% endif %} + {% if group.packages %} + {{ ungettext('{num} Dataset', '{num} Datasets', group.packages).format(num=group.packages) }} + {% else %} + {{ _('0 Datasets') }} + {% endif %} +
          • + {% endfor %} +
          diff --git a/ckanext/example_theme/v11_HTML_and_CSS/__init__.py b/ckanext/example_theme/v11_HTML_and_CSS/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v11_HTML_and_CSS/plugin.py b/ckanext/example_theme/v11_HTML_and_CSS/plugin.py new file mode 120000 index 00000000000..b6af71ab429 --- /dev/null +++ b/ckanext/example_theme/v11_HTML_and_CSS/plugin.py @@ -0,0 +1 @@ +../v10_custom_snippet/plugin.py \ No newline at end of file diff --git a/ckanext/example_theme/v11_HTML_and_CSS/templates/home/layout1.html b/ckanext/example_theme/v11_HTML_and_CSS/templates/home/layout1.html new file mode 100644 index 00000000000..b6556cb3958 --- /dev/null +++ b/ckanext/example_theme/v11_HTML_and_CSS/templates/home/layout1.html @@ -0,0 +1,16 @@ +{% ckan_extends %} + +{% block featured_group %} +
          +
          +

          Recent activity

          +
          +
          + {{ h.recently_changed_packages_activity_stream(limit=4) }} +
          +
          +{% endblock %} + +{% block featured_organization %} + {% snippet 'snippets/example_theme_most_popular_groups.html' %} +{% endblock %} diff --git a/ckanext/example_theme/v11_HTML_and_CSS/templates/snippets/example_theme_most_popular_groups.html b/ckanext/example_theme/v11_HTML_and_CSS/templates/snippets/example_theme_most_popular_groups.html new file mode 100644 index 00000000000..7f4d56ecc63 --- /dev/null +++ b/ckanext/example_theme/v11_HTML_and_CSS/templates/snippets/example_theme_most_popular_groups.html @@ -0,0 +1,30 @@ +{# Renders a list of the site's most popular groups. #} + +
          +
          +

          Most popular groups

          +
          +
          +
            + {% for group in h.example_theme_most_popular_groups() %} +
          • + +

            {{ group.display_name }}

            +
            + {% if group.description %} +

            + {{ h.markdown_extract(group.description, extract_length=80) }} +

            + {% else %} +

            {{ _('This group has no description') }}

            + {% endif %} + {% if group.packages %} + {{ ungettext('{num} Dataset', '{num} Datasets', group.packages).format(num=group.packages) }} + {% else %} + {{ _('0 Datasets') }} + {% endif %} +
          • + {% endfor %} +
          +
          +
          diff --git a/ckanext/example_theme/v12_extra_public_dir/__init__.py b/ckanext/example_theme/v12_extra_public_dir/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v12_extra_public_dir/plugin.py b/ckanext/example_theme/v12_extra_public_dir/plugin.py new file mode 100644 index 00000000000..4bb3e36d4dc --- /dev/null +++ b/ckanext/example_theme/v12_extra_public_dir/plugin.py @@ -0,0 +1,46 @@ +import ckan.plugins as plugins +import ckan.plugins.toolkit as toolkit + + +def most_popular_groups(): + '''Return a sorted list of the groups with the most datasets.''' + + # Get a list of all the site's groups from CKAN, sorted by number of + # datasets. + groups = toolkit.get_action('group_list')( + data_dict={'sort': 'packages desc', 'all_fields': True}) + + # Truncate the list to the 10 most popular groups only. + groups = groups[:10] + + return groups + + +class ExampleThemePlugin(plugins.SingletonPlugin): + '''An example theme plugin. + + ''' + plugins.implements(plugins.IConfigurer) + + # Declare that this plugin will implement ITemplateHelpers. + plugins.implements(plugins.ITemplateHelpers) + + def update_config(self, config): + + # Add this plugin's templates dir to CKAN's extra_template_paths, so + # that CKAN will use this plugin's custom templates. + toolkit.add_template_directory(config, 'templates') + + # Add this plugin's public dir to CKAN's extra_public_paths, so + # that CKAN will use this plugin's custom static files. + toolkit.add_public_directory(config, 'public') + + def get_helpers(self): + '''Register the most_popular_groups() function above as a template + helper function. + + ''' + # Template helper function names should begin with the name of the + # extension they belong to, to avoid clashing with functions from + # other extensions. + return {'example_theme_most_popular_groups': most_popular_groups} diff --git a/ckanext/example_theme/v12_extra_public_dir/public/promoted-image.jpg b/ckanext/example_theme/v12_extra_public_dir/public/promoted-image.jpg new file mode 100644 index 00000000000..6cb24640e0b Binary files /dev/null and b/ckanext/example_theme/v12_extra_public_dir/public/promoted-image.jpg differ diff --git a/ckanext/example_theme/v12_extra_public_dir/templates/home/layout1.html b/ckanext/example_theme/v12_extra_public_dir/templates/home/layout1.html new file mode 120000 index 00000000000..8951cfd33b0 --- /dev/null +++ b/ckanext/example_theme/v12_extra_public_dir/templates/home/layout1.html @@ -0,0 +1 @@ +../../../v11_HTML_and_CSS/templates/home/layout1.html \ No newline at end of file diff --git a/ckanext/example_theme/v12_extra_public_dir/templates/home/snippets/promoted.html b/ckanext/example_theme/v12_extra_public_dir/templates/home/snippets/promoted.html new file mode 100644 index 00000000000..e45a805d82e --- /dev/null +++ b/ckanext/example_theme/v12_extra_public_dir/templates/home/snippets/promoted.html @@ -0,0 +1,13 @@ +{% ckan_extends %} + +{% block home_image_caption %} + {{ _("CKAN's data previewing tool has many powerful features") }} +{% endblock %} + +{# Replace the promoted image. #} +{% block home_image_content %} + + Featured image + +{% endblock %} diff --git a/ckanext/example_theme/v12_extra_public_dir/templates/snippets b/ckanext/example_theme/v12_extra_public_dir/templates/snippets new file mode 120000 index 00000000000..68a4f6d14be --- /dev/null +++ b/ckanext/example_theme/v12_extra_public_dir/templates/snippets @@ -0,0 +1 @@ +../../v11_HTML_and_CSS/templates/snippets \ No newline at end of file diff --git a/ckanext/example_theme/v13_custom_css/__init__.py b/ckanext/example_theme/v13_custom_css/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v13_custom_css/plugin.py b/ckanext/example_theme/v13_custom_css/plugin.py new file mode 120000 index 00000000000..bc2b4e7bc10 --- /dev/null +++ b/ckanext/example_theme/v13_custom_css/plugin.py @@ -0,0 +1 @@ +../v12_extra_public_dir/plugin.py \ No newline at end of file diff --git a/ckanext/example_theme/v13_custom_css/public/example_theme.css b/ckanext/example_theme/v13_custom_css/public/example_theme.css new file mode 100644 index 00000000000..1d9ae84f4ee --- /dev/null +++ b/ckanext/example_theme/v13_custom_css/public/example_theme.css @@ -0,0 +1,3 @@ +.account-masthead { + background-color: rgb(40, 40, 40); +} diff --git a/ckanext/example_theme/v13_custom_css/public/promoted-image.jpg b/ckanext/example_theme/v13_custom_css/public/promoted-image.jpg new file mode 120000 index 00000000000..085db09655e --- /dev/null +++ b/ckanext/example_theme/v13_custom_css/public/promoted-image.jpg @@ -0,0 +1 @@ +../../v12_extra_public_dir/public/promoted-image.jpg \ No newline at end of file diff --git a/ckanext/example_theme/v13_custom_css/templates/base.html b/ckanext/example_theme/v13_custom_css/templates/base.html new file mode 100644 index 00000000000..10d537abd84 --- /dev/null +++ b/ckanext/example_theme/v13_custom_css/templates/base.html @@ -0,0 +1,6 @@ +{% ckan_extends %} + +{% block styles %} + {{ super() }} + +{% endblock %} diff --git a/ckanext/example_theme/v13_custom_css/templates/home b/ckanext/example_theme/v13_custom_css/templates/home new file mode 120000 index 00000000000..0082d3e1c87 --- /dev/null +++ b/ckanext/example_theme/v13_custom_css/templates/home @@ -0,0 +1 @@ +../../v12_extra_public_dir/templates/home \ No newline at end of file diff --git a/ckanext/example_theme/v13_custom_css/templates/snippets b/ckanext/example_theme/v13_custom_css/templates/snippets new file mode 120000 index 00000000000..c54766741bb --- /dev/null +++ b/ckanext/example_theme/v13_custom_css/templates/snippets @@ -0,0 +1 @@ +../../v12_extra_public_dir/templates/snippets \ No newline at end of file diff --git a/ckanext/example_theme/v14_more_custom_css/__init__.py b/ckanext/example_theme/v14_more_custom_css/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v14_more_custom_css/plugin.py b/ckanext/example_theme/v14_more_custom_css/plugin.py new file mode 120000 index 00000000000..d976b2f7ad1 --- /dev/null +++ b/ckanext/example_theme/v14_more_custom_css/plugin.py @@ -0,0 +1 @@ +../v13_custom_css/plugin.py \ No newline at end of file diff --git a/ckanext/example_theme/v14_more_custom_css/public/example_theme.css b/ckanext/example_theme/v14_more_custom_css/public/example_theme.css new file mode 100644 index 00000000000..65e08d70170 --- /dev/null +++ b/ckanext/example_theme/v14_more_custom_css/public/example_theme.css @@ -0,0 +1,103 @@ +/* ===================================================== + The "account masthead" bar across the top of the site + ===================================================== */ + +.account-masthead { + background-color: rgb(40, 40, 40); +} +/* The "bubble" containing the number of new notifications. */ +.account-masthead .account .notifications a span { + background-color: black; +} +/* The text and icons in the user account info. */ +.account-masthead .account ul li a { + color: rgba(255, 255, 255, 0.6); +} +/* The user account info text and icons, when the user's pointer is hovering + over them. */ +.account-masthead .account ul li a:hover { + color: rgba(255, 255, 255, 0.7); + background-color: black; +} + + +/* ======================================================================== + The main masthead bar that contains the site logo, nav links, and search + ======================================================================== */ + +.masthead { + background-color: #3d3d3d; +} +/* The "navigation pills" in the masthead (the links to Datasets, + Organizations, etc) when the user's pointer hovers over them. */ +.masthead .navigation .nav-pills li a:hover { + background-color: rgb(48, 48, 48); + color: white; +} +/* The "active" navigation pill (for example, when you're on the /dataset page + the "Datasets" link is active). */ +.masthead .navigation .nav-pills li.active a { + background-color: rgb(74, 74, 74); +} +/* The "box shadow" effect that appears around the search box when it + has the keyboard cursor's focus. */ +.masthead input[type="text"]:focus { + -webkit-box-shadow: inset 0px 0px 2px 0px rgba(0, 0, 0, 0.7); + box-shadow: inset 0px 0px 2px 0px rgba(0, 0, 0, 0.7); +} + + +/* =========================================== + The content in the middle of the front page + =========================================== */ + +/* Remove the "box shadow" effect around various boxes on the page. */ +.box { + box-shadow: none; +} +/* Remove the borders around the "Welcome to CKAN" and "Search Your Data" + boxes. */ +.hero .box { + border: none; +} +/* Change the colors of the "Search Your Data" box. */ +.homepage .module-search .module-content { + color: rgb(68, 68, 68); + background-color: white; +} +/* Change the background color of the "Popular Tags" box. */ +.homepage .module-search .tags { + background-color: rgb(61, 61, 61); +} +/* Remove some padding. This makes the bottom edges of the "Welcome to CKAN" + and "Search Your Data" boxes line up. */ +.module-content:last-child { + padding-bottom: 0px; +} +.homepage .module-search { + padding: 0px; +} +/* Add a border line between the top and bottom halves of the front page. */ +.homepage [role="main"] { + border-top: 1px solid rgb(204, 204, 204); +} + + +/* ==================================== + The footer at the bottom of the site + ==================================== */ + +.site-footer, +body { + background-color: rgb(40, 40, 40); +} +/* The text in the footer. */ +.site-footer, +.site-footer label, +.site-footer small { + color: rgba(255, 255, 255, 0.6); +} +/* The link texts in the footer. */ +.site-footer a { + color: rgba(255, 255, 255, 0.6); +} diff --git a/ckanext/example_theme/v14_more_custom_css/public/promoted-image.jpg b/ckanext/example_theme/v14_more_custom_css/public/promoted-image.jpg new file mode 120000 index 00000000000..242bd4b1873 --- /dev/null +++ b/ckanext/example_theme/v14_more_custom_css/public/promoted-image.jpg @@ -0,0 +1 @@ +../../v13_custom_css/public/promoted-image.jpg \ No newline at end of file diff --git a/ckanext/example_theme/v14_more_custom_css/templates b/ckanext/example_theme/v14_more_custom_css/templates new file mode 120000 index 00000000000..5ab3de686dd --- /dev/null +++ b/ckanext/example_theme/v14_more_custom_css/templates @@ -0,0 +1 @@ +../v13_custom_css/templates \ No newline at end of file diff --git a/ckanext/example_theme/v15_fanstatic/__init__.py b/ckanext/example_theme/v15_fanstatic/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ckanext/example_theme/v15_fanstatic/fanstatic/example_theme.css b/ckanext/example_theme/v15_fanstatic/fanstatic/example_theme.css new file mode 100644 index 00000000000..65e08d70170 --- /dev/null +++ b/ckanext/example_theme/v15_fanstatic/fanstatic/example_theme.css @@ -0,0 +1,103 @@ +/* ===================================================== + The "account masthead" bar across the top of the site + ===================================================== */ + +.account-masthead { + background-color: rgb(40, 40, 40); +} +/* The "bubble" containing the number of new notifications. */ +.account-masthead .account .notifications a span { + background-color: black; +} +/* The text and icons in the user account info. */ +.account-masthead .account ul li a { + color: rgba(255, 255, 255, 0.6); +} +/* The user account info text and icons, when the user's pointer is hovering + over them. */ +.account-masthead .account ul li a:hover { + color: rgba(255, 255, 255, 0.7); + background-color: black; +} + + +/* ======================================================================== + The main masthead bar that contains the site logo, nav links, and search + ======================================================================== */ + +.masthead { + background-color: #3d3d3d; +} +/* The "navigation pills" in the masthead (the links to Datasets, + Organizations, etc) when the user's pointer hovers over them. */ +.masthead .navigation .nav-pills li a:hover { + background-color: rgb(48, 48, 48); + color: white; +} +/* The "active" navigation pill (for example, when you're on the /dataset page + the "Datasets" link is active). */ +.masthead .navigation .nav-pills li.active a { + background-color: rgb(74, 74, 74); +} +/* The "box shadow" effect that appears around the search box when it + has the keyboard cursor's focus. */ +.masthead input[type="text"]:focus { + -webkit-box-shadow: inset 0px 0px 2px 0px rgba(0, 0, 0, 0.7); + box-shadow: inset 0px 0px 2px 0px rgba(0, 0, 0, 0.7); +} + + +/* =========================================== + The content in the middle of the front page + =========================================== */ + +/* Remove the "box shadow" effect around various boxes on the page. */ +.box { + box-shadow: none; +} +/* Remove the borders around the "Welcome to CKAN" and "Search Your Data" + boxes. */ +.hero .box { + border: none; +} +/* Change the colors of the "Search Your Data" box. */ +.homepage .module-search .module-content { + color: rgb(68, 68, 68); + background-color: white; +} +/* Change the background color of the "Popular Tags" box. */ +.homepage .module-search .tags { + background-color: rgb(61, 61, 61); +} +/* Remove some padding. This makes the bottom edges of the "Welcome to CKAN" + and "Search Your Data" boxes line up. */ +.module-content:last-child { + padding-bottom: 0px; +} +.homepage .module-search { + padding: 0px; +} +/* Add a border line between the top and bottom halves of the front page. */ +.homepage [role="main"] { + border-top: 1px solid rgb(204, 204, 204); +} + + +/* ==================================== + The footer at the bottom of the site + ==================================== */ + +.site-footer, +body { + background-color: rgb(40, 40, 40); +} +/* The text in the footer. */ +.site-footer, +.site-footer label, +.site-footer small { + color: rgba(255, 255, 255, 0.6); +} +/* The link texts in the footer. */ +.site-footer a { + color: rgba(255, 255, 255, 0.6); +} diff --git a/ckanext/example_theme/v15_fanstatic/plugin.py b/ckanext/example_theme/v15_fanstatic/plugin.py new file mode 100644 index 00000000000..f1a82d96ac1 --- /dev/null +++ b/ckanext/example_theme/v15_fanstatic/plugin.py @@ -0,0 +1,53 @@ +import ckan.plugins as plugins +import ckan.plugins.toolkit as toolkit + + +def most_popular_groups(): + '''Return a sorted list of the groups with the most datasets.''' + + # Get a list of all the site's groups from CKAN, sorted by number of + # datasets. + groups = toolkit.get_action('group_list')( + data_dict={'sort': 'packages desc', 'all_fields': True}) + + # Truncate the list to the 10 most popular groups only. + groups = groups[:10] + + return groups + + +class ExampleThemePlugin(plugins.SingletonPlugin): + '''An example theme plugin. + + ''' + plugins.implements(plugins.IConfigurer) + + # Declare that this plugin will implement ITemplateHelpers. + plugins.implements(plugins.ITemplateHelpers) + + def update_config(self, config): + + # Add this plugin's templates dir to CKAN's extra_template_paths, so + # that CKAN will use this plugin's custom templates. + toolkit.add_template_directory(config, 'templates') + + # Add this plugin's public dir to CKAN's extra_public_paths, so + # that CKAN will use this plugin's custom static files. + toolkit.add_public_directory(config, 'public') + + # Register this plugin's fanstatic directory with CKAN. + # Here, 'fanstatic' is the path to the fanstatic directory + # (relative to this plugin.py file), and 'example_theme' is the name + # that we'll use to refer to this fanstatic directory from CKAN + # templates. + toolkit.add_resource('fanstatic', 'example_theme') + + def get_helpers(self): + '''Register the most_popular_groups() function above as a template + helper function. + + ''' + # Template helper function names should begin with the name of the + # extension they belong to, to avoid clashing with functions from + # other extensions. + return {'example_theme_most_popular_groups': most_popular_groups} diff --git a/ckanext/example_theme/v15_fanstatic/public/promoted-image.jpg b/ckanext/example_theme/v15_fanstatic/public/promoted-image.jpg new file mode 120000 index 00000000000..28c5bf28cb0 --- /dev/null +++ b/ckanext/example_theme/v15_fanstatic/public/promoted-image.jpg @@ -0,0 +1 @@ +../../v14_more_custom_css/public/promoted-image.jpg \ No newline at end of file diff --git a/ckanext/example_theme/v15_fanstatic/templates/base.html b/ckanext/example_theme/v15_fanstatic/templates/base.html new file mode 100644 index 00000000000..b8466ef9d41 --- /dev/null +++ b/ckanext/example_theme/v15_fanstatic/templates/base.html @@ -0,0 +1,12 @@ +{% ckan_extends %} + +{% block styles %} + {{ super() }} + + {# Import example_theme.css using Fanstatic. + 'example_theme/' is the name that the example_theme/fanstatic directory + was registered with when the toolkit.add_resource() function was called. + 'example_theme.css' is the path to the CSS file, relative to the root of + the fanstatic directory. #} + {% resource 'example_theme/example_theme.css' %} +{% endblock %} diff --git a/ckanext/example_theme/v15_fanstatic/templates/home b/ckanext/example_theme/v15_fanstatic/templates/home new file mode 120000 index 00000000000..ce6d4453982 --- /dev/null +++ b/ckanext/example_theme/v15_fanstatic/templates/home @@ -0,0 +1 @@ +../../v14_more_custom_css/templates/home \ No newline at end of file diff --git a/ckanext/example_theme/v15_fanstatic/templates/snippets b/ckanext/example_theme/v15_fanstatic/templates/snippets new file mode 120000 index 00000000000..3a256bb7366 --- /dev/null +++ b/ckanext/example_theme/v15_fanstatic/templates/snippets @@ -0,0 +1 @@ +../../v14_more_custom_css/templates/snippets \ No newline at end of file diff --git a/ckanext/example_theme/v16_initialize_a_javascript_module/fanstatic/favorite.js b/ckanext/example_theme/v16_initialize_a_javascript_module/fanstatic/favorite.js new file mode 100644 index 00000000000..a3a142210e5 --- /dev/null +++ b/ckanext/example_theme/v16_initialize_a_javascript_module/fanstatic/favorite.js @@ -0,0 +1,8 @@ +ckan.module('favorite', function (jQuery, _) { + return { + initialize: function () { + console.log("I've been initialized for element: %o", this.el); + } + }; +}); + diff --git a/ckanext/example_theme/v16_initialize_a_javascript_module/plugin.py b/ckanext/example_theme/v16_initialize_a_javascript_module/plugin.py new file mode 100644 index 00000000000..803fb73181a --- /dev/null +++ b/ckanext/example_theme/v16_initialize_a_javascript_module/plugin.py @@ -0,0 +1,14 @@ +import ckan.plugins as plugins +import ckan.plugins.toolkit as toolkit + + +class ExampleThemePlugin(plugins.SingletonPlugin): + '''An example theme plugin. + + ''' + plugins.implements(plugins.IConfigurer) + + def update_config(self, config): + + toolkit.add_template_directory(config, 'templates') + toolkit.add_resource('fanstatic', 'example_theme') diff --git a/ckanext/example_theme/v16_initialize_a_javascript_module/public b/ckanext/example_theme/v16_initialize_a_javascript_module/public new file mode 120000 index 00000000000..f23890633ee --- /dev/null +++ b/ckanext/example_theme/v16_initialize_a_javascript_module/public @@ -0,0 +1 @@ +../v14_more_custom_css/public \ No newline at end of file diff --git a/ckanext/example_theme/v16_initialize_a_javascript_module/templates/home/index.html b/ckanext/example_theme/v16_initialize_a_javascript_module/templates/home/index.html new file mode 100644 index 00000000000..4b534a2fe2b --- /dev/null +++ b/ckanext/example_theme/v16_initialize_a_javascript_module/templates/home/index.html @@ -0,0 +1,15 @@ +{% ckan_extends %} + + +{% block content %} + + {{ super() }} + + {# Use Fanstatic to include our custom JavaScript module. #} + {% resource 'example_theme/favorite.js' %} + +
          + +
          + +{% endblock %} diff --git a/ckanext/example_theme/v16_initialize_a_javascript_module/templates/snippets/package_item.html b/ckanext/example_theme/v16_initialize_a_javascript_module/templates/snippets/package_item.html new file mode 100644 index 00000000000..078659c9165 --- /dev/null +++ b/ckanext/example_theme/v16_initialize_a_javascript_module/templates/snippets/package_item.html @@ -0,0 +1,22 @@ +{% ckan_extends %} + +{% block package_item_content %} + {{ super() }} + + {# Use Fanstatic to include our custom JavaScript module. + A