diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 00000000..341305eb --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,29 @@ +name: Changelog + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +jobs: + has_changelog: + runs-on: ubuntu-latest + # Opt-out: PRs labeled 'No changelog' skip the check entirely + # (docs-only, dependency bumps, CI tweaks). + if: ${{ !contains(github.event.pull_request.labels.*.name, 'No changelog') }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check that the PR adds a changelog entry + run: | + git fetch origin "${{ github.base_ref }}" + head_sha="${{ github.event.pull_request.head.sha || 'HEAD' }}" + changed="$(git diff --name-only "origin/${{ github.base_ref }}"..."$head_sha" -- changelog/)" + if [ -z "$changed" ]; then + echo "::error::No changelog entry found. Add a file under changelog/ (see changelog/README.md), or add the 'No changelog' label if this PR is docs/CI/deps-only." + exit 1 + fi + echo "Changelog entry present:" + echo "$changed" diff --git a/README.md b/README.md index bec26022..1f8107fa 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Code Yellow backend framework for SPA webapps with REST-like API. - Run with `./test` - Access the test database directly by with `docker compose run --rm db psql -h db -U postgres`. -- It may be possible to recreate the test database (for example when you added/changed models). One way of achieving this is to just remove all the docker images that were build `docker compose rm`. The database will be created during the setup in `tests/__init__.py`. +- It may be possible to recreate the test database (for example when you added/changed models). One way of achieving this is to just remove all the docker images that were build `docker compose rm` or `docker compose down db binder`. The database will be created during the setup in `tests/__init__.py`. The tests are set up in such a way that there is no need to keep migration files. The setup procedure in `tests/__init__.py` handles the preparation of the database by directly calling some build-in Django commands. diff --git a/binder/models.py b/binder/models.py index fbcc341c..f38b7875 100644 --- a/binder/models.py +++ b/binder/models.py @@ -244,11 +244,14 @@ class IntegerFieldFilter(FieldFilter): models.ManyToManyField, models.ManyToManyRel, ] - allowed_qualifiers = [None, 'in', 'gt', 'gte', 'lt', 'lte', 'range', 'isnull'] + allowed_qualifiers = [None, 'in', 'gt', 'gte', 'lt', 'lte', 'range', 'isnull', 'icontains'] def clean_value(self, qualifier, v): try: - return int(v) + if qualifier == 'icontains': + return str(v) + else: + return int(v) except ValueError: raise ValidationError('Invalid value {{{}}} for {}.'.format(v, self.field_description())) diff --git a/changelog/.gitkeep b/changelog/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/changelog/README.md b/changelog/README.md new file mode 100644 index 00000000..7332d3f6 --- /dev/null +++ b/changelog/README.md @@ -0,0 +1,22 @@ +# Changelog entries + +Every PR targeting `master` must add at least one file under this directory, +describing the user-facing change. A GitHub Actions workflow +(`.github/workflows/changelog.yml`) enforces this on every pull request. + +## Convention + +- One file per change. Name it after the Phabricator ticket (`T50530`) or, if + there is no ticket, a short descriptive slug (`access-log`). +- File content is one or more bullet lines describing the change, e.g.: + ``` + - [T50530] Bump sentry version + ``` +- Entries are collated into the top-level `CHANGELOG.md` at release time. + +## Skipping the check + +For docs-only, dependency-bump, or CI-only PRs that need no changelog entry, +add the `no-changelog` label to the PR. The workflow will skip the check. + +See `.github/workflows/changelog.yml` for the exact enforcement logic. diff --git a/tests/filters/test_integer_icontains.py b/tests/filters/test_integer_icontains.py new file mode 100644 index 00000000..2761c50d --- /dev/null +++ b/tests/filters/test_integer_icontains.py @@ -0,0 +1,63 @@ +import unittest, os + +from django.test import TestCase, Client +from django.contrib.auth.models import User + +from binder.json import jsonloads + +from ..testapp.models import Zoo, ZooEmployee + + +@unittest.skipIf( + os.environ.get('BINDER_TEST_MYSQL', '0') != '0', + "Only available with PostgreSQL" +) +class IntegerIcontainsTest(TestCase): + + def setUp(self): + super().setUp() + u = User(username='testuser', is_active=True, is_superuser=True) + u.set_password('test') + u.save() + + self.client = Client() + r = self.client.login(username='testuser', password='test') + self.assertTrue(r) + + zoo = Zoo(name='Yes') + zoo.save() + + ZooEmployee(name='Small Number Fan', favorite_number='3', zoo=zoo).save() + ZooEmployee(name='Big Number Enjoyer', favorite_number='100023', zoo=zoo).save() + ZooEmployee(name='Bob', favorite_number='101', zoo=zoo).save() + + def test_filter_partial_match(self): + response = self.client.get('/zoo_employee/', data={'.favorite_number:icontains': '3'}) + + self.assertEqual(response.status_code, 200) + + result = jsonloads(response.content) + self.assertEqual(2, len(result['data'])) + self.assertEqual('Small Number Fan', result['data'][0]['name']) + self.assertEqual('Big Number Enjoyer', result['data'][1]['name']) + + response = self.client.get('/zoo_employee/', data={'.favorite_number:icontains': '100'}) + + result = jsonloads(response.content) + self.assertEqual(1, len(result['data'])) + self.assertEqual('Big Number Enjoyer', result['data'][0]['name']) + + def test_filter_exact_match(self): + response = self.client.get('/zoo_employee/', data={'.favorite_number': '3'}) + + self.assertEqual(response.status_code, 200) + + result = jsonloads(response.content) + self.assertEqual(1, len(result['data'])) + self.assertEqual('Small Number Fan', result['data'][0]['name']) + + response = self.client.get('/zoo_employee/', data={'.favorite_number': '101'}) + + result = jsonloads(response.content) + self.assertEqual(1, len(result['data'])) + self.assertEqual('Bob', result['data'][0]['name']) diff --git a/tests/plugins/test_loaded_values_mixin.py b/tests/plugins/test_loaded_values_mixin.py index bd579441..9052ac99 100644 --- a/tests/plugins/test_loaded_values_mixin.py +++ b/tests/plugins/test_loaded_values_mixin.py @@ -175,6 +175,7 @@ def test_non_nullable_field_does_not_error_out_when_accessing(self): 'id': None, 'name': 'Henk', 'zoo': None, + 'favorite_number': None, 'hourly_wage': None, 'deleted': False, }, zoo_employee.get_old_values()) @@ -197,6 +198,7 @@ def test_non_nullable_field_does_not_error_out_when_accessing(self): 'id': zoo_employee.id, 'name': 'Henk', 'zoo': artis.id, + 'favorite_number': None, 'hourly_wage': None, 'deleted': False, }, zoo_employee.get_old_values()) @@ -216,6 +218,7 @@ def test_non_nullable_field_does_not_error_out_when_accessing(self): 'id': zoo_employee.id, 'name': 'Henk', 'zoo': artis.id, + 'favorite_number': None, 'hourly_wage': None, 'deleted': False, }, zoo_employee.get_old_values()) diff --git a/tests/testapp/models/zoo_employee.py b/tests/testapp/models/zoo_employee.py index 12ec8abc..9dd6939b 100644 --- a/tests/testapp/models/zoo_employee.py +++ b/tests/testapp/models/zoo_employee.py @@ -7,6 +7,7 @@ class ZooEmployee(LoadedValuesMixin, BinderModel): name = models.TextField(max_length=64) zoo = models.ForeignKey('Zoo', on_delete=models.CASCADE, related_name='zoo_employees') hourly_wage = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True) + favorite_number = models.IntegerField(null=True, blank=True) deleted = models.BooleanField(default=False) # Softdelete def __str__(self):