diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 2b7e23f..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Bug report -about: Report a bug you've found in this extension -title: '' -labels: bug -assignees: '' - ---- - -**Description** -A clear and concise description of what the bug is. - -**Expected Behaviour** -What you expected to happen instead. - -**To Reproduce** -Steps to reproduce the bug. - -**Error Log** -Paste any relevant error logs below: -``` - -``` - -**Screenshots** -Add screenshots to illustrate the bug if you want. - -**Your Setup** - - CKAN version: [e.g. 2.8.3] - - Commit/version of this repo: - - Browser (if relevant): [e.g. chrome, safari] - -**Anything Else?** -... diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 7638916..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Feature request -about: Suggest an feature or improvement for this extension -title: '' -labels: enhancement -assignees: '' - ---- - -**Overview** -Give a brief description of what the feature or improvement should do and why. - -**Possible Solutions** -Do you have any ideas for how you'd want to implement it? - -**Anything Else?** -Add any other information here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index a8539ff..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,4 +0,0 @@ -- [ ] I have read the [section on commits and pull requests](https://github.com/NaturalHistoryMuseum/ckanext-contact/blob/main/CONTRIBUTING.md#commits-and-pull-requests) in `CONTRIBUTING.md` - - -Describe your changes, tagging relevant issues where possible. diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md deleted file mode 100644 index 030e7f4..0000000 --- a/.github/SUPPORT.md +++ /dev/null @@ -1,16 +0,0 @@ -# Support - -## Documentation -- [ckanext-contact documentation](https://ckanext-contact.readthedocs.io) -- [Our API documentation](https://naturalhistorymuseum.github.io/dataportal-docs) -- [Official CKAN documentation](http://docs.ckan.org/en/latest) - -## Issues -- [Issues for ckanext-contact](https://github.com/NaturalHistoryMuseum/ckanext-contact/issues) -- [The NHM on Github](https://github.com/NaturalHistoryMuseum) -- [General issue tracker](https://github.com/NaturalHistoryMuseum/data-portal-issues/issues) -- [Our CKAN extensions](https://github.com/search?q=topic:ckan+org:NaturalHistoryMuseum&type=repositories) (for more specific issues) - -## Contact Us -- [Gitter](https://gitter.im/nhm-data-portal/lobby) -- [Email _data@nhm.ac.uk_](mailto:data@nhm.ac.uk) diff --git a/.github/workflows/bump.yml b/.github/workflows/bump.yml deleted file mode 100644 index 1bb1860..0000000 --- a/.github/workflows/bump.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Bump version - -on: - push: - branches: - - main - -jobs: - bump-version: - name: Bump version and create changelog - runs-on: ubuntu-latest - if: "!startsWith(github.event.head_commit.message, 'bump:')" - steps: - - name: Checkout source code - uses: actions/checkout@v4 - with: - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - fetch-depth: 0 - - name: Create bump and changelog - uses: commitizen-tools/commitizen-action@master - with: - github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - changelog_increment_filename: CURRENT.md - extra_requirements: "cz-nhm" - - name: Release - uses: softprops/action-gh-release@v1 - with: - body_path: "CURRENT.md" - tag_name: v${{ env.REVISION }} - env: - GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml deleted file mode 100644 index 8ee1f08..0000000 --- a/.github/workflows/pull-requests.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Validate pull requests - -on: - pull_request: - types: [opened, edited, reopened, synchronize] - -jobs: - validate-commits: - name: Validate commit messages - runs-on: ubuntu-latest - steps: - - name: Checkout source code - uses: actions/checkout@v4 - - name: Check commit message format - uses: webiny/action-conventional-commits@v1.3.0 - with: - allowed-commit-types: 'bump,feat,fix,refactor,perf,docs,style,test,tests,build,ci,chore,new,patch,revert,ui,merge' - pre-commit: - name: Run pre-commit checks - runs-on: ubuntu-latest - steps: - - name: Checkout source code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - - name: Run pre-commit - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml deleted file mode 100644 index bf30290..0000000 --- a/.github/workflows/pypi-publish.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Upload Python Package - -on: - push: - tags: - - "*" - -permissions: - contents: read - -jobs: - deploy: - name: Deploy package to PyPI - runs-on: ubuntu-latest - steps: - - name: Checkout source code - uses: actions/checkout@v4 - with: - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@v5 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} - skip_existing: true diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml deleted file mode 100644 index 4fec115..0000000 --- a/.github/workflows/sync.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Sync branches - -on: - push: - branches: - - main - -jobs: - sync-branches: - name: Sync dev and patch branches to latest commit - runs-on: ubuntu-latest - steps: - - name: Checkout source code - uses: actions/checkout@v4 - with: - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - fetch-depth: 0 - - name: Sync dev branch - uses: connor-baer/action-sync-branch@main - with: - branch: dev - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - - name: Sync patch branch - uses: connor-baer/action-sync-branch@main - with: - branch: patch - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4d954a5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,69 @@ +name: Tests +on: [push, pull_request] +env: + CODE_COVERAGE_THRESHOLD_REQUIRED: 65 +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.9' + - name: Install requirements + run: pip install flake8 pycodestyle + - name: Check syntax + run: flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics --exclude ckan + + test: + needs: lint + strategy: + matrix: + include: + - ckan-version: "2.11" + ckan-image: "ckan/ckan-dev:2.11-py3.10" + solr-version: "9" + - ckan-version: "2.10" + ckan-image: "ckan/ckan-dev:2.10-py3.10" + solr-version: "9" + - ckan-version: "2.9" + ckan-image: "ckan/ckan-dev:2.9-py3.9" + solr-version: "8" + fail-fast: false + name: CKAN ${{ matrix.ckan-version }} + runs-on: ubuntu-latest + container: + image: ${{ matrix.ckan-image }} + options: --user root + services: + solr: + image: ckan/ckan-solr:${{ matrix.ckan-version }}-solr${{ matrix.solr-version }} + postgres: + image: ckan/ckan-postgres-dev:${{ matrix.ckan-version }} + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + redis: + image: redis:3 + env: + CKAN_SQLALCHEMY_URL: postgresql://ckan_default:pass@postgres/ckan_test + CKAN_DATASTORE_WRITE_URL: postgresql://datastore_write:pass@postgres/datastore_test + CKAN_DATASTORE_READ_URL: postgresql://datastore_read:pass@postgres/datastore_test + CKAN_SOLR_URL: http://solr:8983/solr/ckan + CKAN_REDIS_URL: redis://redis:6379/1 + + steps: + - uses: actions/checkout@v4 + - name: Install requirements + run: | + pip install -r dev-requirements.txt + pip install -e . + # Replace default path to CKAN core config file with the one on the container + sed -i -e 's/use = config:.*/use = config:\/srv\/app\/src\/ckan\/test-core.ini/' test.ini + - name: Setup extension + run: | + ckan -c test.ini db init + - name: Run tests + run: pytest --ckan-ini=test.ini --cov=ckanext.contact --disable-warnings --cov-fail-under=${CODE_COVERAGE_THRESHOLD_REQUIRED} ckanext/contact/tests diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 5b3f931..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Tests - -on: - push: - workflow_dispatch: - pull_request: - types: [opened, edited, reopened, synchronize] - -jobs: - test: - name: Run tests - runs-on: ubuntu-latest - steps: - - name: Checkout source code - uses: actions/checkout@v4 - - name: Build images - run: docker compose build - - name: Run tests - env: - COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} - run: docker compose run -e COVERALLS_REPO_TOKEN ckan bash /opt/scripts/run-tests.sh -c ckanext.contact diff --git a/.gitignore b/.gitignore index f0abdeb..0201b33 100755 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ dist/ .idea **/node_modules/ +.DS_Store diff --git a/ckanext/contact/routes/_helpers.py b/ckanext/contact/routes/_helpers.py index e71fbaa..759d712 100644 --- a/ckanext/contact/routes/_helpers.py +++ b/ckanext/contact/routes/_helpers.py @@ -13,6 +13,7 @@ from ckan.lib.navl.dictization_functions import unflatten from ckan.plugins import PluginImplementations, toolkit from pyisemail import is_email +from urllib.parse import urlparse from ckanext.contact import recaptcha from ckanext.contact.interfaces import IContact @@ -30,7 +31,7 @@ def validate(data_dict): """ errors = {} error_summary = {} - optional_fields = {'subject'} + optional_fields = {'subject', 'referrer_url'} recaptcha_error = None # check each field to see if it has a value and if not, show and error @@ -54,6 +55,14 @@ def validate(data_dict): errors['email'] = ['Email address appears to be invalid'] error_summary['email'] = 'Email address appears to be invalid' + # check if referrer_url starts with site_url + referrer_url = data_dict.get('referrer_url', '').strip() + if referrer_url: + site_url = toolkit.config.get('ckan.site_url', '') + if site_url: + if not referrer_url.startswith(site_url): + data_dict.pop('referrer_url', None) + # only check the recaptcha if there are no errors if not errors: try: @@ -98,6 +107,36 @@ def build_subject( return f'{prefix}{" " if prefix else ""}{subject}' +def get_dataset_title_from_url(url): + """ + Try to extract the dataset title from a CKAN URL. + + :param url: the URL to parse + :return: dataset title if successful, None on any error + """ + if not url: + return None + + try: + # Extract package identifier from URL + parsed_url = urlparse(url) + path_parts = parsed_url.path.split('/') + if len(path_parts) >= 3 and path_parts[1] == 'dataset': + package_id = path_parts[2] + else: + return None + + if not package_id: + return None + + # Fetch dataset title using package_show action + context = {'ignore_auth': True} + package_dict = toolkit.get_action('package_show')(context, {'id': package_id}) + return package_dict.get('title') + except Exception: + return None + + def submit(): """ Take the data in the request params and send an email using them. If the data is @@ -124,6 +163,14 @@ def submit(): f' Name: {data_dict["name"]}', f' Email: {data_dict["email"]}', ] + # include referrer URL if available + referrer_url = data_dict.get('referrer_url', '').strip() + if referrer_url: + body_parts.append(f' Referrer URL: {referrer_url}') + # try to get dataset title if referrer_url is from a dataset + dataset_title = get_dataset_title_from_url(referrer_url) + if dataset_title: + body_parts.append(f' Dataset Title: {dataset_title}') mail_dict = { 'recipient_email': toolkit.config.get( 'ckanext.contact.mail_to', toolkit.config.get('email_to') diff --git a/tests/__init__.py b/ckanext/contact/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to ckanext/contact/tests/__init__.py diff --git a/tests/unit/__init__.py b/ckanext/contact/tests/unit/__init__.py similarity index 100% rename from tests/unit/__init__.py rename to ckanext/contact/tests/unit/__init__.py diff --git a/tests/unit/test_auth.py b/ckanext/contact/tests/unit/test_auth.py similarity index 100% rename from tests/unit/test_auth.py rename to ckanext/contact/tests/unit/test_auth.py diff --git a/tests/unit/test_helpers.py b/ckanext/contact/tests/unit/test_helpers.py similarity index 54% rename from tests/unit/test_helpers.py rename to ckanext/contact/tests/unit/test_helpers.py index d8b77a1..00e7cfa 100644 --- a/tests/unit/test_helpers.py +++ b/ckanext/contact/tests/unit/test_helpers.py @@ -1,9 +1,10 @@ from datetime import datetime, timezone import pytest +from mock import patch, MagicMock from freezegun import freeze_time -from ckanext.contact.routes._helpers import build_subject +from ckanext.contact.routes._helpers import build_subject, get_dataset_title_from_url class TestBuildSubject: @@ -83,3 +84,60 @@ def test_prefix_not_provided(self): def test_prefix_provided(self): subject = build_subject(subject='TEST') assert subject == 'PREFIX: TEST' + + +class TestGetDatasetTitleFromUrl: + @patch('ckanext.contact.routes._helpers.toolkit.get_action') + def test_valid_dataset_url_returns_title(self, mock_get_action): + mock_package_show = MagicMock(return_value={'title': 'Test Dataset Title'}) + mock_get_action.return_value = mock_package_show + + url = 'http://example.com/dataset/test-package-id' + result = get_dataset_title_from_url(url) + + assert result == 'Test Dataset Title' + mock_get_action.assert_called_once_with('package_show') + mock_package_show.assert_called_once_with( + {'ignore_auth': True}, {'id': 'test-package-id'} + ) + + @patch('ckanext.contact.routes._helpers.toolkit.get_action') + def test_dataset_url_with_resource_returns_title(self, mock_get_action): + mock_package_show = MagicMock(return_value={'title': 'Dataset with Resource'}) + mock_get_action.return_value = mock_package_show + + url = 'http://example.com/dataset/my-package/resource/resource-id' + result = get_dataset_title_from_url(url) + + assert result == 'Dataset with Resource' + mock_package_show.assert_called_once_with( + {'ignore_auth': True}, {'id': 'my-package'} + ) + + def test_empty_url_returns_none(self): + result = get_dataset_title_from_url('') + assert result is None + + def test_none_url_returns_none(self): + result = get_dataset_title_from_url(None) + assert result is None + + def test_non_dataset_url_returns_none(self): + result = get_dataset_title_from_url('http://example.com/organization/test') + assert result is None + + def test_dataset_search_url_returns_none(self): + result = get_dataset_title_from_url( + 'http://example.com/dataset/?organization=cabinet-office&license_id=notspecified' + ) + assert result is None + + @patch('ckanext.contact.routes._helpers.toolkit.get_action') + def test_package_show_exception_returns_none(self, mock_get_action): + mock_package_show = MagicMock(side_effect=Exception('Dataset not found')) + mock_get_action.return_value = mock_package_show + + url = 'http://example.com/dataset/non-existent-package' + result = get_dataset_title_from_url(url) + + assert result is None diff --git a/ckanext/contact/theme/assets/modules/form-contact.js b/ckanext/contact/theme/assets/modules/form-contact.js index fcc8f93..a1e36e0 100644 --- a/ckanext/contact/theme/assets/modules/form-contact.js +++ b/ckanext/contact/theme/assets/modules/form-contact.js @@ -18,6 +18,12 @@ ckan.module('form-contact', function ($, _) { initialize: function () { self = this; + // populate the referrer URL field with document.referrer + const referrerField = self.el.find('#field-referrer-url'); + if (referrerField.length) { + referrerField.val(document.referrer || ''); + } + // setup the recaptcha context self.context = window.contacts_recaptcha.load( self.options.key, diff --git a/ckanext/contact/theme/assets/modules/modal-contact.js b/ckanext/contact/theme/assets/modules/modal-contact.js index d9151b1..6ace960 100644 --- a/ckanext/contact/theme/assets/modules/modal-contact.js +++ b/ckanext/contact/theme/assets/modules/modal-contact.js @@ -50,6 +50,11 @@ ckan.module('modal-contact', function ($, _) { ); self.modal = $(html); + // populate the referrer URL field with the current page URL + const referrerField = self.modal.find('#field-referrer-url'); + if (referrerField.length) { + referrerField.val(window.location.href || ''); + } // add a close button to the modal self.modal .find('.modal-header :header') diff --git a/ckanext/contact/theme/templates/contact/snippets/form.html b/ckanext/contact/theme/templates/contact/snippets/form.html index 1366ba4..92bb533 100644 --- a/ckanext/contact/theme/templates/contact/snippets/form.html +++ b/ckanext/contact/theme/templates/contact/snippets/form.html @@ -33,6 +33,8 @@ {{ form.textarea('content', label=_('Comment'), id='field-content', value=data.content, error=errors.content, placeholder=_('What do you have to tell us?'), is_required=true) }} + + {% endblock %} diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..81e95bf --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,3 @@ +pytest-ckan +pytest-cov +mock \ No newline at end of file