├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── django_seriously ├── __init__.py ├── authtoken │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── authentication.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_token_last_seen_at_alter_token_scopes.py │ │ └── __init__.py │ ├── models.py │ └── utils.py ├── minimaluser │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ └── models.py ├── py.typed ├── settings.py └── utils │ ├── __init__.py │ ├── admin.py │ ├── fields.py │ ├── forms.py │ ├── models.py │ ├── pydantic.py │ ├── schema.py │ └── settings.py ├── docs └── demo.gif ├── helper ├── github-ci-vars.py ├── makemigrations.py └── mock_settings.py ├── pyproject.toml ├── requirements.txt ├── requirements ├── base.txt ├── optionals.txt ├── packaging.txt └── testing.txt ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── models.py ├── settings.py ├── test_adminitemaction.py ├── test_authtoken.py ├── test_base_model.py ├── test_minimaluser.py ├── test_pydantic_field.py ├── test_validated_field.py └── urls.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | prep-tests: 7 | runs-on: ubuntu-latest 8 | outputs: 9 | matrix: ${{ steps.set-vars.outputs.matrix }} 10 | date: ${{ steps.set-vars.outputs.date }} 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | - run: pip install tox 15 | - name: Generate matrix & vars 16 | id: set-vars 17 | run: python helper/github-ci-vars.py 18 | tests: 19 | needs: prep-tests 20 | runs-on: ubuntu-latest 21 | name: tests-${{ matrix.setup.toxenv }} 22 | continue-on-error: ${{ matrix.setup.experimental }} 23 | strategy: 24 | matrix: 25 | setup: ${{ fromJson(needs.prep-tests.outputs.matrix) }} 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: actions/cache@v2 29 | with: 30 | path: ~/.cache/pip 31 | key: pip-${{ needs.prep-tests.date }} 32 | - uses: actions/setup-python@v2 33 | with: 34 | python-version: ${{ matrix.setup.python-version }} 35 | - name: Install tox 36 | run: pip install tox 37 | - name: Run Tox 38 | run: tox --skip-missing-interpreters=false -e ${{ matrix.setup.toxenv }} 39 | - uses: codecov/codecov-action@v2 40 | with: 41 | name: ${{ matrix.setup.toxenv }} 42 | passed-tests: 43 | name: Required tests passed 44 | needs: [ tests ] 45 | runs-on: ubuntu-latest 46 | steps: 47 | - run: echo "All Done" -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 2 | 3 | name: Release Python Package to PyPi 4 | 5 | on: [ workflow_dispatch ] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: '3.x' 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install -r requirements/packaging.txt 21 | - name: Build and publish 22 | env: 23 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 24 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 25 | run: | 26 | python setup.py publish -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.db 3 | *~ 4 | .* 5 | 6 | html/ 7 | htmlcov/ 8 | coverage/ 9 | build/ 10 | dist/ 11 | venv/ 12 | *.egg-info/ 13 | MANIFEST 14 | docs/_build/ 15 | 16 | bin/ 17 | include/ 18 | lib/ 19 | local/ 20 | 21 | generated_clients/ 22 | *_out.yml 23 | 24 | !.gitignore 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2021-present, T. Franzel 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include LICENSE 3 | include django_seriously/py.typed 4 | include runtests.py 5 | graft tests 6 | graft requirements 7 | global-exclude __pycache__ 8 | global-exclude *.py[co] 9 | global-exclude .mypy_cache 10 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | django-seriously 3 | ================ 4 | 5 | |build-status| |pypi-version| 6 | 7 | ... wait what? no seriously, why isn't that part of Django/DRF? 8 | 9 | Opinionated collection of `Django`_ and `Django REST framework`_ tools that came in handy time and again. 10 | 11 | - ``AdminItemAction`` 12 | - Allow triggering context-aware custom admin operations in model list views. 13 | 14 | - ``admin_navigation_link`` 15 | - Allow navigation from the admin list view to other related models via links. 16 | 17 | - ``MinimalUser`` (abstract model) 18 | - Bare minimum user model ready for customization. 19 | - Removes the username and auxiliary fields like ``first_name`` and ``last_name``. 20 | - Allow creating users without a valid password (unusable password) 21 | - Abstract since its highly recommended to subclass the user model anyway. 22 | 23 | - ``ValidatedJSONField`` (model field) 24 | - validate the structure of JSON fields with Pydantic models. 25 | 26 | - ``TokenAuthentication`` 27 | - When OAuth2 adds too much complexity, DRF's TokenAuthentication is too simple, and 28 | `django-rest-knox`_ does not quite fit the permissioning. 29 | - No plain passwords in database (PBKDF2, i.e. hashed and salted) 30 | - Enabled for permission scoping 31 | - Easy (one-time-view) token creation in Django admin 32 | 33 | - ``BaseModel`` (abstract model) 34 | - Reusable base model with automatic ``created_at``, ``updated_at`` fields. 35 | - Primary key is a random UUID (``uuid4``). 36 | - Ensure validation logic (``full_clean()``) always runs, not just in a subset of cases. 37 | 38 | - ``AppSettings`` 39 | - A settings container with defaults and string importing inspired by DRF's ``APISettings`` 40 | 41 | 42 | License 43 | ------- 44 | 45 | Provided by `T. Franzel `_, `Licensed under 3-Clause BSD `_. 46 | 47 | Requirements 48 | ------------ 49 | 50 | - Python >= 3.6 51 | - Django >= 3.0 52 | - Django REST Framework (optional) 53 | 54 | Installation 55 | ------------ 56 | 57 | .. code:: bash 58 | 59 | $ pip install django-seriously 60 | 61 | 62 | Demo 63 | ---- 64 | 65 | Showcasing ``AdminItemAction``, ``admin_navigation_link``, ``MinimalUser`` and ``TokenAuthentication`` 66 | 67 | .. image:: https://github.com/tfranzel/django-seriously/blob/master/docs/demo.gif 68 | 69 | Usage 70 | ----- 71 | 72 | ``AdminItemAction`` 73 | =================== 74 | 75 | .. code:: python 76 | 77 | # admin.py 78 | from django_seriously.utils.admin import AdminItemAction 79 | 80 | 81 | class UserAdminAction(AdminItemAction[User]): 82 | model_cls = User 83 | actions = [ 84 | ("reset_invitation", "Reset Invitation"), 85 | ] 86 | 87 | @classmethod 88 | def is_actionable(cls, obj: User, action: str) -> bool: 89 | # check whether action should be shown for this item 90 | if action == "reset_invitation": 91 | return is_user_resettable_check(obj) # your code 92 | return False 93 | 94 | def perform_action(self, obj: User, action: str) -> Any: 95 | # perform the action on the item 96 | if action == "reset_invitation": 97 | perform_your_resetting(obj) # your code 98 | obj.save() 99 | 100 | 101 | @admin.register(User) 102 | class UserAdmin(ModelAdmin): 103 | # insert item actions into a list view column 104 | list_display = (..., "admin_actions") 105 | 106 | def admin_actions(self, obj: User): 107 | return UserAdminAction.action_markup(obj) 108 | 109 | .. code:: python 110 | 111 | # urls.py 112 | from django_seriously.utils.admin import AdminItemAction 113 | 114 | urlpatterns = [ 115 | ... 116 | # item actions must precede regular admin endpoints 117 | path("admin/", AdminItemAction.urls()), 118 | path("admin/", admin.site.urls), 119 | ] 120 | 121 | 122 | ``admin_navigation_link`` 123 | ========================= 124 | 125 | .. code:: python 126 | 127 | # admin.py 128 | from django_seriously.utils.admin import admin_navigation_link 129 | 130 | @admin.register(Article) 131 | class ArticleAdmin(ModelAdmin): 132 | # insert item actions into a list view column 133 | list_display = ('id', "name", "author_link") 134 | 135 | def author_link(self, obj: Article): 136 | return admin_navigation_link(obj.author, obj.author.name) 137 | 138 | 139 | ``TokenAuthentication`` 140 | ======================= 141 | 142 | .. code:: python 143 | 144 | # settings.py 145 | INSTALLED_APPS = [ 146 | ... 147 | # only required if auth token is not extended by you 148 | 'django_seriously.authtoken', 149 | ... 150 | ] 151 | 152 | SERIOUSLY_SETTINGS = { 153 | "AUTH_TOKEN_SCOPES": ["test-scope", "test-scope2"] 154 | } 155 | 156 | # views.py 157 | from django_seriously.authtoken.authentication import TokenAuthentication, TokenHasScope 158 | 159 | class TestViewSet(viewsets.ModelViewSet): 160 | ... 161 | permission_classes = [TokenHasScope] 162 | authentication_classes = [TokenAuthentication] 163 | required_scopes = ['test-scope'] 164 | 165 | 166 | ``MinimalUser`` 167 | =============== 168 | 169 | .. code:: python 170 | 171 | # models.py 172 | from django_seriously.minimaluser.models import MinimalAbstractUser 173 | from django_seriously.utils.models import BaseModel 174 | 175 | # BaseModel is optional but adds useful uuid, created_at, updated_at 176 | class User(BaseModel, MinimalAbstractUser): 177 | pass 178 | 179 | # admin.py 180 | from django_seriously.minimaluser.admin import MinimalUserAdmin 181 | 182 | @admin.register(User) 183 | class UserAdmin(MinimalUserAdmin): 184 | pass 185 | 186 | 187 | .. _Django: https://www.djangoproject.com/ 188 | .. _Django REST framework: https://www.django-rest-framework.org/ 189 | .. _django-rest-knox: https://github.com/James1345/django-rest-knox 190 | 191 | .. |pypi-version| image:: https://img.shields.io/pypi/v/django-seriously.svg 192 | :target: https://pypi.python.org/pypi/django-seriously 193 | .. |build-status| image:: https://github.com/tfranzel/django-seriously/actions/workflows/ci.yml/badge.svg 194 | :target: https://github.com/tfranzel/django-seriously/actions/workflows/ci.yml -------------------------------------------------------------------------------- /django_seriously/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.4.3" 2 | -------------------------------------------------------------------------------- /django_seriously/authtoken/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/django_seriously/authtoken/__init__.py -------------------------------------------------------------------------------- /django_seriously/authtoken/admin.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin, messages 3 | from django.contrib.admin import ModelAdmin 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from django_seriously.authtoken.forms import TokenChangeForm 7 | from django_seriously.authtoken.models import Token 8 | from django_seriously.authtoken.utils import generate_token 9 | 10 | 11 | class TokenAdmin(ModelAdmin): 12 | list_display = ( 13 | "id", 14 | "user", 15 | "name", 16 | "scopes", 17 | ) 18 | form = TokenChangeForm 19 | 20 | def save_model(self, request, obj: Token, form, change): 21 | if not change: 22 | token = generate_token() 23 | self.message_user( 24 | request=request, 25 | message=_( 26 | 'New bearer token is "{}". This can only be viewed once!' 27 | ).format(token.encoded_bearer), 28 | level=messages.WARNING, 29 | ) 30 | obj.id = token.id 31 | obj.key = token.key 32 | return super().save_model(request, obj, form, change) 33 | 34 | def get_readonly_fields(self, request, obj=None): 35 | return "id", "created_at", "updated_at", "last_seen_at" 36 | 37 | 38 | if "django_seriously.authtoken" in settings.INSTALLED_APPS: 39 | admin.site.register(Token, TokenAdmin) 40 | -------------------------------------------------------------------------------- /django_seriously/authtoken/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AuthtokenConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "django_seriously.authtoken" 7 | -------------------------------------------------------------------------------- /django_seriously/authtoken/authentication.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import uuid 3 | from typing import TYPE_CHECKING, Optional 4 | 5 | from django.contrib.auth.hashers import check_password 6 | from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist 7 | from django.utils import timezone 8 | from django.utils.translation import gettext_lazy as _ 9 | from rest_framework import exceptions 10 | from rest_framework.authentication import BaseAuthentication, get_authorization_header 11 | from rest_framework.permissions import BasePermission 12 | 13 | from django_seriously.settings import seriously_settings 14 | 15 | if TYPE_CHECKING: 16 | from django_seriously.authtoken.models import Token 17 | 18 | 19 | class TokenAuthentication(BaseAuthentication): 20 | """ 21 | Authentication method that works in tandem with Token. 22 | """ 23 | 24 | keyword = "Bearer" 25 | model = None 26 | 27 | def get_model(self): 28 | if self.model is not None: 29 | return self.model 30 | return seriously_settings.AUTH_TOKEN_MODEL 31 | 32 | def get_queryset(self): 33 | return self.get_model().objects.select_related("user") 34 | 35 | def authenticate(self, request): 36 | auth = get_authorization_header(request).split() 37 | 38 | if not auth or auth[0].lower() != self.keyword.lower().encode(): 39 | return None 40 | 41 | if len(auth) == 1: 42 | msg = _("Invalid token header. No credentials provided.") 43 | raise exceptions.AuthenticationFailed(msg) 44 | elif len(auth) > 2: 45 | msg = _("Invalid token header. Token string should not contain spaces.") 46 | raise exceptions.AuthenticationFailed(msg) 47 | 48 | try: 49 | token_str = auth[1].decode() 50 | except UnicodeError: 51 | msg = _( 52 | "Invalid token header. Token string should not contain invalid characters." 53 | ) 54 | raise exceptions.AuthenticationFailed(msg) 55 | 56 | return self.authenticate_credentials(token_str) 57 | 58 | def authenticate_credentials(self, token_str): 59 | try: 60 | token_bytes = base64.urlsafe_b64decode(token_str) 61 | if len(token_bytes) != 32: 62 | raise ValueError() 63 | token_id = uuid.UUID(bytes=token_bytes[:16], version=4) 64 | raw_token = token_bytes[16:] 65 | except ValueError: 66 | raise exceptions.AuthenticationFailed(_("Invalid token.")) 67 | 68 | try: 69 | token: "Token" = self.get_queryset().get(id=token_id) 70 | except ObjectDoesNotExist: 71 | raise exceptions.AuthenticationFailed(_("Invalid token.")) 72 | 73 | if not check_password(password=raw_token, encoded=token.key): # type: ignore 74 | raise exceptions.AuthenticationFailed(_("Invalid token.")) 75 | 76 | if not self.check_expiration(token): 77 | raise exceptions.AuthenticationFailed(_("Invalid token.")) 78 | 79 | if not token.user.is_active: 80 | raise exceptions.AuthenticationFailed(_("User inactive or deleted.")) 81 | 82 | token.last_seen_at = timezone.now() 83 | token.save(update_fields=["last_seen_at"]) 84 | 85 | if seriously_settings.CHECK_PASSWORD_REHASH(token.key): 86 | token.key = seriously_settings.MAKE_PASSWORD(raw_token) 87 | token.save(update_fields=["key"]) 88 | 89 | return token.user, token 90 | 91 | def check_expiration(self, token: "Token") -> bool: 92 | """user method that handles expired tokens""" 93 | return True 94 | 95 | def authenticate_header(self, request) -> str: 96 | return self.keyword 97 | 98 | 99 | class TokenHasScope(BasePermission): 100 | """Derived from django-oauth-toolkit's TokenHasScope""" 101 | 102 | def has_permission(self, request, view): 103 | token: Optional["Token"] = request.auth 104 | 105 | if not token: 106 | return False 107 | 108 | if hasattr(token, "scopes"): 109 | required_scopes = self.get_scopes(request, view) 110 | return all(r in token.scope_list for r in required_scopes) 111 | 112 | assert False, ( 113 | "TokenHasScope requires the`django_seriously.authtoken.TokenAuthentication` " 114 | "authentication class to be used." 115 | ) 116 | 117 | def get_scopes(self, request, view): 118 | try: 119 | return getattr(view, "required_scopes") 120 | except AttributeError: 121 | raise ImproperlyConfigured( 122 | "TokenHasScope requires the view to define the required_scopes attribute" 123 | ) 124 | -------------------------------------------------------------------------------- /django_seriously/authtoken/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth.forms import ReadOnlyPasswordHashField 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class TokenChangeForm(forms.ModelForm): 7 | key = ReadOnlyPasswordHashField( 8 | label=_("Key"), 9 | help_text=_("Raw keys are not stored, so there is no way to see this key"), 10 | ) 11 | -------------------------------------------------------------------------------- /django_seriously/authtoken/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-12 10:53 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Token', 20 | fields=[ 21 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 22 | ('created_at', models.DateTimeField(auto_now_add=True)), 23 | ('updated_at', models.DateTimeField(auto_now=True)), 24 | ('name', models.CharField(blank=True, max_length=25)), 25 | ('key', models.CharField(max_length=128, verbose_name='Key')), 26 | ('scopes', models.CharField(blank=True, help_text='comma-separated list of scopes. choices are .', max_length=50)), 27 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_tokens', to=settings.AUTH_USER_MODEL)), 28 | ], 29 | options={ 30 | 'abstract': False, 31 | }, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /django_seriously/authtoken/migrations/0002_token_last_seen_at_alter_token_scopes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.3 on 2022-12-18 15:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("authtoken", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="token", 15 | name="last_seen_at", 16 | field=models.DateTimeField(blank=True, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="token", 20 | name="scopes", 21 | field=models.CharField( 22 | blank=True, 23 | help_text="comma-separated list of scopes. choices are n/a.", 24 | max_length=50, 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /django_seriously/authtoken/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/django_seriously/authtoken/migrations/__init__.py -------------------------------------------------------------------------------- /django_seriously/authtoken/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.exceptions import ValidationError 3 | from django.db import models 4 | from django.utils.functional import cached_property 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from django_seriously.settings import seriously_settings 8 | from django_seriously.utils.models import BaseModel 9 | 10 | 11 | class Token(BaseModel): 12 | name = models.CharField(max_length=25, blank=True) 13 | key = models.CharField(_("Key"), max_length=128) 14 | user = models.ForeignKey( 15 | settings.AUTH_USER_MODEL, 16 | related_name="auth_tokens", 17 | on_delete=models.CASCADE, 18 | ) 19 | scopes = models.CharField( 20 | blank=True, 21 | max_length=50, 22 | help_text=( 23 | f"comma-separated list of scopes. choices are " 24 | f"{','.join(seriously_settings.AUTH_TOKEN_SCOPES) or 'n/a'}." 25 | ), 26 | ) 27 | last_seen_at = models.DateTimeField(blank=True, null=True) 28 | 29 | @cached_property 30 | def scope_list(self): 31 | return self.scopes.split(",") 32 | 33 | def clean(self) -> None: 34 | super().clean() 35 | if not self.scopes: 36 | scopes = [] 37 | elif isinstance(self.scopes, (list, tuple)): 38 | scopes = self.scopes 39 | elif isinstance(self.scopes, str): 40 | scopes = self.scopes.split(",") 41 | else: 42 | raise ValidationError({"scopes": "invalid scopes input"}) 43 | 44 | valid_scopes = seriously_settings.AUTH_TOKEN_SCOPES 45 | if valid_scopes and not all([s in valid_scopes for s in scopes]): 46 | raise ValidationError( 47 | {"scopes": f"invalid scope choices. valid choices are: {valid_scopes}"} 48 | ) 49 | self.scopes = ",".join(scopes) 50 | 51 | def __str__(self): 52 | return f"{self.name} ({self.user})" 53 | 54 | class Meta: 55 | abstract = "django_seriously.authtoken" not in settings.INSTALLED_APPS 56 | -------------------------------------------------------------------------------- /django_seriously/authtoken/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import uuid 4 | 5 | from django.contrib.auth.hashers import get_hasher 6 | 7 | from django_seriously.settings import seriously_settings 8 | 9 | 10 | class TokenContainer: 11 | """ 12 | A token is comprised of two random 16 byte strings. Id serves as primary key on 13 | the token table for fast retrieval, while the second part contains the actual 14 | secret. The token table will only contain a salted and hashed value for 15 | comparison (key), while cleartext secret is never persisted. 16 | 17 | bearer = id + secret 18 | encoded_bearer = base64(bearer) 19 | key = PBKDF2(secret) 20 | """ 21 | 22 | id: uuid.UUID 23 | key: str 24 | bearer: bytes 25 | encoded_bearer: str 26 | 27 | def __init__(self, id, key, bearer, encoded_bearer): 28 | self.id = id 29 | self.key = key 30 | self.bearer = bearer 31 | self.encoded_bearer = encoded_bearer 32 | 33 | 34 | def generate_token() -> TokenContainer: 35 | token_id = uuid.uuid4() 36 | secret = os.urandom(16) 37 | raw_bearer_token = token_id.bytes + secret 38 | return TokenContainer( 39 | id=token_id, 40 | key=seriously_settings.MAKE_PASSWORD(secret), 41 | bearer=raw_bearer_token, 42 | encoded_bearer=base64.urlsafe_b64encode(raw_bearer_token).decode(), 43 | ) 44 | 45 | 46 | def make_password(password) -> str: 47 | """Default hasher function used by seriously_settings.MAKE_PASSWORD""" 48 | if not isinstance(password, (bytes, str)): 49 | raise TypeError( 50 | f"Password must be a string or bytes, got {type(password).__qualname__}." 51 | ) 52 | hasher = get_hasher("pbkdf2_sha256") 53 | return hasher.encode(password, hasher.salt(), iterations=1_000) # type: ignore 54 | 55 | 56 | def check_password_rehash(raw_password: str) -> bool: 57 | return not raw_password.startswith("pbkdf2_sha256$1000$") 58 | -------------------------------------------------------------------------------- /django_seriously/minimaluser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/django_seriously/minimaluser/__init__.py -------------------------------------------------------------------------------- /django_seriously/minimaluser/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth import password_validation 3 | from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin 4 | from django.contrib.auth.forms import UserCreationForm as DjangoUserCreationForm 5 | from django.core.exceptions import ValidationError 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | 9 | class UserCreationForm(DjangoUserCreationForm): 10 | password1 = forms.CharField( 11 | label=_("Password"), 12 | strip=False, 13 | required=False, 14 | widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), 15 | help_text=password_validation.password_validators_help_text_html(), 16 | ) 17 | password2 = forms.CharField( 18 | label=_("Password confirmation"), 19 | widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}), 20 | strip=False, 21 | required=False, 22 | help_text=_("Enter the same password as before, for verification."), 23 | ) 24 | no_password = forms.BooleanField( 25 | label=_("Create user without password"), 26 | required=False, 27 | help_text=_("Sets an invalid password"), 28 | ) 29 | 30 | def _post_clean(self): 31 | super()._post_clean() # type: ignore 32 | # Validate the password after self.instance is updated with form data 33 | # by super(). 34 | password = self.cleaned_data.get("password2") 35 | if password: 36 | try: 37 | password_validation.validate_password(password, self.instance) 38 | except ValidationError as error: 39 | self.add_error("password2", error) 40 | elif self.cleaned_data.get("no_password"): 41 | if self.cleaned_data.get("password1") or self.cleaned_data.get("password2"): 42 | self.add_error( 43 | "password1", _("Cannot set password when no password is requested") 44 | ) 45 | self.add_error( 46 | "password2", _("Cannot set password when no password is requested") 47 | ) 48 | else: 49 | for f in ["no_password", "password1", "password2"]: 50 | self.add_error(f, _("Either password or flag must be set")) 51 | 52 | def save(self, commit=True): 53 | user = super().save(commit=False) 54 | if self.cleaned_data["no_password"]: 55 | user.set_unusable_password() 56 | else: 57 | user.set_password(self.cleaned_data["password1"]) 58 | if commit: 59 | user.save() 60 | return user 61 | 62 | 63 | class MinimalUserAdmin(DjangoUserAdmin): 64 | add_form = UserCreationForm 65 | fieldsets = ( 66 | (None, {"fields": ("id", "email", "password")}), 67 | ( 68 | _("Permissions"), 69 | { 70 | "fields": ( 71 | "is_active", 72 | "is_staff", 73 | "is_superuser", 74 | "groups", 75 | "user_permissions", 76 | ), 77 | }, 78 | ), 79 | (_("Important dates"), {"fields": ("last_login", "date_joined")}), 80 | ) 81 | add_fieldsets = ( 82 | ( 83 | None, 84 | { 85 | "classes": ("wide",), 86 | "fields": ("email", "password1", "password2", "no_password"), 87 | }, 88 | ), 89 | ) 90 | readonly_fields = ("id",) 91 | list_display = ("email", "last_login", "is_staff", "is_superuser") 92 | search_fields = ("email",) 93 | ordering = ("email",) 94 | -------------------------------------------------------------------------------- /django_seriously/minimaluser/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MinimaluserConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "django_seriously.minimaluser" 7 | -------------------------------------------------------------------------------- /django_seriously/minimaluser/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.base_user import AbstractBaseUser 2 | from django.contrib.auth.models import PermissionsMixin 3 | from django.contrib.auth.models import UserManager as DjangoUserManager 4 | from django.core.mail import send_mail 5 | from django.db import models 6 | from django.utils import timezone 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | 10 | class UserManager(DjangoUserManager): 11 | """Adaptation for minimaluser with minor changes to the Django version""" 12 | 13 | def _create_user(self, email, password, **extra_fields): 14 | email = self.normalize_email(email).lower() 15 | user = self.model(email=email, **extra_fields) 16 | user.set_password(password) 17 | user.save(using=self._db) 18 | return user 19 | 20 | def create_user(self, email, password=None, **extra_fields): 21 | extra_fields.setdefault("is_staff", False) 22 | extra_fields.setdefault("is_superuser", False) 23 | return self._create_user(email, password, **extra_fields) 24 | 25 | def create_superuser(self, email, password=None, **extra_fields): 26 | extra_fields.setdefault("is_staff", True) 27 | extra_fields.setdefault("is_superuser", True) 28 | 29 | if extra_fields.get("is_staff") is not True: 30 | raise ValueError("Superuser must have is_staff=True.") 31 | if extra_fields.get("is_superuser") is not True: 32 | raise ValueError("Superuser must have is_superuser=True.") 33 | 34 | return self._create_user(email, password, **extra_fields) 35 | 36 | 37 | class MinimalAbstractUser(AbstractBaseUser, PermissionsMixin): 38 | """ 39 | An abstract base class implementing a fully featured User model with 40 | admin-compliant permissions. Slight adaptation from Django version. 41 | 42 | Subclass (and optionally adapt) this model in your main app. 43 | """ 44 | 45 | email = models.EmailField( 46 | _("email address"), 47 | unique=True, 48 | error_messages={ 49 | "unique": _("A user with that email already exists."), 50 | }, 51 | ) 52 | is_staff = models.BooleanField( 53 | _("staff status"), 54 | default=False, 55 | help_text=_("Designates whether the user can log into this admin site."), 56 | ) 57 | is_active = models.BooleanField( 58 | _("active"), 59 | default=True, 60 | help_text=_( 61 | "Designates whether this user should be treated as active. " 62 | "Unselect this instead of deleting accounts." 63 | ), 64 | ) 65 | date_joined = models.DateTimeField(_("date joined"), default=timezone.now) 66 | 67 | objects = UserManager() 68 | 69 | EMAIL_FIELD = "email" 70 | USERNAME_FIELD = "email" 71 | REQUIRED_FIELDS: list[str] = [] 72 | 73 | class Meta: 74 | verbose_name = _("user") 75 | verbose_name_plural = _("users") 76 | abstract = True 77 | 78 | def clean(self): 79 | super().clean() 80 | self.email = self.email.lower() 81 | 82 | def email_user(self, subject, message, from_email=None, **kwargs): 83 | """Send an email to this user.""" 84 | send_mail(subject, message, from_email, [self.email], **kwargs) 85 | -------------------------------------------------------------------------------- /django_seriously/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/django_seriously/py.typed -------------------------------------------------------------------------------- /django_seriously/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | from django.conf import settings 4 | 5 | from django_seriously.utils.settings import AppSettings 6 | 7 | DEFAULTS: Dict[str, Any] = { 8 | "AUTH_TOKEN_SCOPES": [], 9 | "AUTH_TOKEN_MODEL": "django_seriously.authtoken.models.Token", 10 | "MAKE_PASSWORD": "django_seriously.authtoken.utils.make_password", 11 | "CHECK_PASSWORD_REHASH": "django_seriously.authtoken.utils.check_password_rehash", 12 | } 13 | 14 | IMPORT_STRINGS = ["AUTH_TOKEN_MODEL", "MAKE_PASSWORD", "CHECK_PASSWORD_REHASH"] 15 | 16 | seriously_settings = AppSettings( 17 | user_settings=getattr(settings, "SERIOUSLY_SETTINGS", {}), 18 | defaults=DEFAULTS, 19 | import_strings=IMPORT_STRINGS, 20 | ) 21 | -------------------------------------------------------------------------------- /django_seriously/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/django_seriously/utils/__init__.py -------------------------------------------------------------------------------- /django_seriously/utils/admin.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Any, Generic, Optional, Type, TypeVar, Union 3 | 4 | from django.contrib import messages 5 | from django.contrib.auth.mixins import AccessMixin 6 | from django.db import models, transaction 7 | from django.http.response import HttpResponse, JsonResponse 8 | from django.urls.base import reverse 9 | from django.urls.conf import include, path 10 | from django.utils.functional import Promise 11 | from django.utils.html import format_html 12 | from django.utils.safestring import mark_safe 13 | from django.utils.translation import gettext_lazy as _ 14 | from django.views.generic.base import View 15 | 16 | _T = TypeVar("_T", bound=models.Model) 17 | 18 | 19 | class AdminRequiredMixin(AccessMixin): 20 | """Verify that the current user is authenticated and is admin""" 21 | 22 | def dispatch(self, request, *args, **kwargs): 23 | if not request.user.is_authenticated or not request.user.is_staff: 24 | return self.handle_no_permission() 25 | return super().dispatch(request, *args, **kwargs) # type: ignore 26 | 27 | 28 | class AdminItemAction(AdminRequiredMixin, View, Generic[_T], metaclass=abc.ABCMeta): 29 | """ 30 | convenience view for having a context sensitive button per item in the admin list view. 31 | """ 32 | 33 | _registry: list[Type["AdminItemAction"]] = [] 34 | model_cls: Type[_T] 35 | successful_message = _("Action completed successfully") 36 | error_message = _("Action failed: {}") 37 | actions = [ 38 | ("nop", _("No operation")), 39 | ] 40 | 41 | def __init_subclass__(cls, **kwargs): 42 | super().__init_subclass__(**kwargs) # type: ignore 43 | cls._registry.append(cls) 44 | 45 | def post(self, request, id: str, action: str): 46 | try: 47 | # try to obtain referenced model object 48 | obj = self.model_cls.objects.get(id=id) 49 | # see if object is still actionable (might have changed in the meantime) 50 | with transaction.atomic(): 51 | if not self.is_actionable(obj, action): 52 | raise ValueError("not actionable anymore") 53 | res = self.perform_action(obj, action) 54 | messages.success(request, self.successful_message) 55 | if res is None: 56 | return HttpResponse(status=204) 57 | else: 58 | return JsonResponse(res, safe=False) 59 | except Exception as e: 60 | messages.error(request, self.error_message.format(e)) 61 | return HttpResponse(status=400) 62 | 63 | @abc.abstractmethod 64 | def perform_action(self, obj: _T, action: str) -> Any: 65 | raise NotImplementedError() 66 | 67 | @classmethod 68 | @abc.abstractmethod 69 | def is_actionable(cls, obj: _T, action: str) -> bool: 70 | """does the action apply to this particular object""" 71 | return True 72 | 73 | @classmethod 74 | def extra_javascript(cls): 75 | return "" 76 | 77 | @classmethod 78 | def action_markup(cls, obj: _T): 79 | return mark_safe( 80 | " ".join([cls._action(obj, action, label) for action, label in cls.actions]) 81 | ) 82 | 83 | @classmethod 84 | def _action(cls, obj: _T, action: str, label: Union[str, Promise]): 85 | """template rendering of action""" 86 | if not cls.is_actionable(obj, action): 87 | return "" 88 | 89 | return format_html( 90 | """ 91 | {} 108 | """, # noqa: E501 109 | reverse(cls.__name__.lower(), args=[obj.pk, action]), 110 | cls.extra_javascript(), 111 | label, 112 | ) 113 | 114 | @classmethod 115 | def _path(cls): 116 | """returns a urlpattern for registration in settings""" 117 | return path( 118 | route=( 119 | f"{cls.model_cls._meta.app_label}/{cls.model_cls.__name__.lower()}/" 120 | f"/{cls.__name__.lower()}//" 121 | ), 122 | view=cls.as_view(), 123 | name=cls.__name__.lower(), 124 | ) 125 | 126 | @classmethod 127 | def urls(cls): 128 | return include([item._path() for item in cls._registry]) 129 | 130 | 131 | def admin_navigation_link(entity: models.Model, label: Optional[str] = None) -> str: 132 | if not entity: 133 | return "" 134 | 135 | url = reverse( 136 | f"admin:{entity._meta.app_label}_{entity._meta.model_name}_change", 137 | args=[entity.pk], 138 | ) 139 | if not label: 140 | label = str(entity) 141 | return format_html(f'{label}') 142 | -------------------------------------------------------------------------------- /django_seriously/utils/fields.py: -------------------------------------------------------------------------------- 1 | from django.core.serializers.json import DjangoJSONEncoder 2 | from django.db import models 3 | 4 | from django_seriously.utils.forms import PydanticJSONFormField 5 | from django_seriously.utils.pydantic import PydanticMixin 6 | 7 | 8 | class ValidatedJSONField(PydanticMixin, models.JSONField): 9 | """ 10 | Model field that validates json data structure according to specified 11 | pydantic model. Data remains in generic python objects, while pydantic 12 | is only used for validation on save. 13 | """ 14 | 15 | def __init__(self, structure, **kwargs): 16 | self.structure = structure 17 | if structure in [str, bytes]: 18 | raise ValueError("ValidatedJSONField requires non-trivial structure") 19 | kwargs.setdefault("encoder", DjangoJSONEncoder) 20 | self.json_loads = kwargs.pop("json_loads", None) 21 | super().__init__(**kwargs) 22 | 23 | def deconstruct(self): 24 | name, path, args, kwargs = super().deconstruct() 25 | kwargs["structure"] = None 26 | return name, path, args, kwargs 27 | 28 | def validate(self, value, model_instance): 29 | super(models.JSONField, self).validate(value, model_instance) 30 | self._loads(value) 31 | 32 | 33 | class PydanticJSONField(PydanticMixin, models.JSONField): 34 | """ """ 35 | 36 | def __init__(self, structure, **kwargs): 37 | self.structure = structure 38 | if structure in [str, bytes]: 39 | raise ValueError("PydanticJSONField requires non-trivial structure") 40 | kwargs.setdefault("encoder", DjangoJSONEncoder) 41 | self.json_loads = kwargs.pop("json_loads", None) 42 | self.json_dumps = kwargs.pop("json_dumps", None) 43 | self._initial = None 44 | super().__init__(**kwargs) 45 | 46 | def deconstruct(self): 47 | name, path, args, kwargs = super().deconstruct() 48 | kwargs["structure"] = None 49 | return name, path, args, kwargs 50 | 51 | def validate(self, value, model_instance): 52 | super(models.JSONField, self).validate(value, model_instance) 53 | # this is somewhat stupid, but pydantic only validates on load and 54 | # thus validation errors can only be caught with this extra step. 55 | # try to be a bit smarter by skipping loading when nothing changed 56 | dumped_value = self._dumps(value) 57 | if dumped_value != self._initial: 58 | self._loads(dumped_value) 59 | 60 | def from_db_value(self, value, expression, connection): 61 | if value is None: 62 | return value 63 | self._initial = value 64 | return self._loads(value) 65 | 66 | def to_python(self, value): 67 | if value is None: 68 | return value 69 | return self._loads(value) 70 | 71 | def get_prep_value(self, value): 72 | if value is None: 73 | return value 74 | return self._dumps(value) 75 | 76 | def formfield(self, **kwargs): 77 | return super().formfield( 78 | **{ 79 | "form_class": PydanticJSONFormField, 80 | "structure": self.structure, 81 | "encoder": self.encoder, 82 | "decoder": self.decoder, 83 | "json_loads": self.json_loads, 84 | "json_dumps": self.json_dumps, 85 | **kwargs, 86 | } 87 | ) 88 | -------------------------------------------------------------------------------- /django_seriously/utils/forms.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.forms import JSONField 3 | from django.forms.fields import InvalidJSONInput, JSONString 4 | 5 | from django_seriously.utils.pydantic import PydanticMixin 6 | 7 | 8 | class PydanticJSONFormField(PydanticMixin, JSONField): 9 | def __init__( 10 | self, 11 | structure, 12 | encoder=None, 13 | decoder=None, 14 | json_loads=None, 15 | json_dumps=None, 16 | **kwargs, 17 | ): 18 | self.structure = structure 19 | self.json_loads = json_loads 20 | self.json_dumps = json_dumps 21 | super().__init__(encoder=encoder, decoder=decoder, **kwargs) 22 | 23 | def to_python(self, value): 24 | if self.disabled: 25 | return value 26 | if value in self.empty_values: 27 | return None 28 | elif isinstance(value, (list, dict, int, float, JSONString)): 29 | return value 30 | try: 31 | converted = self._loads(value) 32 | except ValidationError: 33 | raise ValidationError( 34 | self.error_messages["invalid"], 35 | code="invalid", 36 | params={"value": value}, 37 | ) 38 | if isinstance(converted, str): 39 | return JSONString(converted) 40 | else: 41 | return converted 42 | 43 | def bound_data(self, data, initial): 44 | if self.disabled: 45 | return initial 46 | if data is None: 47 | return None 48 | try: 49 | return self._loads(data) 50 | except ValidationError: 51 | return InvalidJSONInput(data) 52 | 53 | def prepare_value(self, value): 54 | if isinstance(value, InvalidJSONInput): 55 | return value 56 | return self._dumps(value) 57 | 58 | def has_changed(self, initial, data): 59 | if super().has_changed(initial, data): 60 | return True 61 | # For purposes of seeing whether something has changed, True isn't the 62 | # same as 1 and the order of keys doesn't matter. 63 | return self._dumps(initial) != self._dumps(self.to_python(data)) 64 | -------------------------------------------------------------------------------- /django_seriously/utils/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | 6 | class BaseModel(models.Model): 7 | """Opinionated Django base model""" 8 | 9 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 10 | created_at = models.DateTimeField(auto_now_add=True) 11 | updated_at = models.DateTimeField(auto_now=True) 12 | 13 | class Meta: 14 | abstract = True 15 | 16 | def save(self, *args, **kwargs): 17 | """ 18 | Because the default is ridiculous. This guarantees that validation 19 | logic is executed in every non-bulk save situation. This comes at 20 | the expense of potentially running validation more than once. 21 | """ 22 | self.full_clean() 23 | return super().save(*args, **kwargs) 24 | -------------------------------------------------------------------------------- /django_seriously/utils/pydantic.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from django import VERSION as DJANGO_VERSION 5 | from django.core import exceptions 6 | from pydantic import BaseModel, ValidationError, parse_obj_as, parse_raw_as 7 | 8 | 9 | def _build_container(structure, *, json_loads=None, json_dumps=None): 10 | loads_callable = json_loads or json.loads 11 | dumps_callable = json_dumps or json.dumps 12 | 13 | class Tmp(BaseModel): 14 | __root__: structure # type: ignore 15 | 16 | class Config: 17 | json_loads = loads_callable 18 | json_dumps = dumps_callable 19 | 20 | return Tmp 21 | 22 | 23 | def _is_pydantic(value: Any) -> bool: 24 | try: 25 | return isinstance(value, BaseModel) 26 | except TypeError: 27 | return False 28 | 29 | 30 | def pydantic_dumps(structure, value: Any, *, json_dumps=None, encoder=None) -> str: 31 | try: 32 | if _is_pydantic(value) and not json_dumps: 33 | obj = value 34 | else: 35 | obj = _build_container(structure, json_dumps=json_dumps)(__root__=value) 36 | 37 | if DJANGO_VERSION < (4, 2): 38 | return obj.json() 39 | else: 40 | obj = obj.dict() 41 | return obj["__root__"] if "__root__" in obj else obj 42 | except ValidationError as e: 43 | raise exceptions.ValidationError(f"Invalid type structure for {structure}: {e}") 44 | 45 | 46 | def pydantic_loads(structure, value: Any, *, json_loads=None, decoder=None) -> Any: 47 | try: 48 | if json_loads: 49 | # enclose structure in thin wrapper to inject custom json_loads 50 | structure = _build_container(structure, json_loads=json_loads) 51 | if isinstance(value, (str, bytes)): 52 | result = parse_raw_as(structure, value) 53 | else: 54 | result = parse_obj_as(structure, value) 55 | # unpack added wrapper 56 | return result.__root__ if json_loads else result 57 | except ValidationError as e: 58 | raise exceptions.ValidationError(f"Invalid type structure for {structure}: {e}") 59 | 60 | 61 | class PydanticMixin: 62 | def _dumps(self, value): 63 | return pydantic_dumps( 64 | self.structure, value, json_dumps=self.json_dumps, encoder=self.encoder 65 | ) 66 | 67 | def _loads(self, value): 68 | return pydantic_loads( 69 | self.structure, value, json_loads=self.json_loads, decoder=self.decoder 70 | ) 71 | -------------------------------------------------------------------------------- /django_seriously/utils/schema.py: -------------------------------------------------------------------------------- 1 | from drf_spectacular.extensions import OpenApiSerializerFieldExtension 2 | from drf_spectacular.plumbing import follow_field_source 3 | 4 | 5 | class PydanticJsonFieldExtensions(OpenApiSerializerFieldExtension): 6 | """ 7 | extension class for drf-spectacular that hooks into JSONField 8 | parsing and injects `structure`'s schema instead of using a 9 | catch-all object. 10 | Simply import this file into your project to load the extension 11 | """ 12 | 13 | target_class = "rest_framework.fields.JSONField" 14 | 15 | def map_serializer_field(self, auto_schema, direction): 16 | if hasattr(self.target.parent, "Meta"): 17 | model = self.target.parent.Meta.model 18 | source = self.target.source.split(".") 19 | model_field = follow_field_source(model, source) 20 | 21 | if hasattr(model_field, "structure"): 22 | # let pydantic generate as JSON Schema 23 | return model_field.structure.schema() 24 | 25 | return auto_schema._map_serializer_field( 26 | self.target, direction, bypass_extensions=True 27 | ) 28 | -------------------------------------------------------------------------------- /django_seriously/utils/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Set 2 | 3 | from django.utils.module_loading import import_string 4 | 5 | 6 | def perform_import(val): 7 | if val is None: 8 | return None 9 | elif isinstance(val, str): 10 | return import_string(val) 11 | elif isinstance(val, (list, tuple)): 12 | return [import_string(item) for item in val] 13 | return val 14 | 15 | 16 | class AppSettings: 17 | """ 18 | Reusable settings container that handles import strings, defaults, 19 | reloading, and lazy evaluation. 20 | This class is shamelessly recycled from DRF's APISettings without 21 | introducing a dependency on DRF. 22 | """ 23 | 24 | def __init__( 25 | self, 26 | user_settings: Dict[str, Any], 27 | defaults: Dict[str, Any], 28 | import_strings: List[str], 29 | ): 30 | self.user_settings = user_settings 31 | self.defaults = defaults 32 | self.import_strings = import_strings 33 | self._cached_attrs: Set[str] = set() 34 | 35 | def __getattr__(self, attr): 36 | if attr not in self.defaults: 37 | raise AttributeError("Invalid API setting: '%s'" % attr) 38 | 39 | try: 40 | # Check if present in user settings 41 | val = self.user_settings[attr] 42 | except KeyError: 43 | # Fall back to defaults 44 | val = self.defaults[attr] 45 | 46 | # Coerce import strings into classes 47 | if attr in self.import_strings: 48 | val = perform_import(val) 49 | 50 | # Cache the result 51 | self._cached_attrs.add(attr) 52 | setattr(self, attr, val) 53 | return val 54 | 55 | def reload(self): 56 | for attr in self._cached_attrs: 57 | delattr(self, attr) 58 | self._cached_attrs.clear() 59 | if hasattr(self, "_user_settings"): 60 | delattr(self, "_user_settings") 61 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/docs/demo.gif -------------------------------------------------------------------------------- /helper/github-ci-vars.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import re 4 | import subprocess 5 | 6 | envs = subprocess.check_output(["tox", "-l"]).decode().rstrip().split("\n") 7 | matrix = [] 8 | 9 | for env in envs: 10 | version = re.search(r"^py(?P\d)(?P\d+)-", env) 11 | 12 | # github "commit" checks will fail even though workflow passes overall. 13 | # temp remove the optional targets to make github CI work. 14 | if "master" in env: 15 | continue 16 | 17 | matrix.append( 18 | { 19 | "toxenv": env, 20 | "python-version": f'{version.group("major")}.{version.group("minor")}', 21 | "experimental": bool("master" in env), 22 | } 23 | ) 24 | 25 | print(f"::set-output name=date::{datetime.date.today()}") 26 | print(f"::set-output name=matrix::{json.dumps(matrix)}") 27 | -------------------------------------------------------------------------------- /helper/makemigrations.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | if __name__ == "__main__": 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "helper.mock_settings") 6 | from django.core.management import execute_from_command_line 7 | 8 | args = sys.argv + ["makemigrations"] 9 | execute_from_command_line(args) 10 | -------------------------------------------------------------------------------- /helper/mock_settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BASE_DIR = Path(__file__).resolve().parent.parent 4 | 5 | SECRET_KEY = "django-insecure-" 6 | DEBUG = True 7 | DATABASES = { 8 | "default": { 9 | "ENGINE": "django.db.backends.sqlite3", 10 | "NAME": BASE_DIR / "db.sqlite3", 11 | } 12 | } 13 | INSTALLED_APPS = [ 14 | "django.contrib.auth", 15 | "django.contrib.contenttypes", 16 | "django_seriously.authtoken", 17 | "django_seriously.minimaluser", 18 | ] 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ['py39'] 3 | extend-exclude = 'migrations/.*' 4 | 5 | [tool.isort] 6 | profile = 'black' 7 | multi_line_output = 3 8 | skip_glob = "*/migrations/*" 9 | 10 | [tool.mypy] 11 | python_version = "3.9" 12 | plugins = "mypy_django_plugin.main,mypy_drf_plugin.main" 13 | 14 | [tool.django-stubs] 15 | django_settings_module = "tests.settings" 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/base.txt 2 | -r requirements/optionals.txt 3 | -r requirements/testing.txt 4 | -r requirements/packaging.txt -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | Django>=3.2 2 | djangorestframework>=3.12.0 3 | typing-extensions>=3.10.0.2 4 | pydantic>=1.8.2,<2.0 5 | -------------------------------------------------------------------------------- /requirements/optionals.txt: -------------------------------------------------------------------------------- 1 | drf-spectacular>=0.24.2 -------------------------------------------------------------------------------- /requirements/packaging.txt: -------------------------------------------------------------------------------- 1 | twine>=3.1.1 2 | wheel>=0.34.2 3 | -------------------------------------------------------------------------------- /requirements/testing.txt: -------------------------------------------------------------------------------- 1 | black>=21.12b0 2 | pytest>=5.3.5 3 | pytest-django>=3.8.0 4 | pytest-cov>=2.8.1 5 | flake8>=3.7.9 6 | isort>=5.0.4 7 | mypy>=0.770 8 | django-stubs>=1.8.0 9 | djangorestframework-stubs>=1.1.0 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import re 5 | import shutil 6 | import sys 7 | 8 | from setuptools import setup 9 | 10 | name = "django-seriously" 11 | package = "django_seriously" 12 | description = ( 13 | "Opinionated collection of Django and DRF tools that came in handy time and again." 14 | ) 15 | url = "https://github.com/tfranzel/django-seriously" 16 | author = "T. Franzel" 17 | author_email = "tfranzel@gmail.com" 18 | license = "BSD" 19 | 20 | 21 | with open("README.rst") as readme: 22 | long_description = readme.read() 23 | 24 | with open("requirements/base.txt") as fh: 25 | requirements = [r for r in fh.read().split("\n") if not r.startswith("#")] 26 | 27 | 28 | def get_version(package): 29 | """ 30 | Return package version as listed in `__version__` in `init.py`. 31 | """ 32 | init_py = open(os.path.join(package, "__init__.py")).read() 33 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE).group( 34 | 1 35 | ) 36 | 37 | 38 | def get_packages(package): 39 | """ 40 | Return root package and all sub-packages. 41 | """ 42 | return [ 43 | dirpath 44 | for dirpath, dirnames, filenames in os.walk(package) 45 | if os.path.exists(os.path.join(dirpath, "__init__.py")) 46 | ] 47 | 48 | 49 | version = get_version(package) 50 | 51 | 52 | if sys.argv[-1] == "publish": 53 | if os.system("pip freeze | grep twine"): 54 | print("twine not installed.\nUse `pip install twine`.\nExiting.") 55 | sys.exit(1) 56 | os.system("python setup.py sdist bdist_wheel") 57 | if os.system("twine check dist/*"): 58 | print("twine check failed. Packages might be outdated.") 59 | print("Try using `pip install -U twine wheel`.\nExiting.") 60 | sys.exit(1) 61 | if os.system("twine upload dist/*"): 62 | print("failed to upload package") 63 | sys.exit(1) 64 | if os.environ.get("CI"): 65 | os.system("git config user.name github-actions") 66 | os.system("git config user.email github-actions@github.com") 67 | os.system(f"git tag -a {version} -m 'version {version}'") 68 | if os.system("git push --tags"): 69 | print("failed pushing release tag") 70 | sys.exit(1) 71 | shutil.rmtree("dist") 72 | shutil.rmtree("build") 73 | shutil.rmtree("django_seriously.egg-info") 74 | sys.exit() 75 | 76 | 77 | setup( 78 | name=name, 79 | version=version, 80 | url=url, 81 | license=license, 82 | description=description, 83 | long_description=long_description, 84 | long_description_content_type="text/x-rst", 85 | author=author, 86 | author_email=author_email, 87 | packages=get_packages(package), 88 | include_package_data=True, 89 | python_requires=">=3.6", 90 | install_requires=requirements, 91 | extras_require={}, 92 | classifiers=[ 93 | "Development Status :: 4 - Beta", 94 | "Environment :: Web Environment", 95 | "Framework :: Django", 96 | "Framework :: Django :: 3.2", 97 | "Framework :: Django :: 4.0", 98 | "Intended Audience :: Developers", 99 | "License :: OSI Approved :: BSD License", 100 | "Operating System :: OS Independent", 101 | "Natural Language :: English", 102 | "Programming Language :: Python :: 3", 103 | "Programming Language :: Python :: 3.6", 104 | "Programming Language :: Python :: 3.7", 105 | "Programming Language :: Python :: 3.8", 106 | "Programming Language :: Python :: 3.9", 107 | "Programming Language :: Python :: 3.10", 108 | "Topic :: Internet :: WWW/HTTP", 109 | ], 110 | project_urls={ 111 | "Source": "https://github.com/tfranzel/django-seriously", 112 | }, 113 | ) 114 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | import pytest 5 | from django.core import management 6 | 7 | 8 | def pytest_configure(config): 9 | from django.conf import settings 10 | 11 | base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 12 | 13 | settings.configure( 14 | DEBUG_PROPAGATE_EXCEPTIONS=True, 15 | DATABASES={ 16 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} 17 | }, 18 | SITE_ID=1, 19 | SECRET_KEY="not very secret in tests", 20 | USE_I18N=True, 21 | LANGUAGES=[ 22 | ("de-de", "German"), 23 | ("en-us", "English"), 24 | ], 25 | LOCALE_PATHS=[base_dir + "/locale/"], 26 | STATIC_URL="/static/", 27 | ROOT_URLCONF="tests.urls", 28 | TEMPLATES=[ 29 | { 30 | "BACKEND": "django.template.backends.django.DjangoTemplates", 31 | "DIRS": [], 32 | "APP_DIRS": True, 33 | "OPTIONS": { 34 | "context_processors": [ 35 | "django.template.context_processors.debug", 36 | ], 37 | }, 38 | }, 39 | ], 40 | MIDDLEWARE=( 41 | "django.contrib.sessions.middleware.SessionMiddleware", 42 | "django.middleware.common.CommonMiddleware", 43 | "django.contrib.auth.middleware.AuthenticationMiddleware", 44 | "django.middleware.locale.LocaleMiddleware", 45 | ), 46 | INSTALLED_APPS=( 47 | "django.contrib.auth", 48 | "django.contrib.contenttypes", 49 | "django.contrib.sessions", 50 | "django.contrib.sites", 51 | "django.contrib.messages", 52 | "django.contrib.staticfiles", 53 | "rest_framework", 54 | "django_seriously.authtoken", 55 | "tests", 56 | ), 57 | PASSWORD_HASHERS=( 58 | "django.contrib.auth.hashers.SHA1PasswordHasher", 59 | "django.contrib.auth.hashers.PBKDF2PasswordHasher", 60 | "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", 61 | "django.contrib.auth.hashers.BCryptPasswordHasher", 62 | "django.contrib.auth.hashers.MD5PasswordHasher", 63 | "django.contrib.auth.hashers.CryptPasswordHasher", 64 | ), 65 | DEFAULT_AUTO_FIELD="django.db.models.AutoField", 66 | SILENCED_SYSTEM_CHECKS=[ 67 | "rest_framework.W001", 68 | "fields.E210", 69 | "security.W001", 70 | "security.W002", 71 | "security.W003", 72 | "security.W009", 73 | "security.W012", 74 | ], 75 | ) 76 | 77 | django.setup() 78 | # For whatever reason this works locally without an issue. 79 | # on TravisCI content_type table is missing in the sqlite db as 80 | # if no migration ran, but then why does it work locally?! 81 | management.call_command("migrate") 82 | 83 | 84 | @pytest.fixture() 85 | def no_warnings(capsys): 86 | """make sure test emits no warnings""" 87 | yield capsys 88 | captured = capsys.readouterr() 89 | assert not captured.out 90 | assert not captured.err 91 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/tests/models.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/tests/settings.py -------------------------------------------------------------------------------- /tests/test_adminitemaction.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/tests/test_adminitemaction.py -------------------------------------------------------------------------------- /tests/test_authtoken.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import uuid 4 | from unittest import mock 5 | 6 | import pytest 7 | from django.contrib.auth.hashers import get_hasher 8 | from django.contrib.auth.models import User 9 | from django.urls import path 10 | from rest_framework.permissions import IsAuthenticated 11 | from rest_framework.response import Response 12 | from rest_framework.test import APIClient 13 | from rest_framework.views import APIView 14 | 15 | from django_seriously.authtoken.authentication import TokenAuthentication, TokenHasScope 16 | from django_seriously.authtoken.models import Token 17 | from django_seriously.authtoken.utils import TokenContainer, generate_token 18 | 19 | 20 | class TestAPIView(APIView): 21 | permission_classes = [IsAuthenticated] 22 | authentication_classes = [TokenAuthentication] 23 | 24 | def get(self, request): 25 | return Response("ok") 26 | 27 | 28 | class TestAPIScopedView(APIView): 29 | permission_classes = [TokenHasScope] 30 | authentication_classes = [TokenAuthentication] 31 | required_scopes = ["test-scope1"] 32 | 33 | def get(self, request): 34 | return Response("ok") 35 | 36 | 37 | urlpatterns = [ 38 | path("u/", TestAPIView.as_view()), 39 | path("s/", TestAPIScopedView.as_view()), 40 | ] 41 | 42 | 43 | def gen_token() -> tuple[Token, TokenContainer]: 44 | token_container = generate_token() 45 | token = Token.objects.create( 46 | id=token_container.id, 47 | key=token_container.key, 48 | user=User.objects.create_user("test@example.com"), 49 | name="test", 50 | ) 51 | return token, token_container 52 | 53 | 54 | @pytest.mark.urls(__name__) 55 | @pytest.mark.django_db 56 | def test_token_auth(): 57 | token, token_container = gen_token() 58 | 59 | response = APIClient().get("/u/") 60 | assert response.status_code == 401 61 | 62 | response = APIClient().get( 63 | "/u/", HTTP_AUTHORIZATION=f"Bearer {token_container.encoded_bearer}" 64 | ) 65 | assert response.status_code == 200 66 | 67 | 68 | @pytest.mark.urls(__name__) 69 | @pytest.mark.django_db 70 | def test_unknown_token(): 71 | token_id = uuid.uuid4() 72 | secret = os.urandom(16) 73 | raw_bearer_token = token_id.bytes + secret 74 | bearer = base64.urlsafe_b64encode(raw_bearer_token).decode() 75 | 76 | response = APIClient().get("/u/", HTTP_AUTHORIZATION=f"Bearer {bearer}") 77 | assert response.status_code == 401 78 | 79 | 80 | @pytest.mark.urls(__name__) 81 | @pytest.mark.django_db 82 | def test_known_id_invalid_secret(): 83 | _, token_container = gen_token() 84 | secret = os.urandom(16) 85 | raw_bearer_token = token_container.id.bytes + secret 86 | bearer = base64.urlsafe_b64encode(raw_bearer_token).decode() 87 | 88 | response = APIClient().get("/u/", HTTP_AUTHORIZATION=f"Bearer {bearer}") 89 | assert response.status_code == 401 90 | 91 | 92 | @pytest.mark.urls(__name__) 93 | @pytest.mark.django_db 94 | @mock.patch( 95 | "django_seriously.settings.seriously_settings.AUTH_TOKEN_SCOPES", 96 | ["test-scope1", "test-scope2"], 97 | ) 98 | def test_scoped_token_auth(): 99 | token, token_container = gen_token() 100 | 101 | response = APIClient().get("/s/") 102 | assert response.status_code == 401 103 | 104 | # token is missing scope 105 | response = APIClient().get( 106 | "/s/", HTTP_AUTHORIZATION=f"Bearer {token_container.encoded_bearer}" 107 | ) 108 | assert response.status_code == 403 109 | 110 | token.scopes = "test-scope1" 111 | token.save() 112 | 113 | # token has scope 114 | response = APIClient().get( 115 | "/s/", HTTP_AUTHORIZATION=f"Bearer {token_container.encoded_bearer}" 116 | ) 117 | assert response.status_code == 200 118 | 119 | 120 | @pytest.mark.urls(__name__) 121 | @pytest.mark.django_db 122 | def test_token_rehash(): 123 | token, token_container = gen_token() 124 | 125 | assert ( 126 | APIClient() 127 | .get("/u/", HTTP_AUTHORIZATION=f"Bearer {token_container.encoded_bearer}") 128 | .status_code 129 | == 200 130 | ) 131 | 132 | token.refresh_from_db() 133 | saved_key_original = token.key 134 | 135 | def make_new_password(password) -> str: 136 | hasher = get_hasher("pbkdf2_sha256") 137 | return hasher.encode(password, hasher.salt(), iterations=5_000) # type: ignore 138 | 139 | def check_new_password_rehash(raw_password: str) -> bool: 140 | return not raw_password.startswith("pbkdf2_sha256$5000$") 141 | 142 | with mock.patch( 143 | "django_seriously.settings.seriously_settings.CHECK_PASSWORD_REHASH", 144 | check_new_password_rehash, 145 | ), mock.patch( 146 | "django_seriously.settings.seriously_settings.MAKE_PASSWORD", make_new_password 147 | ): 148 | assert ( 149 | APIClient() 150 | .get("/u/", HTTP_AUTHORIZATION=f"Bearer {token_container.encoded_bearer}") 151 | .status_code 152 | == 200 153 | ) 154 | 155 | token.refresh_from_db() 156 | saved_key_rehashed = token.key 157 | 158 | assert ( 159 | APIClient() 160 | .get("/u/", HTTP_AUTHORIZATION=f"Bearer {token_container.encoded_bearer}") 161 | .status_code 162 | == 200 163 | ) 164 | 165 | token.refresh_from_db() 166 | saved_key_rehashed2 = token.key 167 | 168 | assert saved_key_original.startswith("pbkdf2_sha256$1000$") 169 | assert saved_key_rehashed.startswith("pbkdf2_sha256$5000$") 170 | # no change in method, so nothing is supposed to change 171 | assert saved_key_rehashed == saved_key_rehashed2 172 | -------------------------------------------------------------------------------- /tests/test_base_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core import exceptions 3 | from django.core.validators import MaxValueValidator, MinValueValidator 4 | from django.db import models 5 | 6 | from django_seriously.utils.models import BaseModel 7 | 8 | 9 | class TestModel(BaseModel): 10 | field = models.IntegerField( 11 | validators=[MinValueValidator(0), MaxValueValidator(100)] 12 | ) 13 | 14 | 15 | @pytest.mark.django_db 16 | def test_base_model_derivation(): 17 | inst = TestModel.objects.create(field=1) 18 | assert inst.created_at 19 | assert inst.updated_at 20 | assert inst.field == 1 21 | 22 | 23 | @pytest.mark.django_db 24 | def test_base_model_clean_check(): 25 | """forced full_clean makes this raise. otherwise validation would not run""" 26 | with pytest.raises(exceptions.ValidationError): 27 | TestModel.objects.create(field=102) 28 | -------------------------------------------------------------------------------- /tests/test_minimaluser.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tfranzel/django-seriously/15bb1abc18b7d86b06860fd7c0887f45fd3a9798/tests/test_minimaluser.py -------------------------------------------------------------------------------- /tests/test_pydantic_field.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | from django.core import exceptions 5 | from django.utils import timezone 6 | from pydantic import BaseModel as PydanticBaseModel 7 | 8 | from django_seriously.utils.fields import PydanticJSONField 9 | from django_seriously.utils.models import BaseModel 10 | 11 | 12 | class X(PydanticBaseModel): 13 | a: int 14 | b: str 15 | c: datetime 16 | 17 | 18 | class PydanticFieldTestModel(BaseModel): 19 | field = PydanticJSONField(structure=X) 20 | field_list = PydanticJSONField(structure=list[X]) 21 | 22 | 23 | X_EXAMPLE_DICT = {"a": 1, "b": "foo", "c": timezone.now()} 24 | 25 | 26 | @pytest.mark.parametrize("field_input", [X_EXAMPLE_DICT, X(**X_EXAMPLE_DICT)]) 27 | @pytest.mark.django_db 28 | def test_pydantic_field_creation(field_input): 29 | instance = PydanticFieldTestModel.objects.create( 30 | field=field_input, 31 | field_list=[field_input], 32 | ) 33 | assert isinstance(instance.field, X) 34 | assert isinstance(instance.field_list[0], X) 35 | instance_retrieved = PydanticFieldTestModel.objects.get() 36 | assert isinstance(instance_retrieved.field, X) 37 | assert isinstance(instance_retrieved.field_list[0], X) 38 | 39 | 40 | @pytest.mark.django_db 41 | def test_pydantic_field_invalid_creation(): 42 | with pytest.raises(exceptions.ValidationError): 43 | PydanticFieldTestModel.objects.create(field=1, field_list=[X_EXAMPLE_DICT]) 44 | with pytest.raises(exceptions.ValidationError): 45 | PydanticFieldTestModel.objects.create(field=X_EXAMPLE_DICT, field_list=[1]) 46 | 47 | 48 | @pytest.mark.django_db 49 | def test_pydantic_field_invalid_modification(): 50 | instance = PydanticFieldTestModel.objects.create( 51 | field=X_EXAMPLE_DICT, field_list=[X_EXAMPLE_DICT] 52 | ) 53 | 54 | instance.field.a = 2 55 | instance.save() 56 | 57 | instance.field.a = "NaN" 58 | with pytest.raises(exceptions.ValidationError): 59 | instance.save() 60 | 61 | # previous validation should make this never fail 62 | instance.refresh_from_db() 63 | 64 | instance.field_list = [{"invalid": "invalid"}] 65 | with pytest.raises(exceptions.ValidationError): 66 | instance.save() 67 | -------------------------------------------------------------------------------- /tests/test_validated_field.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | from django.core import exceptions 5 | from django.utils import timezone 6 | from pydantic import BaseModel as PydanticBaseModel 7 | 8 | from django_seriously.utils.fields import ValidatedJSONField 9 | from django_seriously.utils.models import BaseModel 10 | 11 | 12 | class X(PydanticBaseModel): 13 | a: int 14 | b: str 15 | c: datetime 16 | 17 | 18 | class ValidatedFieldTestModel(BaseModel): 19 | field = ValidatedJSONField(structure=X) 20 | field_list = ValidatedJSONField(structure=list[X]) 21 | 22 | 23 | X_EXAMPLE_DICT = {"a": 1, "b": "foo", "c": timezone.now()} 24 | 25 | 26 | @pytest.mark.django_db 27 | def test_validated_field(): 28 | instance = ValidatedFieldTestModel.objects.create( 29 | field=X_EXAMPLE_DICT, field_list=[X_EXAMPLE_DICT] 30 | ) 31 | assert isinstance(instance.field, dict) 32 | assert instance.field == X_EXAMPLE_DICT 33 | assert instance.field_list[0] == X_EXAMPLE_DICT 34 | 35 | 36 | @pytest.mark.django_db 37 | def test_validated_field_validation(): 38 | with pytest.raises(exceptions.ValidationError): 39 | ValidatedFieldTestModel.objects.create(field=1, field_list=[X_EXAMPLE_DICT]) 40 | with pytest.raises(exceptions.ValidationError): 41 | ValidatedFieldTestModel.objects.create(field=X_EXAMPLE_DICT, field_list=[1]) 42 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] # type: ignore 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py311-lint, 4 | {py39}-django{3.2}-drf{3.12}, 5 | {py310}-django{4.0,4.1}-drf{3.13}, 6 | {py311}-django{4.2}-drf{3.14}, 7 | skip_missing_interpreters = true 8 | 9 | [testenv] 10 | commands = pytest 11 | setenv = 12 | PYTHONDONTWRITEBYTECODE=1 13 | passenv = 14 | CI 15 | deps = 16 | django3.2: Django>=3.2,<4.0 17 | django4.0: Django>=4.0,<4.1 18 | django4.1: Django>=4.1,<4.2 19 | django4.2: Django>=4.2,<5.0 20 | 21 | drf3.12: djangorestframework>=3.12,<3.13 22 | drf3.13: djangorestframework>=3.13,<3.14 23 | drf3.14: djangorestframework>=3.14,<3.15 24 | 25 | -r requirements/testing.txt 26 | 27 | [testenv:py311-lint] 28 | commands = 29 | black . --check 30 | isort . --check 31 | flake8 django_seriously 32 | mypy django_seriously 33 | deps = 34 | -r requirements/testing.txt 35 | -r requirements/optionals.txt 36 | 37 | [flake8] 38 | ignore = 39 | # line break before binary operator 40 | W503 41 | max-line-length = 91 42 | exclude = */migrations/* 43 | --------------------------------------------------------------------------------