diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..2aa2814 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,26 @@ +name: Ruff Linter + +on: + pull_request: + push: + branches: + - main + tags-ignore: + - '**' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + pip install ruff + - name: Run Ruff + run: ruff check --output-format=github . diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2011227 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + tags-ignore: + - '**' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + pip install codecov + - name: Run Tests with coverage + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + run: | + pytest -s --cov=src/dalf --cov-report=xml tests/testproject + codecov -f coverage.xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + flags: unittests diff --git a/README.md b/README.md index c0bf73e..d02a6c7 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ [![Ruff](https://img.shields.io/endpoint?style=for-the-badge&url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![PyPI version](https://img.shields.io/pypi/v/dalf.svg?style=for-the-badge)](https://pypi.org/project/dalf/) ![PyPI - Downloads](https://img.shields.io/pypi/dm/dalf?style=for-the-badge) +[![Codecov](https://codecov.io/gh/vigo/django-admin-list-filter/graph/badge.svg?token=6JRNSB6WN1)](https://codecov.io/gh/vigo/django-admin-list-filter) + # Django Admin List Filter @@ -216,6 +218,7 @@ rake -T rake build # Build package rake bump[revision] # Bump version: major,minor,patch rake clean # Remove/Delete build.. +rake test # Run tests rake upload:main # Upload package to main distro (release) rake upload:test # Upload package to test distro ``` @@ -224,6 +227,10 @@ rake upload:test # Upload package to test distro ## Change Log +**2024-05-23** + +- Add tests + **2024-05-20** - Initial release. diff --git a/Rakefile b/Rakefile index 8bdcd87..dbe63bc 100644 --- a/Rakefile +++ b/Rakefile @@ -33,3 +33,10 @@ task :bump, [:revision] do |t, args| abort "Please provide valid revision: #{AVAILABLE_REVISIONS.join(',')}" unless AVAILABLE_REVISIONS.include?(args.revision) system "bumpversion #{args.revision}" end + +desc "Run tests" +task :test do + system %{ + pytest -s --cov=src/dalf --cov-report=xml tests/testproject + } +end diff --git a/pyproject.toml b/pyproject.toml index cd5e0b9..bdf94bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,9 @@ version = "0.1.0" authors = [ { name="Uğur Özyılmazel", email="ugurozyilmazel@gmail.com" }, ] -description = "Django admin list filter with goodies" +description = "Dead simple autocompletion for Django admin list_filter with goodies." readme = "README.md" +license = { file = "LICENSE" } requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", @@ -14,6 +15,11 @@ classifiers = [ "Framework :: Django :: 5.0", "Development Status :: 3 - Alpha", ] +keywords = ["django", "django admin", "list filter"] + +[project.optional-dependencies] +build = ["build", "twine"] +dev = ["Django", "pytest", "pytest-django", "pytest-factoryboy", "pytest-cov"] [project.urls] Homepage = "https://github.com/vigo/django-admin-list-filter" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..96f4b19 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +Django==5.0.6 +pytest==8.2.1 +pytest-cov==5.0.0 +pytest-django==4.8.0 +pytest-factoryboy==2.7.0 \ No newline at end of file diff --git a/tests/testproject/manage.py b/tests/testproject/manage.py new file mode 100755 index 0000000..9ccfbb6 --- /dev/null +++ b/tests/testproject/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# ruff: noqa: TRY003,EM101 + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + 'available on your PYTHONPATH environment variable? Did you ' + 'forget to activate a virtual environment?' + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/tests/testproject/pytest.ini b/tests/testproject/pytest.ini new file mode 100644 index 0000000..4d6d318 --- /dev/null +++ b/tests/testproject/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +DJANGO_SETTINGS_MODULE = testproject.settings +python_files = tests.py test_*.py *_tests.py +pythonpath = ../../src +addopts = -p no:warnings --strict-markers --no-migrations --reuse-db --capture=no diff --git a/tests/testproject/testapp/__init__.py b/tests/testproject/testapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testproject/testapp/admin.py b/tests/testproject/testapp/admin.py new file mode 100644 index 0000000..daf1cdc --- /dev/null +++ b/tests/testproject/testapp/admin.py @@ -0,0 +1,33 @@ +from dalf.admin import ( + DALFChoicesField, + DALFModelAdmin, + DALFRelatedField, + DALFRelatedFieldAjax, + DALFRelatedOnlyField, +) +from django.contrib import admin + +from .models import Category, Post, Tag + + +@admin.register(Post) +class PostAdmin(DALFModelAdmin): + list_display = ('title',) + list_filter = ( + ('author', DALFRelatedField), + ('audience', DALFChoicesField), + ('category', DALFRelatedFieldAjax), + ('tags', DALFRelatedOnlyField), + ) + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + search_fields = ('name',) + ordering = ('name',) + + +@admin.register(Tag) +class TagAdmin(admin.ModelAdmin): + search_fields = ('name',) + ordering = ('name',) diff --git a/tests/testproject/testapp/apps.py b/tests/testproject/testapp/apps.py new file mode 100644 index 0000000..8adcb9d --- /dev/null +++ b/tests/testproject/testapp/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TestappConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'testapp' diff --git a/tests/testproject/testapp/conftest.py b/tests/testproject/testapp/conftest.py new file mode 100644 index 0000000..d36c04c --- /dev/null +++ b/tests/testproject/testapp/conftest.py @@ -0,0 +1,12 @@ +import pytest +from pytest_factoryboy import register + +from .factories import PostFactory, TagFactory + +register(TagFactory) +register(PostFactory) + + +@pytest.fixture() +def posts(): + return PostFactory.create_batch(10) diff --git a/tests/testproject/testapp/factories.py b/tests/testproject/testapp/factories.py new file mode 100644 index 0000000..d04224e --- /dev/null +++ b/tests/testproject/testapp/factories.py @@ -0,0 +1,77 @@ +import factory +from django.contrib.auth import get_user_model +from factory import fuzzy + +from .models import AudienceChoices, Category, Post, Tag + +FAKE_USERNAMES = [ + 'vigo', + 'turbo', + 'move', +] + +FAKE_CATEGORIES = [ + 'Python', + 'Ruby', + 'Go', + 'Bash', + 'AppleScript', + 'C', + 'Perl', +] + +FAKE_TAGS = [ + 'django', + 'django rest', + 'linux', + 'macos', + 'stdlib', +] + + +class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = get_user_model() + django_get_or_create = ('username',) + + username = fuzzy.FuzzyChoice(FAKE_USERNAMES) + email = factory.Faker('email') + password = factory.PostGenerationMethodCall('set_password', 'defaultpassword') + + +class CategoryFactory(factory.django.DjangoModelFactory): + class Meta: + model = Category + django_get_or_create = ('name',) + + name = factory.Iterator(FAKE_CATEGORIES) + + +class TagFactory(factory.django.DjangoModelFactory): + class Meta: + model = Tag + django_get_or_create = ('name',) + + name = fuzzy.FuzzyChoice(FAKE_TAGS) + + +class PostFactory(factory.django.DjangoModelFactory): + class Meta: + model = Post + django_get_or_create = ('title',) + + author = factory.SubFactory(UserFactory) + category = factory.SubFactory(CategoryFactory) + title = factory.Sequence(lambda n: f'Book about {FAKE_CATEGORIES[n % len(FAKE_CATEGORIES)]} - {n}') + audience = fuzzy.FuzzyChoice(AudienceChoices.choices, getter=lambda c: c[0]) + + @factory.post_generation + def tags(self, create, extracted, **kwargs): # noqa: ARG002 + if not create: + return + + if extracted: + self.tags.add(*extracted) + else: + tags = TagFactory.create_batch(len(FAKE_TAGS)) + self.tags.add(*tags) diff --git a/tests/testproject/testapp/migrations/__init__.py b/tests/testproject/testapp/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testproject/testapp/models.py b/tests/testproject/testapp/models.py new file mode 100644 index 0000000..88aff07 --- /dev/null +++ b/tests/testproject/testapp/models.py @@ -0,0 +1,46 @@ +from django.conf import settings +from django.db import models + + +class Category(models.Model): + name = models.CharField(max_length=255) + + def __str__(self): + return self.name + + +class Tag(models.Model): + name = models.CharField(max_length=255) + + def __str__(self): + return self.name + + +class AudienceChoices(models.TextChoices): + BEGINNER = 'beginner', 'Beginer' + INTERMEDIATE = 'intermediate', 'Intermediate' + PRO = 'pro', 'Pro' + + +class Post(models.Model): + author = models.ForeignKey( + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='posts', + ) + category = models.ForeignKey( + to='Category', + on_delete=models.CASCADE, + related_name='posts', + ) + tags = models.ManyToManyField(to='Tag', blank=True) + audience = models.CharField( + max_length=100, + choices=AudienceChoices.choices, + default=AudienceChoices.BEGINNER, + ) + + title = models.CharField(max_length=255) + + def __str__(self): + return self.title diff --git a/tests/testproject/testapp/tests.py b/tests/testproject/testapp/tests.py new file mode 100644 index 0000000..0cdca15 --- /dev/null +++ b/tests/testproject/testapp/tests.py @@ -0,0 +1,80 @@ +# ruff: noqa: S101 +import re +from http import HTTPStatus + +import pytest +from dalf.admin import DALFChoicesField, DALFRelatedField, DALFRelatedFieldAjax, DALFRelatedOnlyField +from django.urls import reverse + +from .models import Post + +csrf_token_pattern = re.compile(r'name="csrfmiddlewaretoken" value="([^"]+)"') + + +@pytest.mark.django_db() +def test_post_admin_filters_basics(admin_client, posts): # noqa: ARG001 + posts_count = 10 + post_authors = set(Post.objects.values_list('author__username', flat=True)) + post_audiences = set(Post.objects.values_list('audience', flat=True)) + + assert post_authors + assert post_audiences + + response = admin_client.get(reverse('admin:testapp_post_changelist')) + assert response.status_code == HTTPStatus.OK + assert len(response.context['results']) == posts_count + assert response.context.get('cl', None) + assert hasattr(response.context.get('cl', {}), 'filter_specs') + + content = response.content.decode() + filter_specs = response.context['cl'].filter_specs + + assert len(filter_specs) > 0 + + for spec in filter_specs: + if isinstance(spec, (DALFRelatedField, DALFChoicesField, DALFRelatedFieldAjax, DALFRelatedOnlyField)): + filter_choices = list(spec.choices(response.context['cl'])) + filter_custom_options = filter_choices.pop() + option_field_name = filter_custom_options.get('field_name', None) + option_is_choices_filter = filter_custom_options.get('is_choices_filter', None) + + if option_field_name in ['author', 'audience']: + assert ( + f'' in content + + url_params = '&'.join( + [f'{key}={value}' for key, value in filter_custom_options.items() if key != 'selected_value'] + ) + url_params += '&term=Py' + ajax_resonse = admin_client.get(f'/admin/autocomplete/?{url_params}') + + assert ajax_resonse['Content-Type'] == 'application/json' + + json_response = ajax_resonse.json() + assert json_response + + results = json_response.get('results') + pagination = json_response.get('pagination', {}).get('more', None) + + assert len(results) == 1 + assert pagination is not None + assert pagination is False diff --git a/tests/testproject/testproject/__init__.py b/tests/testproject/testproject/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testproject/testproject/asgi.py b/tests/testproject/testproject/asgi.py new file mode 100644 index 0000000..6bbfe1e --- /dev/null +++ b/tests/testproject/testproject/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') + +application = get_asgi_application() diff --git a/tests/testproject/testproject/settings.py b/tests/testproject/testproject/settings.py new file mode 100644 index 0000000..7819922 --- /dev/null +++ b/tests/testproject/testproject/settings.py @@ -0,0 +1,59 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +SECRET_KEY = 'django-insecure-testprojectst' # noqa: S105 +DEBUG = True +ALLOWED_HOSTS = [] +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'testapp.apps.TestappConfig', + 'dalf', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'testproject.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'testproject.wsgi.application' +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } +} + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True +STATIC_URL = 'static/' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/tests/testproject/testproject/urls.py b/tests/testproject/testproject/urls.py new file mode 100644 index 0000000..dfc7362 --- /dev/null +++ b/tests/testproject/testproject/urls.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/tests/testproject/testproject/wsgi.py b/tests/testproject/testproject/wsgi.py new file mode 100644 index 0000000..8ad347a --- /dev/null +++ b/tests/testproject/testproject/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') + +application = get_wsgi_application()