diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aba4195..0d97acd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,12 +8,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: '3.13' architecture: 'x64' - name: Install dependencies and package @@ -38,4 +38,4 @@ jobs: TWINE_REPOSITORY: ${{ secrets.PYPI_REPOSITORY }} TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - TWINE_NON_INTERACTIVE: yes \ No newline at end of file + TWINE_NON_INTERACTIVE: yes diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b98a337..039314f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,30 +7,20 @@ jobs: strategy: fail-fast: false matrix: - python-version: - - '3.8' - - '3.9' - - '3.10' - - '3.11' - django-version: - - '3.2' - - '4.1' - - '4.2' - djangorestframework-version: - - '3.12' - - '3.13' - - '3.14' + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + django-version: ['4.2', '5.0', '5.1'] + djangorestframework-version: ['3.15'] exclude: - - django-version: '4.2' - djangorestframework-version: '3.12' - - django-version: '4.2' - djangorestframework-version: '3.13' + - python-version: '3.9' + django-version: '5.0' + - python-version: '3.9' + django-version: '5.1' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} @@ -60,4 +50,4 @@ jobs: coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 \ No newline at end of file + uses: codecov/codecov-action@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89ddb36..22bc75f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,27 @@ repos: - - repo: https://github.com/pycqa/isort - rev: 5.12.0 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 hooks: - - id: isort - args: [ "--profile", "black", "--filter-files" ] + - id: check-merge-conflict + - id: end-of-file-fixer + - id: requirements-txt-fixer + - id: trailing-whitespace + args: ["--markdown-linebreak-ext=md"] - - repo: https://github.com/psf/black - rev: 23.3.0 # Replace by any tag/version: https://github.com/psf/black/tags + - repo: https://github.com/asottile/pyupgrade + rev: v3.17.0 hooks: - - id: black - language_version: python3 # Should be a command that runs python3.6.2+ \ No newline at end of file + - id: pyupgrade + args: ["--py312-plus"] + + - repo: https://github.com/asottile/add-trailing-comma + rev: v3.1.0 + hooks: + - id: add-trailing-comma + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.2 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f9c2c..d6cb92d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.2.0 - Unreleased] ### Added +- Support for Python 3.12 and 3.13 +- Support for Django 5.0 and 5.1 - Mixin for User to provide properties `is_anonymous_login` and `anonymous_login` - Cookie authentication support +### Changed +- pre-commit configuration + +### Removed +- Support for Python 3.8 +- Support for Django 3.2 and 4.1 + ## [1.1.0] ### Added diff --git a/LICENSE b/LICENSE index cd0bfbf..fb4b56a 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 12a7555..fc1a300 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ INSTALLED_APPS = [ There are multiple ways to include the `AnonymousLogin` functionality to your endpoints. We recommend to use one of the following approaches: -1. Inherit from the `AnonymousLoginAuthenticationModelViewSet` for any model that is supposed to be accessible via +1. Inherit from the `AnonymousLoginAuthenticationModelViewSet` for any model that is supposed to be accessible via valid token header. You'll find a simple exemplary usage scenario provided the [testapp](tests/testapp/views.py). OR @@ -38,24 +38,24 @@ OR 2. Directly add the `AnonymousLoginAuthentication` and `IsAuthenticated` to your ViewSet's `authentication_classes` and `permission_classes` as implemented in the [AnonymousLoginAuthenticationModelViewSet](drf_anonymous_login/views.py). -3. Optionally add the `AnonymousLoginUserMixin` to your app's User model in order to access its `is_anonymous_login` +3. Optionally add the `AnonymousLoginUserMixin` to your app's User model in order to access its `is_anonymous_login` and `anonymous_login` properties: ``` # myapp.models.py - + class User(AnonymousLoginUserMixin, AbstractUser): pass ``` - + ``` # settings.py - + AUTH_USER_MODEL = "myapp.User" ``` - + #### Configure token expiration -The tokens will not expire by default (expiration_datetime remains `None`). You can configure the +The tokens will not expire by default (expiration_datetime remains `None`). You can configure the `ANONYMOUS_LOGIN_EXPIRATION` in your application's `settings.py` to define a default expiration in minutes, e.g. to have any token only valid for 15 minutes, use: ```python @@ -80,7 +80,7 @@ See folder [tests/](tests/). The provided tests cover these criteria: * access private endpoint with expired token Follow below instructions to run the tests. -You may exchange the installed Django and DRF versions according to your requirements. +You may exchange the installed Django and DRF versions according to your requirements. :warning: Depending on your local environment settings you might need to explicitly call `python3` instead of `python`. ```bash # install dependencies diff --git a/drf_anonymous_login/management/commands/cleanup_tokens.py b/drf_anonymous_login/management/commands/cleanup_tokens.py index 708ecad..c71110e 100644 --- a/drf_anonymous_login/management/commands/cleanup_tokens.py +++ b/drf_anonymous_login/management/commands/cleanup_tokens.py @@ -29,7 +29,8 @@ def handle(self, *args, **options): except Exception as exc: self.stdout.write( - "%s exception occurred ... " % (exc.__class__.__name__,), ending="" + f"{exc.__class__.__name__} exception occurred ... ", + ending="", ) self.stdout.flush() self.stdout.write(self.style.ERROR("FATAL")) @@ -60,7 +61,7 @@ def add_arguments(self, parser): ) def __init__(self, *args, **kwargs): - super(Command, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # The command will run as long as the `_running` attribute is # set to `True`. To safely quit the command, just set this attribute to `False` and the diff --git a/drf_anonymous_login/migrations/0001_initial.py b/drf_anonymous_login/migrations/0001_initial.py index 26dae9e..35df018 100644 --- a/drf_anonymous_login/migrations/0001_initial.py +++ b/drf_anonymous_login/migrations/0001_initial.py @@ -24,7 +24,10 @@ class Migration(migrations.Migration): ( "token", models.CharField( - db_index=True, max_length=64, unique=True, verbose_name="Token" + db_index=True, + max_length=64, + unique=True, + verbose_name="Token", ), ), ( diff --git a/drf_anonymous_login/migrations/0002_anonymous_login_expiration_datetime.py b/drf_anonymous_login/migrations/0002_anonymous_login_expiration_datetime.py index 965026a..2e324f2 100644 --- a/drf_anonymous_login/migrations/0002_anonymous_login_expiration_datetime.py +++ b/drf_anonymous_login/migrations/0002_anonymous_login_expiration_datetime.py @@ -13,7 +13,9 @@ class Migration(migrations.Migration): model_name="anonymouslogin", name="expiration_datetime", field=models.DateTimeField( - default=None, null=True, verbose_name="expiration datetime" + default=None, + null=True, + verbose_name="expiration datetime", ), ), migrations.AlterField( @@ -25,7 +27,10 @@ class Migration(migrations.Migration): model_name="anonymouslogin", name="token", field=models.CharField( - db_index=True, max_length=64, unique=True, verbose_name="token" + db_index=True, + max_length=64, + unique=True, + verbose_name="token", ), ), ] diff --git a/drf_anonymous_login/models.py b/drf_anonymous_login/models.py index d01bce4..666e712 100644 --- a/drf_anonymous_login/models.py +++ b/drf_anonymous_login/models.py @@ -13,7 +13,9 @@ class AnonymousLogin(models.Model): created = models.DateTimeField(_("created"), auto_now_add=True) request_data = models.JSONField(default=dict) expiration_datetime = models.DateTimeField( - _("expiration datetime"), null=True, default=None + _("expiration datetime"), + null=True, + default=None, ) def save(self, *args, **kwargs): @@ -35,7 +37,7 @@ def set_default_expiration_datetime(): return timezone.now() + timedelta(minutes=default_expiration) -class AnonymousLoginUserMixin(object): +class AnonymousLoginUserMixin: @property def is_anonymous_login(self): return AnonymousLogin.objects.filter(token=self.username).exists() diff --git a/drf_anonymous_login/views.py b/drf_anonymous_login/views.py index f1798cf..d5e079a 100644 --- a/drf_anonymous_login/views.py +++ b/drf_anonymous_login/views.py @@ -30,18 +30,18 @@ class CreateAnonymousLoginViewSet(mixins.CreateModelMixin, viewsets.GenericViewS @staticmethod def extract_request_headers(request): regex = re.compile("^HTTP_") - return dict( - (regex.sub("", header), value) + return { + regex.sub("", header): value for (header, value) in request.META.items() if header.startswith("HTTP_") - ) + } def create(self, request, *args, **kwargs): user = AnonymousLogin.objects.create( request_data={ "data": request.data, "headers": self.extract_request_headers(request), - } + }, ) response = Response({"token": user.token}, status=status.HTTP_201_CREATED) response.set_cookie("anonymous_token", f"Token {user.token}") diff --git a/requirements.txt b/requirements.txt index b6868ba..e8e8094 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,15 @@ # Package and package dependencies -e . +coverage>=7.6.2,<7.7 -# Development dependencies -setuptools>=67.6.1,<67.7 -wheel>=0.40.0,<0.41 -twine>=4.0.2,<4.1 -coverage>=7.2.3,<7.3 +# TestApp dependencies +django>=4.2,<5.2 +djangorestframework>=3.15.0,<3.16 # Linters and formatters -pre-commit>=3.2.2,<3.3 +pre-commit>=4.0.1,<4.1 -# TestApp dependencies -django>=3.2,<4.3 -djangorestframework>=3.14.0,<4 +# Development dependencies +setuptools>=75.1.0,<76.0 +twine>=5.1.1,<5.2 +wheel>=0.44.0,<0.45 diff --git a/setup.py b/setup.py index 8d8bd3d..2ef4cf7 100644 --- a/setup.py +++ b/setup.py @@ -24,17 +24,18 @@ classifiers=[ "Development Status :: 5 - Production/Stable", "Framework :: Django", - "Framework :: Django :: 3.2", - "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], ) diff --git a/tests/core/settings.py b/tests/core/settings.py index 01cfbc5..30d0174 100644 --- a/tests/core/settings.py +++ b/tests/core/settings.py @@ -68,7 +68,7 @@ "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - } + }, } # Password validation diff --git a/tests/core/urls.py b/tests/core/urls.py index 8672393..4f1366f 100644 --- a/tests/core/urls.py +++ b/tests/core/urls.py @@ -12,7 +12,9 @@ # anonymous login route router.register( - r"auth_anonymous", CreateAnonymousLoginViewSet, basename="auth_anonymous" + r"auth_anonymous", + CreateAnonymousLoginViewSet, + basename="auth_anonymous", ) urlpatterns = [path("admin/", admin.site.urls), path("api/", include(router.urls))] diff --git a/tests/manage.py b/tests/manage.py index c0b8614..6e01835 100644 --- a/tests/manage.py +++ b/tests/manage.py @@ -1,22 +1,24 @@ #!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + import os import sys -if __name__ == "__main__": + +def main(): + """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") + try: from django.core.management import execute_from_command_line - except ImportError: - # The above import may fail for some other reason. Ensure that the - # issue is really that Django is missing to avoid masking other - # exceptions on Python 2. - try: - import django - except ImportError: - 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?" - ) - raise + 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/testapp/migrations/0001_initial.py b/tests/testapp/migrations/0001_initial.py index 232a5ed..7150f72 100644 --- a/tests/testapp/migrations/0001_initial.py +++ b/tests/testapp/migrations/0001_initial.py @@ -50,7 +50,9 @@ class Migration(migrations.Migration): ( "last_login", models.DateTimeField( - blank=True, null=True, verbose_name="last login" + blank=True, + null=True, + verbose_name="last login", ), ), ( @@ -65,13 +67,13 @@ class Migration(migrations.Migration): "username", models.CharField( error_messages={ - "unique": "A user with that username already exists." + "unique": "A user with that username already exists.", }, help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", max_length=150, unique=True, validators=[ - django.contrib.auth.validators.UnicodeUsernameValidator() + django.contrib.auth.validators.UnicodeUsernameValidator(), ], verbose_name="username", ), @@ -79,19 +81,25 @@ class Migration(migrations.Migration): ( "first_name", models.CharField( - blank=True, max_length=150, verbose_name="first name" + blank=True, + max_length=150, + verbose_name="first name", ), ), ( "last_name", models.CharField( - blank=True, max_length=150, verbose_name="last name" + blank=True, + max_length=150, + verbose_name="last name", ), ), ( "email", models.EmailField( - blank=True, max_length=254, verbose_name="email address" + blank=True, + max_length=254, + verbose_name="email address", ), ), ( @@ -113,7 +121,8 @@ class Migration(migrations.Migration): ( "date_joined", models.DateTimeField( - default=django.utils.timezone.now, verbose_name="date joined" + default=django.utils.timezone.now, + verbose_name="date joined", ), ), ( diff --git a/tests/testapp/tests/test_api.py b/tests/testapp/tests/test_api.py index fa71d96..8850596 100644 --- a/tests/testapp/tests/test_api.py +++ b/tests/testapp/tests/test_api.py @@ -19,7 +19,7 @@ def setUp(self): PrivateModel.objects.create(name="private model") def login_header(self): - return {AUTH_HEADER: "{} {}".format(AUTH_KEYWORD, self.anonymous_login.token)} + return {AUTH_HEADER: f"{AUTH_KEYWORD} {self.anonymous_login.token}"} def test_no_default_expiration_datetime(self): self.assertIsNone(self.anonymous_login.expiration_datetime) @@ -31,7 +31,8 @@ def test_default_expiration_datetime(self): self.assertIsNotNone(self.anonymous_login.expiration_datetime) self.assertGreater(self.anonymous_login.expiration_datetime, today) self.assertLess( - self.anonymous_login.expiration_datetime, today + timedelta(minutes=16) + self.anonymous_login.expiration_datetime, + today + timedelta(minutes=16), ) def test_no_login(self): @@ -66,7 +67,8 @@ def test_anonymous_login_token_invalid(self): """ url = reverse("privatemodel-list") response = self.client.get( - url, **{AUTH_HEADER: "{} {}".format(AUTH_KEYWORD, "invalid_token")} + url, + **{AUTH_HEADER: "{} {}".format(AUTH_KEYWORD, "invalid_token")}, ) self.assertEqual(HTTP_403_FORBIDDEN, response.status_code, response.content) self.assertEqual(str(response.data["detail"]), "Invalid authentication token") @@ -110,7 +112,8 @@ def test_user_is_anonymous_login(self): :return: """ user = User.objects.create( - username=self.anonymous_login.token, password="password" + username=self.anonymous_login.token, + password="password", ) self.assertTrue(user.is_anonymous_login) @@ -128,7 +131,8 @@ def test_user_get_anonymous_login(self): :return: """ user = User.objects.create( - username=self.anonymous_login.token, password="password" + username=self.anonymous_login.token, + password="password", ) self.assertEqual(user.anonymous_login, self.anonymous_login) diff --git a/tests/testapp/tests/test_setup.py b/tests/testapp/tests/test_setup.py index 315ec45..a725558 100644 --- a/tests/testapp/tests/test_setup.py +++ b/tests/testapp/tests/test_setup.py @@ -12,7 +12,8 @@ def test_installed_apps(self): def test_models(self): self.assertIs( - apps.get_model("drf_anonymous_login", "AnonymousLogin"), AnonymousLogin + apps.get_model("drf_anonymous_login", "AnonymousLogin"), + AnonymousLogin, ) self.assertIs(apps.get_model("testapp", "PublicModel"), PublicModel) self.assertIs(apps.get_model("testapp", "PrivateModel"), PrivateModel)