Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ jobs:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-24.04

services:
postgres:
image: postgres:16
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

strategy:
matrix:
python-version:
Expand All @@ -37,6 +52,9 @@ jobs:
- '3.13'
- '3.14'

env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres

steps:
- uses: actions/checkout@v6

Expand All @@ -52,6 +70,9 @@ jobs:
- name: Install dependencies
run: python -m pip install --upgrade tox

- name: Create unaccent extension
run: PGPASSWORD=postgres psql -h localhost -U postgres -d postgres -c 'CREATE EXTENSION IF NOT EXISTS unaccent;'
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This CI step creates the unaccent extension in the postgres database, but Django/pytest will run tests against a newly created test database (e.g. test_postgres), which will not automatically inherit extensions from the original DB. If unaccent is needed for tests, create it in the test DB during test setup (e.g. in a pytest fixture after DB creation) or create it in a template DB used for test DB creation.

Suggested change
run: PGPASSWORD=postgres psql -h localhost -U postgres -d postgres -c 'CREATE EXTENSION IF NOT EXISTS unaccent;'
run: |
PGPASSWORD=postgres psql -h localhost -U postgres -d template1 -c 'CREATE EXTENSION IF NOT EXISTS unaccent;'
PGPASSWORD=postgres psql -h localhost -U postgres -d postgres -c 'CREATE EXTENSION IF NOT EXISTS unaccent;'

Copilot uses AI. Check for mistakes.

- name: Run tox targets for ${{ matrix.python-version }}
run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d . | cut -f 1 -d '-')

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dev = [
{ include-group = "test" },
]
test = [
"dj-database-url>=3.1.0",
"importlib-metadata<10.0",
# Pytest for running the tests.
"pytest==9.*",
Expand Down Expand Up @@ -116,6 +117,9 @@ keep_full_version = true
[tool.pytest.ini_options]
addopts = "--tb=short --strict-markers -ra"
testpaths = [ "tests" ]
markers = [
"requires_postgres: marks tests as requiring a PostgreSQL database backend",
]
filterwarnings = [
"ignore:'cgi' is deprecated:DeprecationWarning",
]
Expand Down
38 changes: 32 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import os

import dj_database_url
import django
import pytest
from django.core import management


Expand All @@ -13,19 +15,27 @@ def pytest_addoption(parser):
def pytest_configure(config):
from django.conf import settings

settings.configure(
DEBUG_PROPAGATE_EXCEPTIONS=True,
DEFAULT_AUTO_FIELD="django.db.models.AutoField",
DATABASES={
if os.getenv('DATABASE_URL'):
databases = {
'default': dj_database_url.config(),
'secondary': dj_database_url.config(),
Comment on lines +19 to +21
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When DATABASE_URL is set, both the default and secondary database aliases are configured with the exact same connection info. On PostgreSQL, Django will attempt to create a separate test database per alias, and identical NAME/settings can cause test database creation/teardown to collide. Configure secondary as a TEST mirror of default (or give it a distinct TEST/NAME) so multi-db tests can run reliably on Postgres.

Suggested change
databases = {
'default': dj_database_url.config(),
'secondary': dj_database_url.config(),
default_database = dj_database_url.config()
secondary_database = default_database.copy()
secondary_database['TEST'] = {'MIRROR': 'default'}
databases = {
'default': default_database,
'secondary': secondary_database,

Copilot uses AI. Check for mistakes.
}
else:
databases = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:'
},
'secondary': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:'
}
},
},
}

settings.configure(
DEBUG_PROPAGATE_EXCEPTIONS=True,
DEFAULT_AUTO_FIELD="django.db.models.AutoField",
DATABASES=databases,
SITE_ID=1,
SECRET_KEY='not very secret in tests',
USE_I18N=True,
Expand Down Expand Up @@ -65,6 +75,12 @@ def pytest_configure(config):
),
)

# Add django.contrib.postgres when using a PostgreSQL database
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql':
settings.INSTALLED_APPS += (
'django.contrib.postgres',
)

# guardian is optional
try:
import guardian # NOQA
Expand All @@ -91,3 +107,13 @@ def pytest_configure(config):

if config.getoption('--staticfiles'):
management.call_command('collectstatic', verbosity=0, interactive=False)


def pytest_collection_modifyitems(config, items):
from django.conf import settings

if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.postgresql':
skip_postgres = pytest.mark.skip(reason='Requires PostgreSQL database backend')
for item in items:
if 'requires_postgres' in item.keywords:
item.add_marker(skip_postgres)
107 changes: 107 additions & 0 deletions tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,109 @@ class SearchListView(generics.ListAPIView):
]


@pytest.mark.requires_postgres
class SearchFilterFullTextTests(TestCase):
@classmethod
def setUpTestData(cls):
SearchFilterModel.objects.create(title='The quick brown fox', text='jumps over the lazy dog')
SearchFilterModel.objects.create(title='The slow brown turtle', text='crawls under the fence')
SearchFilterModel.objects.create(title='A bright sunny day', text='in the park with friends')

def test_full_text_search_single_term(self):
class SearchListView(generics.ListAPIView):
queryset = SearchFilterModel.objects.all()
serializer_class = SearchFilterSerializer
filter_backends = (filters.SearchFilter,)
search_fields = ('@title',)

view = SearchListView.as_view()
request = factory.get('/', {'search': 'fox'})
response = view(request)
assert len(response.data) == 1
assert response.data[0]['title'] == 'The quick brown fox'

def test_full_text_search_multiple_results(self):
class SearchListView(generics.ListAPIView):
queryset = SearchFilterModel.objects.all()
serializer_class = SearchFilterSerializer
filter_backends = (filters.SearchFilter,)
search_fields = ('@title',)

view = SearchListView.as_view()
request = factory.get('/', {'search': 'brown'})
response = view(request)
assert len(response.data) == 2
titles = {item['title'] for item in response.data}
assert titles == {'The quick brown fox', 'The slow brown turtle'}

def test_full_text_search_no_results(self):
class SearchListView(generics.ListAPIView):
queryset = SearchFilterModel.objects.all()
serializer_class = SearchFilterSerializer
filter_backends = (filters.SearchFilter,)
search_fields = ('@title',)

view = SearchListView.as_view()
request = factory.get('/', {'search': 'elephant'})
response = view(request)
assert len(response.data) == 0

def test_full_text_search_multiple_fields(self):
class SearchListView(generics.ListAPIView):
queryset = SearchFilterModel.objects.all()
serializer_class = SearchFilterSerializer
filter_backends = (filters.SearchFilter,)
search_fields = ('@title', '@text')

view = SearchListView.as_view()
request = factory.get('/', {'search': 'lazy'})
response = view(request)
assert len(response.data) == 1
assert response.data[0]['title'] == 'The quick brown fox'

def test_full_text_search_stemming(self):
"""Full text search should match stemmed words (e.g. 'jumping' matches 'jumps')."""
class SearchListView(generics.ListAPIView):
queryset = SearchFilterModel.objects.all()
serializer_class = SearchFilterSerializer
filter_backends = (filters.SearchFilter,)
search_fields = ('@text',)

view = SearchListView.as_view()
request = factory.get('/', {'search': 'jumping'})
response = view(request)
assert len(response.data) == 1
assert response.data[0]['text'] == 'jumps over the lazy dog'

def test_full_text_search_multiple_terms(self):
"""Each search term must match (AND semantics across terms)."""
class SearchListView(generics.ListAPIView):
queryset = SearchFilterModel.objects.all()
serializer_class = SearchFilterSerializer
filter_backends = (filters.SearchFilter,)
search_fields = ('@title', '@text')

view = SearchListView.as_view()
request = factory.get('/', {'search': 'brown lazy'})
response = view(request)
assert len(response.data) == 1
assert response.data[0]['title'] == 'The quick brown fox'

def test_full_text_search_mixed_with_icontains(self):
"""Full text search fields can be mixed with regular icontains fields."""
class SearchListView(generics.ListAPIView):
queryset = SearchFilterModel.objects.all()
serializer_class = SearchFilterSerializer
filter_backends = (filters.SearchFilter,)
search_fields = ('@title', 'text')

view = SearchListView.as_view()
request = factory.get('/', {'search': 'park'})
response = view(request)
assert len(response.data) == 1
assert response.data[0]['title'] == 'A bright sunny day'


class AttributeModel(models.Model):
label = models.CharField(max_length=32)

Expand Down Expand Up @@ -339,6 +442,10 @@ def test_custom_lookup_to_related_model(self):
assert 'attribute__label__icontains' == filter_.construct_search('attribute__label', SearchFilterModelFk._meta)
assert 'attribute__label__iendswith' == filter_.construct_search('attribute__label__iendswith', SearchFilterModelFk._meta)

def test_construct_search_with_at_prefix(self):
filter_ = filters.SearchFilter()
assert 'title__search' == filter_.construct_search('@title', SearchFilterModelFk._meta)


class SearchFilterModelM2M(models.Model):
title = models.CharField(max_length=20)
Expand Down
Loading