diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..66e8eda0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,52 @@ +name: 🐛 Bug +description: Report a technical issue. +title: '🐛 ' +labels: + - bug +assignees: + - codingjoe +body: + + - type: markdown + attributes: + value: | + Thank you for taking the time to report a bug. + Please fill in as much of the template below as you're able. + + - type: markdown + attributes: + value: | + ## Security issues + Please do not report security issues here. + Instead, disclose them as described in our [security policy](https://github.com/codingjoe/django-select2/security). + + - type: textarea + id: bug-description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: I found a bug + validations: + required: true + + - type: textarea + id: bug-steps + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + + - type: textarea + id: bug-expected + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + placeholder: I expected the app to do X + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 61090361..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: codingjoe - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**Exception & Traceback** -Should you have run into an exception, please provide us with the exception as well as with the full traceback. - -**Code Snippet** -Please provide us with a code example on how to reproduce the error. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..68d01326 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: ✨ Feature Requests + url: https://github.com/codingjoe/django-select2/discussions/categories/ideas + about: Please use the GitHub Discussions to request new features. + - name: 🙋 Questions & Help + url: https://github.com/codingjoe/django-select2/discussions/categories/q-a + about: Please use the GitHub Discussions to ask questions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7d..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/problem-with-django-admin.md b/.github/ISSUE_TEMPLATE/problem-with-django-admin.md deleted file mode 100644 index ce54f92c..00000000 --- a/.github/ISSUE_TEMPLATE/problem-with-django-admin.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: Problem with Django admin -about: You are facing a problem integrating django-select2 into Django's admin interface -title: '' -labels: wontfix -assignees: '' - ---- - -Django-Select2 does NOT support Django admin, since Django admin has a built-in feature called `autocomplete_fields`. Autocomplete fields are superior and we recommend using them, instead of this package for the admin. - -You can find more information here: -https://docs.djangoproject.com/en/stable/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index ae936a5e..00000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Question -about: You have a question about Django-Select2 -title: '' -labels: question -assignees: '' - ---- - -**Goal** -Please describe your goal in all detail. What are you trying to do - -**Problem** -Please describe your problem in all detail. Where are you struggling? - -**Code Snippet** -Please provide a code snippet of your problem. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2e16142..0a3c1aa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: - isort --check-only --diff . - pydocstyle . steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.x" cache: 'pip' @@ -30,8 +30,8 @@ jobs: dist: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.x" - run: python -m pip install --upgrade pip build wheel twine readme-renderer @@ -41,8 +41,8 @@ jobs: standardjs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: '12.x' - run: npm install -g standard @@ -51,8 +51,8 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.10" - run: sudo apt-get install -y gettext graphviz @@ -76,14 +76,14 @@ jobs: - "4.2" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - run: python -m pip install Django~="${{ matrix.django-version }}.0" - run: python -m pip install -e .[test] - run: python -m pytest -m "not selenium" - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v4 Selenium: needs: @@ -95,7 +95,7 @@ jobs: - "3.x" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install Chrome run: sudo apt-get install -y google-chrome-stable - name: Install Selenium @@ -103,10 +103,10 @@ jobs: mkdir bin curl -O https://chromedriver.storage.googleapis.com/`curl -s https://chromedriver.storage.googleapis.com/LATEST_RELEASE`/chromedriver_linux64.zip unzip chromedriver_linux64.zip -d bin - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - run: python -m pip install Django - run: python -m pip install -e .[test,selenium] - run: python -m pytest -m selenium - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6548af4..29ff0b81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,8 @@ jobs: PyPI: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.x" - run: python -m pip install --upgrade pip build wheel twine @@ -23,9 +23,9 @@ jobs: npm: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - uses: actions/setup-python@v5 with: python-version: "3.x" - run: python -m pip install --upgrade setuptools_scm diff --git a/django_select2/__init__.py b/django_select2/__init__.py index 95783ce7..4966ddc6 100644 --- a/django_select2/__init__.py +++ b/django_select2/__init__.py @@ -7,6 +7,7 @@ .. _Select2: https://select2.org/ """ + from django import get_version from . import _version diff --git a/django_select2/apps.py b/django_select2/apps.py index 77b053ac..cc7d0b26 100644 --- a/django_select2/apps.py +++ b/django_select2/apps.py @@ -1,4 +1,5 @@ """Django application configuration.""" + from django.apps import AppConfig diff --git a/django_select2/cache.py b/django_select2/cache.py index ce8953d0..6dbac226 100644 --- a/django_select2/cache.py +++ b/django_select2/cache.py @@ -11,6 +11,7 @@ .. _django.core.cache: https://docs.djangoproject.com/en/dev/topics/cache/ """ + from django.core.cache import caches from .conf import settings diff --git a/django_select2/conf.py b/django_select2/conf.py index a704eb17..e66b55d9 100644 --- a/django_select2/conf.py +++ b/django_select2/conf.py @@ -1,4 +1,5 @@ """Settings for Django-Select2.""" + from appconf import AppConf from django.conf import settings # NOQA diff --git a/django_select2/forms.py b/django_select2/forms.py index 1c0c7059..02860075 100644 --- a/django_select2/forms.py +++ b/django_select2/forms.py @@ -7,36 +7,43 @@ library, hence these components are meant to be used with choice fields. -Widgets are generally of two types: - - 1. **Light** -- - They are not meant to be used when there - are too many options, say, in thousands. - This is because all those options would - have to be pre-rendered onto the page - and JavaScript would be used to search - through them. Said that, they are also one - the easiest to use. They are a - drop-in-replacement for Django's default - select widgets. - - 2(a). **Heavy** -- - They are suited for scenarios when the number of options - are large and need complex queries (from maybe different - sources) to get the options. - - This dynamic fetching of options undoubtedly requires - Ajax communication with the server. Django-Select2 includes - a helper JS file which is included automatically, - so you need not worry about writing any Ajax related JS code. - Although on the server side you do need to create a view - specifically to respond to the queries. - - 2(b). **Model** -- - Model-widgets are a further specialized versions of Heavies. - These do not require views to serve Ajax requests. - When they are instantiated, they register themselves - with one central view which handles Ajax requests for them. +Widgets are generally of tree types: +Light, Heavy and Model. + +Light +~~~~~ + +They are not meant to be used when there +are too many options, say, in thousands. +This is because all those options would +have to be pre-rendered onto the page +and JavaScript would be used to search +through them. Said that, they are also one +the easiest to use. They are a +drop-in-replacement for Django's default +select widgets. + +Heavy +~~~~~ + +They are suited for scenarios when the number of options +are large and need complex queries (from maybe different +sources) to get the options. + +This dynamic fetching of options undoubtedly requires +Ajax communication with the server. Django-Select2 includes +a helper JS file which is included automatically, +so you need not worry about writing any Ajax related JS code. +Although on the server side you do need to create a view +specifically to respond to the queries. + +Model +~~~~~ + +Model-widgets are a further specialized versions of Heavies. +These do not require views to serve Ajax requests. +When they are instantiated, they register themselves +with one central view which handles Ajax requests for them. Heavy and Model widgets have respectively the word 'Heavy' and 'Model' in their name. Light widgets are normally named, i.e. there is no 'Light' word @@ -46,6 +53,7 @@ :parts: 1 """ + import operator import uuid from functools import reduce @@ -546,6 +554,28 @@ def label_from_instance(obj): """ return str(obj) + def result_from_instance(self, obj, request): + """ + Return a dictionary representing the object. + + Can be overridden to change the result returned by + :class:`.AutoResponseView` for each object. + + The request passed in will correspond to the request sent to the + :class:`.AutoResponseView` by the widget. + + Example usage:: + + class MyWidget(ModelSelect2Widget): + def result_from_instance(obj, request): + return { + 'id': obj.pk, + 'text': self.label_from_instance(obj), + 'extra_data': obj.extra_data, + } + """ + return {"id": obj.pk, "text": self.label_from_instance(obj)} + class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget): """ diff --git a/django_select2/urls.py b/django_select2/urls.py index 69c0f148..9f40776d 100644 --- a/django_select2/urls.py +++ b/django_select2/urls.py @@ -9,6 +9,7 @@ path('select2/', include('django_select2.urls')), """ + from django.urls import path from .views import AutoResponseView diff --git a/django_select2/views.py b/django_select2/views.py index 66fdc420..f78f19ea 100644 --- a/django_select2/views.py +++ b/django_select2/views.py @@ -1,4 +1,5 @@ """JSONResponse views for model widgets.""" + from django.core import signing from django.core.signing import BadSignature from django.http import Http404, JsonResponse @@ -20,6 +21,9 @@ def get(self, request, *args, **kwargs): """ Return a :class:`.django.http.JsonResponse`. + Each result will be rendered by the widget's + :func:`django_select2.forms.ModelSelect2Mixin.result_from_instance` method. + Example:: { @@ -40,7 +44,7 @@ def get(self, request, *args, **kwargs): return JsonResponse( { "results": [ - {"text": self.widget.label_from_instance(obj), "id": obj.pk} + self.widget.result_from_instance(obj, request) for obj in context["object_list"] ], "more": context["page_obj"].has_next(), diff --git a/docs/conf.py b/docs/conf.py index b339972f..845ceecd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,6 +31,7 @@ extensions = [ "sphinx.ext.autodoc", + "sphinx.ext.autosectionlabel", "sphinx.ext.napoleon", "sphinx.ext.inheritance_diagram", "sphinx.ext.intersphinx", diff --git a/docs/index.rst b/docs/index.rst index 35454f9c..0b2772ea 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -32,8 +32,9 @@ Add ``django_select`` to your URL root configuration: ] -``django-select2`` requires a cache backend which is **persistent** -across all application servers.. +The :ref:`Model` -widgets require a **persistent** cache backend across +all application servers. This is because the widget needs to store +meta data to be able to fetch the results based on the user input. **This means that the** :class:`.DummyCache` **backend will not work!** diff --git a/linter-requirements.txt b/linter-requirements.txt index 3523059f..4b8d76cb 100644 --- a/linter-requirements.txt +++ b/linter-requirements.txt @@ -1,5 +1,5 @@ -bandit==1.7.5 -black==23.3.0 -flake8==6.0.0 -isort==5.12.0 +bandit==1.7.9 +black==24.4.2 +flake8==7.1.0 +isort==5.13.2 pydocstyle[toml]==6.3.0 diff --git a/tests/test_forms.py b/tests/test_forms.py index c7281949..ddb0f328 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -438,6 +438,15 @@ def test_get_queryset(self): widget.queryset = Genre.objects.all() assert isinstance(widget.get_queryset(), QuerySet) + def test_result_from_instance_ModelSelect2Widget(self, genres): + widget = ModelSelect2Widget() + widget.model = Genre + genre = Genre.objects.first() + assert widget.result_from_instance(genre, request=None) == { + "id": genre.pk, + "text": str(genre), + } + def test_tag_attrs_Select2Widget(self): widget = Select2Widget() output = widget.render("name", "value") diff --git a/tests/test_views.py b/tests/test_views.py index e63ffe80..745dbfad 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -4,7 +4,11 @@ from django_select2.cache import cache from django_select2.forms import ModelSelect2Widget -from tests.testapp.forms import AlbumModelSelect2WidgetForm, ArtistCustomTitleWidget +from tests.testapp.forms import ( + AlbumModelSelect2WidgetForm, + ArtistCustomTitleWidget, + CityForm, +) from tests.testapp.models import Genre try: @@ -84,6 +88,23 @@ def test_label_from_instance(self, artists, client): "results" ] + def test_result_from_instance(self, cities, client): + url = reverse("django_select2:auto-json") + + form = CityForm() + assert form.as_p() + field_id = form.fields["city"].widget.field_id + city = cities[0] + response = client.get(url, {"field_id": field_id, "term": city.name}) + assert response.status_code == 200 + data = json.loads(response.content.decode("utf-8")) + assert data["results"] + assert { + "id": city.pk, + "text": smart_str(city), + "country": smart_str(city.country), + } in data["results"] + def test_url_check(self, client, artists): artist = artists[0] form = AlbumModelSelect2WidgetForm() diff --git a/tests/testapp/forms.py b/tests/testapp/forms.py index ed90f222..0b115339 100644 --- a/tests/testapp/forms.py +++ b/tests/testapp/forms.py @@ -232,3 +232,17 @@ class Meta: model = models.Groupie fields = "__all__" widgets = {"obsession": ArtistCustomTitleWidget} + + +class CityModelSelect2Widget(ModelSelect2Widget): + model = City + search_fields = ["name"] + + def result_from_instance(self, obj, request): + return {"id": obj.pk, "text": obj.name, "country": str(obj.country)} + + +class CityForm(forms.Form): + city = forms.ModelChoiceField( + queryset=City.objects.all(), widget=CityModelSelect2Widget(), required=False + )