107 |
|
145 |
├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── test.yaml ├── .gitignore ├── LICENSE.md ├── README.md ├── example.gif ├── magiclink ├── __init__.py ├── apps.py ├── backends.py ├── forms.py ├── helpers.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── magiclink_clear_logins.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_magiclinkunsubscribe.py │ └── __init__.py ├── models.py ├── settings.py ├── templates │ └── magiclink │ │ ├── login.html │ │ ├── login_email.html │ │ ├── login_email.txt │ │ ├── login_failed.html │ │ ├── login_sent.html │ │ └── signup.html ├── urls.py ├── utils.py └── views.py ├── manage.py ├── mypy.ini ├── poetry.lock ├── pyproject.toml ├── tests ├── __init__.py ├── fixtures.py ├── models.py ├── settings.py ├── test_backend.py ├── test_clear_logins_command.py ├── test_helpers.py ├── test_login.py ├── test_login_verify.py ├── test_logout.py ├── test_models.py ├── test_settings.py ├── test_signup.py ├── test_utils.py └── urls.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.py] 11 | indent_size = 4 12 | 13 | [*.{yml,yaml,json}] 14 | indent_size = 2 15 | 16 | [*.{js,html}] 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 50 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python: ["3.8", "3.9", "3.10", "3.11"] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Setup Python 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: ${{ matrix.python }} 23 | 24 | - name: Install tox and any other packages 25 | run: pip install tox 26 | 27 | - name: Run tox 28 | # Run tox using the version of Python in `PATH` 29 | run: tox -e python 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### OSX ### 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Icon must end with two \r 7 | Icon 8 | 9 | # Thumbnails 10 | ._* 11 | 12 | # Files that might appear on external disk 13 | .Spotlight-V100 14 | .Trashes 15 | 16 | # Directories potentially created on remote AFP share 17 | .AppleDB 18 | .AppleDesktop 19 | Network Trash Folder 20 | Temporary Items 21 | .apdisk 22 | 23 | # Editors 24 | .vscode/ 25 | 26 | ### Python ### 27 | # Byte-compiled / optimized / DLL files 28 | __pycache__/ 29 | .pyre/ 30 | *.py[cod] 31 | 32 | # C extensions 33 | *.so 34 | 35 | # Distribution / packaging 36 | .Python 37 | venv/ 38 | env/ 39 | develop-eggs/ 40 | dist/ 41 | downloads/ 42 | eggs/ 43 | lib/ 44 | lib64/ 45 | parts/ 46 | sdist/ 47 | var/ 48 | *.egg-info/ 49 | .installed.cfg 50 | *.egg 51 | .mypy_cache 52 | 53 | # PyInstaller 54 | # Usually these files are written by a python script from a template 55 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 56 | *.manifest 57 | #*.spec 58 | 59 | # Installer logs 60 | pip-log.txt 61 | pip-delete-this-directory.txt 62 | 63 | # Unit test / coverage reports 64 | htmlcov/ 65 | .tox/ 66 | .coverage 67 | .cache 68 | nosetests.xml 69 | coverage.xml 70 | .testmondata 71 | 72 | # Translations 73 | *.mo 74 | *.pot 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | ### Django ### 83 | *.log 84 | *.pot 85 | *.pyc 86 | __pycache__/ 87 | local_settings.py 88 | 89 | .env 90 | *.sqlite3 91 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matt Pye 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django MagicLink 2 | 3 | 4 | Passwordless Authentication for Django with Magic Links. 5 | 6 | This package was created with a focus on [ease of setup](#steps-to-impliment), [security](#security) and testing (coverage is currently at 100%). The idea is to use sane defaults to quickly create secure single-use token authentication for Django. 7 | 8 |  9 | 10 | 11 | ## Install 12 | 13 | ```bash 14 | pip install django-magiclink 15 | ``` 16 | 17 | ## Setup 18 | 19 | The setup of the app is simple but has a few steps and a few templates that need overriding. 20 | 21 | 1. [Install the app](#install) 22 | 1. [Configure the app](#configuration) adding urls and settings. There are also a number of [additional configuration settings](#configuration-settings) 23 | 1. [Set up the login page](#login-page) by overriding the login page template 24 | 1. [Override the login sent page HTML](#login-sent-page) 25 | 1. [Customise the login failed page](#login-failed-page) 26 | 1. [Set up the magic link email](#magic-link-email) (optional) by setting the email logo and colours. It's also possible to override the email templates 27 | 1. [Create a signup page](#signup-page) (optional) depending on your settings configuration 28 | 29 | 30 | ### Basic login flow 31 | 32 | 1. The user signs up via the sign up page (This can be skipped if `MAGICLINK_REQUIRE_SIGNUP = False`) 33 | 1. They enter their email on the login page to request a magic link 34 | 1. A magic link is sent to users email address 35 | 1. The user is redirected to a login sent page 36 | 1. The user clicks on the magic link in their email 37 | 1. The user is logged in and redirected 38 | 39 | 40 | *If you want to create a different passwordless login flow see the [Manual usage](#manual-usage) section* 41 | 42 | 43 | #### Configuration 44 | 45 | Add to the `urlpatterns` in `urls.py`: 46 | ```python 47 | urlpatterns = [ 48 | ... 49 | path('auth/', include('magiclink.urls', namespace='magiclink')), 50 | ... 51 | ] 52 | ``` 53 | 54 | Add `magiclink` to your `INSTALLED_APPS`: 55 | ```python 56 | INSTALLED_APPS = ( 57 | ... 58 | 'magiclink', 59 | ... 60 | ) 61 | ``` 62 | 63 | ```python 64 | AUTHENTICATION_BACKENDS = ( 65 | 'magiclink.backends.MagicLinkBackend', 66 | ... 67 | 'django.contrib.auth.backends.ModelBackend', 68 | ) 69 | ``` 70 | *Note: MagicLinkBackend should be placed at the top of AUTHENTICATION_BACKENDS* to ensure it is used as the primary login backend. 71 | 72 | 73 | Add the following settings to your `settings.py` (you will need to replace the template names in the below steps): 74 | ```python 75 | # Set Djangos login URL to the magiclink login page 76 | LOGIN_URL = 'magiclink:login' 77 | 78 | MAGICLINK_LOGIN_TEMPLATE_NAME = 'magiclink/login.html' 79 | MAGICLINK_LOGIN_SENT_TEMPLATE_NAME = 'magiclink/login_sent.html' 80 | MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = 'magiclink/login_failed.html' 81 | 82 | # Optional: 83 | # If this setting is set to False a user account will be created the first 84 | # time a user requests a login link. 85 | MAGICLINK_REQUIRE_SIGNUP = True 86 | MAGICLINK_SIGNUP_TEMPLATE_NAME = 'magiclink/signup.html' 87 | ``` 88 | 89 | See [additional configuration settings](#configuration-settings) for all of the different available settings. 90 | 91 | 92 | Once the app has been added to `INSTALLED_APPS` you must run the migrations for `magiclink` 93 | 94 | ```bash 95 | python manage.py migrate magiclink 96 | ``` 97 | 98 | #### Login page 99 | 100 | Each login page will need different HTML so you need to set the `MAGICLINK_LOGIN_TEMPLATE_NAME` setting to a template of your own. When overriding this template please ensure the following code is included: 101 | 102 | ```html 103 |
108 | ``` 109 | 110 | See the login docs if you want to create your own login view 111 | 112 | 113 | #### Login sent page 114 | 115 | After the user has requested a magic link, they will be redirected to a success page. The HTML for this page can be overridden using the setting `MAGICLINK_LOGIN_SENT_TEMPLATE_NAME`. It is advised you return a simple message telling the user to check their email: 116 | 117 | ```html 118 |We have sent you a magic link to your email address
120 |Please click the link to be logged in automatically
121 | ``` 122 | 123 | 124 | #### Login failed page 125 | 126 | If the user tries to use an invalid magic token they will be shown a custom error page. To override the HTML for this page you can set the `MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME` setting. If you would like to return a 404 page you can set this setting to a empty string (or any falsy value). If you would like to redirect to another page (say a custom front-end) you can use the `MAGICLINK_LOGIN_FAILED_REDIRECT` setting. 127 | 128 | The reasons for the login failing is passed through as the context variable `{{ login_error }}` 129 | 130 | To help tailor the error page and explain the possible reasons the user could not login the following context variables are provided: 131 | 132 | * `{{ login_error }}` - The reason the login failed (raised by `MagicLink.validate()`) 133 | * `{{ one_token_per_user }}` - The value of the `MAGICLINK_ONE_TOKEN_PER_USER` setting 134 | * `{{ require_same_browser }}` - The value of the `MAGICLINK_REQUIRE_SAME_BROWSER` setting 135 | * `{{ require_same_ip }}` - The value of the `MAGICLINK_REQUIRE_SAME_IP` setting 136 | * `{{ allow_superuser_login }}` - The value of the `MAGICLINK_ALLOW_SUPERUSER_LOGIN` setting 137 | * `{{ allow_staff_login }}` - The value of the `MAGICLINK_ALLOW_STAFF_LOGIN` setting 138 | 139 | 140 | For an example of this page see the [default login failed template](https://github.com/pyepye/django-magiclink/blob/master/magiclink/templates/magiclink/login_failed.html) 141 | 142 | 143 | #### Magic link email 144 | 145 | The login email which includes the magic link needs to be configured. By default, a simple HTML template is used which can be adapted to your own branding using the `MAGICLINK_EMAIL_STYLES` setting, or you can override the template (see below) 146 | 147 | This `MAGICLINK_EMAIL_STYLES` setting should be a dict with the following key values: 148 | 149 | ```python 150 | MAGICLINK_EMAIL_STYLES = { 151 | 'logo_url': 'https://example.com/logo.png', 152 | 'background-colour': '#ffffff', 153 | 'main-text-color': '#000000', 154 | 'button-background-color': '#0078be', 155 | 'button-text-color': '#ffffff', 156 | } 157 | ``` 158 | *Note: The logo URL must be a full URL. For email client support you should use either a jpeg or png.* 159 | 160 | If this email template is not to your liking you can override the email templates (one for text and one for html). To do so you need to override the `MAGICLINK_EMAIL_TEMPLATE_NAME_TEXT` and `MAGICLINK_EMAIL_TEMPLATE_NAME_HTML` settings. If you override these templates the following context variables are available: 161 | 162 | * `{{ subject }}` - The subject of the email "Your login magic link" 163 | * `{{ magiclink }}` - The magic link URL 164 | * `{{ user }}` - The full user object 165 | * `{{ expiry }}` - Datetime for when the magiclink expires 166 | * `{{ ip_address }}` - The IP address of the person who requested the magic link 167 | * `{{ created }}` - Datetime of when the magic link was created 168 | * `{{ require_same_ip }}` - The value of `MAGICLINK_REQUIRE_SAME_IP` 169 | * `{{ require_same_browser }}` - The value of `MAGICLINK_REQUIRE_SAME_BROWSER` 170 | * `{{ token_uses }}` - The value of `MAGICLINK_TOKEN_USES` 171 | 172 | 173 | #### Signup page 174 | 175 | If you want users to have to signup before being able to log in you will want to override the signup page template using the `MAGICLINK_SIGNUP_TEMPLATE_NAME` setting. This is needed when `MAGICLINK_REQUIRE_SIGNUP = True`. On successful signup the user will be sent a login email with a magic link. 176 | 177 | When overriding this template please ensure the following content is included: 178 | 179 | ```html 180 | 185 |Already have an account? Log in here
186 | ``` 187 | 188 | There are several forms made avalible in the context on this page depending on what information you want to collect: 189 | * **SignupFormEmailOnly** - Only includes an `email` field 190 | * **SignupForm** - Includes `name` and `email` fields 191 | * **SignupFormWithUsername** - Includes `username` and `email` fields 192 | * **SignupFormFull** - Includes `username`, `name` and `email` fields 193 | 194 | 195 | Like the login for the sign up flow can be overridden if you require more information from the user on signup. See the login/setup docs for more details. 196 | 197 | 198 | #### Configuration settings 199 | 200 | Below are the different settings that can be overridden. To do so place the setting into your `settings.py`. 201 | 202 | *Note: Each of the url / redirect settings can either be a URL or url name* 203 | 204 | ```python 205 | 206 | # Override the login page template. See 'Login page' in the Setup section 207 | MAGICLINK_LOGIN_TEMPLATE_NAME = 'myapp/login.html' 208 | 209 | # Override the login page template. See 'Login sent page' in the Setup section 210 | MAGICLINK_LOGIN_SENT_TEMPLATE_NAME = 'myapp/login_sent.html' 211 | 212 | # Override the template that shows when the user tries to login with a 213 | # magic link that is not valid. See 'Login failed page' in the Setup section 214 | MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = 'magiclink/login_failed.html' 215 | 216 | # If a login failed redirect is specified the user will be redirected to this 217 | # URL instead of being shown the LOGIN_FAILED_TEMPLATE 218 | MAGICLINK_LOGIN_FAILED_REDIRECT = '' 219 | 220 | # If this setting is set to False a user account will be created the first time 221 | # a user requests a login link. 222 | MAGICLINK_REQUIRE_SIGNUP = True 223 | # Override the login page template. See 'Login sent page' in the Setup section 224 | MAGICLINK_SIGNUP_TEMPLATE_NAME = 'myapp/signup.html' 225 | 226 | # Set Djangos login redirect URL to be used once the user opens the magic link 227 | # This will be used whenever a ?next parameter is not set on login 228 | LOGIN_REDIRECT_URL = '/accounts/profile/' 229 | 230 | # If a new user is created via the signup page use this setting to send them to 231 | # a different url than LOGIN_REDIRECT_URL when clicking the magic link 232 | # This will fall back to LOGIN_REDIRECT_URL 233 | MAGICLINK_SIGNUP_LOGIN_REDIRECT = '/welcome/' 234 | 235 | # Change the url a user is redirect to after requesting a magic link 236 | MAGICLINK_LOGIN_SENT_REDIRECT = 'magiclink:login_sent' 237 | 238 | # Ensure the branding of the login email is correct. This setting is not needed 239 | # if you override the `login_email.html` template 240 | MAGICLINK_EMAIL_STYLES = { 241 | 'logo_url': '', 242 | 'background-colour': '#ffffff', 243 | 'main-text-color': '#000000', 244 | 'button-background-color': '#0078be', 245 | 'button-text-color': '#ffffff', 246 | } 247 | 248 | # If you want to use your own email templates you can override the text and 249 | # html templates used with: 250 | MAGICLINK_EMAIL_TEMPLATE_NAME_TEXT = 'myapp/login_email.text' 251 | MAGICLINK_EMAIL_TEMPLATE_NAME_HTML = 'myapp/login_email.html' 252 | 253 | # How long a magic link is valid for before returning an error 254 | MAGICLINK_AUTH_TIMEOUT = 300 # In second - Default is 5 minutes 255 | 256 | # Email address is not case sensitive. If this setting is set to True all 257 | # emails addresses will be set to lowercase before any checks are run against it 258 | MAGICLINK_IGNORE_EMAIL_CASE = True 259 | 260 | # When creating a user assign their email as the username (if the User model 261 | # has a username field) 262 | MAGICLINK_EMAIL_AS_USERNAME = True 263 | 264 | # Allow superusers to login via a magic link 265 | MAGICLINK_ALLOW_SUPERUSER_LOGIN = True 266 | 267 | # Allow staff users to login via a magic link 268 | MAGICLINK_ALLOW_STAFF_LOGIN = True 269 | 270 | # Ignore the Django user model's is_active flag for login requests 271 | MAGICLINK_IGNORE_IS_ACTIVE_FLAG = True 272 | 273 | # Override the default magic link length 274 | # Warning: Overriding this setting has security implications, shorter tokens 275 | # are much more susceptible to brute force attacks* 276 | MAGICLINK_TOKEN_LENGTH = 50 277 | 278 | # Require the user email to be included in the verification link 279 | # Warning: If this is set to false tokens are more vulnerable to brute force 280 | MAGICLINK_VERIFY_INCLUDE_EMAIL = True 281 | 282 | # Ensure the user who clicked magic link used the same browser as the 283 | # initial login request. 284 | # Note: This can cause issues on devices where the default browser is 285 | # different from the browser being used by the user such as on iOS) 286 | MAGICLINK_REQUIRE_SAME_BROWSER = True 287 | 288 | # Ensure the user who clicked magic link has the same IP address as the 289 | # initial login request. 290 | MAGICLINK_REQUIRE_SAME_IP = True 291 | 292 | # Remove the last 8-bit octet of a clients IP address. 293 | # Note: This has no effect if MAGICLINK_REQUIRE_SAME_IP as no IP address 294 | # is stored 295 | MAGICLINK_ANONYMIZE_IP = True 296 | 297 | # The number of times a login token can be used before being disabled 298 | MAGICLINK_TOKEN_USES = 1 299 | 300 | # How often a user can request a new login token (basic rate limiting). 301 | MAGICLINK_LOGIN_REQUEST_TIME_LIMIT = 30 # In seconds 302 | 303 | # Disable all other tokens for a user when a new token is requested 304 | MAGICLINK_ONE_TOKEN_PER_USER = True 305 | 306 | # Include basic anti spam form fields to help stop bots. False by default 307 | # Note: IF you use the default forms you will need to add CSS to your 308 | # page / stylesheet to hide the labels for the anti spam fields. 309 | # See the login.html or signup.html for an example 310 | MAGICLINK_ANTISPAM_FORMS = False 311 | # The shortest time a user can fill out each field and submit a form without 312 | # being considered a bot. The time is per field and defaults to 1 second. 313 | # This means if the form has 3 fields and the user will need to make more than 314 | # 3 seconds to fill out a form. 315 | MAGICLINK_ANTISPAM_FIELD_TIME = 1 316 | 317 | # Override the login verify address. You must inherit from Magiclink LoginVerify 318 | # view. See Manual usage for more details 319 | MAGICLINK_LOGIN_VERIFY_URL = 'magiclink:login_verify' 320 | 321 | # If an email address has been added to the unsubscribe table but is also 322 | # assocaited with a Django user, should a login email be sent 323 | MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER = False 324 | ``` 325 | 326 | ## Magic Link cleanup 327 | 328 | Each Magic Link is a seperate row in the database. To help give the user a better warning as to why their login was not successful, magic links are not cleared even once they have expired or have been disabled. 329 | 330 | To clear old disabled magic links as well as magic links which expired over 1 week ago, you can use the `magiclink_clear_logins` management command 331 | 332 | ``` 333 | python manage.py magiclink_clear_logins 334 | ``` 335 | 336 | 337 | ## Security 338 | 339 | Using magic links can be dangerous as poorly implemented login links can be brute-forced and emails can be forwarded by accident. There are several security measures used to mitigate these risks: 340 | 341 | * The one-time password issued will be valid for 5 minutes before it expires 342 | * The user's email is specified alongside login tokens to stop URLs being brute-forced 343 | * Each login token will be at least 20 digits 344 | * The initial request and its response must take place from the same IP address 345 | * The initial request and its response must take place in the same browser 346 | * Each one-time link can only be used once 347 | * Only the last one-time link issued will be accepted. Once the latest one is issued, any others are invalidated. 348 | 349 | *Note: Each of the above settings can be overridden / changed when configuring django-magiclink* 350 | 351 | 352 | ## Unsubscribe / stopping email spam 353 | 354 | Sadly bots like to go around the internet and fill out any forms they can with random email addresses that don't belong to them. Because of this, if an email is added to the `MagicLinkUnsubscribe` model they will no longer receive a login or welcome email, even if the email has a user associated with it. This behaviour can be changed for existing users using the `MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER` setting. 355 | 356 | Adding a user to the unsubscribe model is done using the normal Django ORM create method 357 | 358 | ```python 359 | from magiclink.models import MagicLinkUnsubscribe 360 | 361 | MagicLinkUnsubscribe.objects.create(email='test@example.com') 362 | ``` 363 | 364 | If you are using the Django Magiclink login or signup functionality, the unsubscribe check happens during form validation. This means a new user will never be created if their email address has already been added to the `MagicLinkUnsubscribe` list. 365 | 366 | 367 | ## Manual usage 368 | 369 | ### Creating magiclinks 370 | 371 | django-magiclink uses a model to help create, send and validate magic links. A `create_magiclink` helper function can be used easily create a MagicLink using the correct settings: 372 | 373 | ```python 374 | from magiclink.helpers import create_magiclink 375 | 376 | # Returns newly created from magiclink.models.MagicLink instance 377 | magiclink = create_magiclink(email, request, redirect_url='') 378 | 379 | # Generates the magic link url and sends it in an email 380 | magiclink.send(request) 381 | 382 | # If you want to build the magic link from the model instance but don't want to 383 | # send the email you can you can use: 384 | magic_link_url = magiclink.generate_url(request) 385 | ``` 386 | 387 | ### Custom Login verify flow 388 | 389 | It is also possible to override the login verify flow to run your own code once the user has successfully logged in instead of a simple redirect. To do this you will need to create a new view which inherits the `magiclink.views.LoginVerify` view and overrides the `login_complete_action` method. 390 | 391 | The below example will redirect the user to a different page depending on superuser / staff status: 392 | 393 | 394 | Your own `views.py` 395 | 396 | ```python 397 | from magiclink.views import LoginVerify 398 | 399 | 400 | class CustomLoginVerify(LoginVerify): 401 | 402 | def login_complete_action(self): 403 | if self.request.user.is_superuser: 404 | url = reverse('superuser_page') 405 | elif self.request.user.is_staff: 406 | url = reverse('staff_page') 407 | else: 408 | url = reverse('normal_page') 409 | return HttpResponseRedirect(url) 410 | ``` 411 | 412 | 413 | Your own `urls.py` 414 | 415 | ```python 416 | urlpatterns = [ 417 | ... 418 | path( 419 | 'custom-login-verify/', 420 | CustomLoginVerify.as_view(), 421 | name='custom_login_verify' 422 | ), 423 | ... 424 | ] 425 | ``` 426 | 427 | 428 | `settings.py` 429 | 430 | ```python 431 | ... 432 | MAGICLINK_LOGIN_VERIFY_URL = 'custom_login_verify' 433 | ... 434 | ``` 435 | 436 | 437 | ## Upgrading 438 | 439 | A new migration was added to version `1.2.0`. If you upgrade to `1.2.0` or above from a previous version please ensure you migrate 440 | 441 | ```bash 442 | python manage.py migrate magiclink 443 | ``` 444 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyepye/django-magiclink/70a3ea9e2c493e036042ab16ca85d841a14384ad/example.gif -------------------------------------------------------------------------------- /magiclink/__init__.py: -------------------------------------------------------------------------------- 1 | from django import get_version 2 | from packaging import version 3 | 4 | """ 5 | To stop waring: 6 | RemovedInDjango41Warning: 'magiclink' defines 7 | default_app_config = 'magiclink.apps.MagiclinkConfig'. Django now detects 8 | this configuration automatically. You can remove default_app_config. 9 | """ 10 | if version.parse(get_version()) < version.parse('3.2'): # pragma: no cover 11 | default_app_config = 'magiclink.apps.MagiclinkConfig' 12 | -------------------------------------------------------------------------------- /magiclink/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MagiclinkConfig(AppConfig): 5 | name = 'magiclink' 6 | -------------------------------------------------------------------------------- /magiclink/backends.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.http import HttpRequest 5 | 6 | from . import settings 7 | from .models import MagicLink, MagicLinkError 8 | 9 | User = get_user_model() 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | class MagicLinkBackend(): 14 | 15 | def authenticate( 16 | self, 17 | request: HttpRequest, 18 | token: str = '', 19 | email: str = '', 20 | ): 21 | log.debug(f'MagicLink authenticate token: {token} - email: {email}') 22 | 23 | if not token: 24 | log.warning('Token missing from authentication') 25 | return 26 | 27 | if settings.VERIFY_INCLUDE_EMAIL and not email: 28 | log.warning('Email address not supplied with token') 29 | return 30 | 31 | try: 32 | magiclink = MagicLink.objects.get(token=token) 33 | except MagicLink.DoesNotExist: 34 | log.warning(f'MagicLink with token "{token}" not found') 35 | return 36 | 37 | if magiclink.disabled: 38 | log.warning(f'MagicLink "{magiclink.pk}" is disabled') 39 | return 40 | 41 | try: 42 | user = magiclink.validate(request, email) 43 | except MagicLinkError as error: 44 | log.warning(error) 45 | return 46 | 47 | magiclink.used() 48 | log.info(f'{user} authenticated via MagicLink') 49 | return user 50 | 51 | def get_user(self, user_id): 52 | try: 53 | return User.objects.get(pk=user_id) 54 | except User.DoesNotExist: 55 | return 56 | -------------------------------------------------------------------------------- /magiclink/forms.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | 3 | from django import forms 4 | from django.contrib.auth import get_user_model 5 | from django.core.exceptions import ValidationError 6 | 7 | from . import settings 8 | from .models import MagicLinkUnsubscribe 9 | 10 | User = get_user_model() 11 | 12 | 13 | class AntiSpam(forms.Form): 14 | url = forms.CharField( 15 | required=False, 16 | label='url (antispam field, don\'t fill out)', 17 | widget=forms.TextInput( 18 | attrs={ 19 | 'autocomplete': 'off', 20 | 'tabindex': '-1', 21 | 'style': 'display: none !important', 22 | } 23 | ), 24 | ) 25 | load_time = forms.CharField( 26 | label='ALT (antispam field, don\'t fill out)', 27 | widget=forms.TextInput( 28 | attrs={ 29 | 'autocomplete': 'off', 30 | 'tabindex': '-1', 31 | 'style': 'display: none !important', 32 | } 33 | ), 34 | ) 35 | 36 | def clean_url(self) -> str: 37 | url: str = self.cleaned_data.get('url', '') 38 | if url: 39 | raise ValidationError('url should be empty') 40 | return url 41 | 42 | def clean_load_time(self) -> float: 43 | cleaned_load_time: str = self.cleaned_data.get('load_time', '') 44 | try: 45 | load_time = float(cleaned_load_time) 46 | except ValueError: 47 | raise ValidationError('Invalid value') 48 | 49 | shown_field_count = 0 50 | spam_fields = ['load_time', 'url'] 51 | for name, field in self.fields.items(): 52 | if field.widget.input_type != 'hidden' and name not in spam_fields: 53 | shown_field_count += 1 54 | 55 | submit_threshold = shown_field_count * settings.ANTISPAM_FIELD_TIME 56 | if (time() - load_time) < submit_threshold: 57 | raise ValidationError('Form filled out too fast - bot detected') 58 | return load_time 59 | 60 | def __init__(self, *args, **kwargs): 61 | super().__init__(*args, **kwargs) 62 | self.fields['load_time'].initial = time() 63 | if not settings.ANTISPAM_FORMS: 64 | del self.fields['url'] 65 | del self.fields['load_time'] 66 | 67 | 68 | class LoginForm(AntiSpam): 69 | email = forms.EmailField( 70 | widget=forms.EmailInput(attrs={ 71 | 'autofocus': 'autofocus', 'placeholder': 'Enter your email' 72 | }) 73 | ) 74 | 75 | def clean_email(self) -> str: 76 | email = self.cleaned_data['email'] 77 | 78 | if settings.EMAIL_IGNORE_CASE: 79 | email = email.lower() 80 | 81 | try: 82 | user = User.objects.get(email=email) 83 | except User.DoesNotExist: 84 | if settings.REQUIRE_SIGNUP: 85 | error = 'We could not find a user with that email address' 86 | raise forms.ValidationError(error) 87 | else: 88 | is_active = getattr(user, 'is_active', True) 89 | if not settings.IGNORE_IS_ACTIVE_FLAG and not is_active: 90 | raise forms.ValidationError('This user has been deactivated') 91 | 92 | if not settings.IGNORE_UNSUBSCRIBE_IF_USER: 93 | try: 94 | MagicLinkUnsubscribe.objects.get(email=email) 95 | error = 'Email address is on the unsubscribe list' 96 | raise forms.ValidationError(error) 97 | except MagicLinkUnsubscribe.DoesNotExist: 98 | pass 99 | 100 | return email 101 | 102 | 103 | class SignupFormEmailOnly(AntiSpam): 104 | form_name = forms.CharField( 105 | initial='SignupFormEmailOnly', widget=forms.HiddenInput() 106 | ) 107 | email = forms.EmailField( 108 | widget=forms.EmailInput(attrs={'placeholder': 'Enter your email'}) 109 | ) 110 | 111 | def clean_email(self) -> str: 112 | email = self.cleaned_data['email'] 113 | 114 | if settings.EMAIL_IGNORE_CASE: 115 | email = email.lower() 116 | 117 | try: 118 | MagicLinkUnsubscribe.objects.get(email=email) 119 | error = 'Email address is on the unsubscribe list' 120 | raise forms.ValidationError(error) 121 | except MagicLinkUnsubscribe.DoesNotExist: 122 | pass 123 | 124 | try: 125 | user = User.objects.get(email=email) 126 | except User.DoesNotExist: 127 | return email 128 | else: 129 | error = 'Email address is already linked to an account' 130 | is_active = getattr(user, 'is_active', True) 131 | if not settings.IGNORE_IS_ACTIVE_FLAG and not is_active: 132 | error = 'This user has been deactivated' 133 | raise forms.ValidationError(error) 134 | 135 | 136 | class SignupForm(SignupFormEmailOnly): 137 | form_name = forms.CharField( 138 | initial='SignupForm', widget=forms.HiddenInput() 139 | ) 140 | name = forms.CharField( 141 | widget=forms.TextInput(attrs={'placeholder': 'Enter your name'}) 142 | ) 143 | field_order = ['form_name', 'name', 'email'] 144 | 145 | 146 | class SignupFormWithUsername(SignupFormEmailOnly): 147 | form_name = forms.CharField( 148 | initial='SignupFormWithUsername', widget=forms.HiddenInput() 149 | ) 150 | username = forms.CharField( 151 | widget=forms.TextInput(attrs={'placeholder': 'Enter your username'}) 152 | ) 153 | field_order = ['form_name', 'username', 'email'] 154 | 155 | def clean_username(self) -> str: 156 | username = self.cleaned_data['username'] 157 | users = User.objects.filter(username=username) 158 | if users: 159 | raise forms.ValidationError( 160 | 'username is already linked to an account' 161 | ) 162 | return username 163 | 164 | 165 | class SignupFormFull(SignupForm, SignupFormWithUsername): 166 | form_name = forms.CharField( 167 | initial='SignupFormFull', widget=forms.HiddenInput() 168 | ) 169 | field_order = ['form_name', 'username', 'name', 'email'] 170 | -------------------------------------------------------------------------------- /magiclink/helpers.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from uuid import uuid4 3 | 4 | from django.conf import settings as djsettings 5 | from django.contrib.auth import get_user_model 6 | from django.db.utils import IntegrityError 7 | from django.http import HttpRequest 8 | from django.utils import timezone 9 | from django.utils.crypto import get_random_string 10 | 11 | from . import settings 12 | from .models import MagicLink, MagicLinkError 13 | from .utils import get_client_ip, get_url_path 14 | 15 | 16 | def create_magiclink( 17 | email: str, 18 | request: HttpRequest, 19 | redirect_url: str = '', 20 | ) -> MagicLink: 21 | if settings.EMAIL_IGNORE_CASE: 22 | email = email.lower() 23 | 24 | limit = timezone.now() - timedelta(seconds=settings.LOGIN_REQUEST_TIME_LIMIT) # NOQA: E501 25 | over_limit = MagicLink.objects.filter(email=email, created__gte=limit) 26 | if over_limit: 27 | raise MagicLinkError('Too many magic login requests') 28 | 29 | if settings.ONE_TOKEN_PER_USER: 30 | magic_links = MagicLink.objects.filter(email=email, disabled=False) 31 | magic_links.update(disabled=True) 32 | 33 | if not redirect_url: 34 | redirect_url = get_url_path(djsettings.LOGIN_REDIRECT_URL) 35 | 36 | client_ip = None 37 | if settings.REQUIRE_SAME_IP: 38 | client_ip = get_client_ip(request) 39 | if client_ip and settings.ANONYMIZE_IP: 40 | client_ip = client_ip[:client_ip.rfind('.')+1] + '0' 41 | 42 | expiry = timezone.now() + timedelta(seconds=settings.AUTH_TIMEOUT) 43 | magic_link = MagicLink.objects.create( 44 | email=email, 45 | token=get_random_string(length=settings.TOKEN_LENGTH), 46 | expiry=expiry, 47 | redirect_url=redirect_url, 48 | cookie_value=str(uuid4()), 49 | ip_address=client_ip, 50 | ) 51 | return magic_link 52 | 53 | 54 | def get_or_create_user( 55 | email: str, 56 | username: str = '', 57 | first_name: str = '', 58 | last_name: str = '' 59 | ): 60 | User = get_user_model() 61 | 62 | if settings.EMAIL_IGNORE_CASE: 63 | email = email.lower() 64 | 65 | try: 66 | user = User.objects.get(email=email) 67 | except User.DoesNotExist: 68 | pass 69 | else: 70 | return user 71 | 72 | user_fields = [field.name for field in User._meta.get_fields()] 73 | 74 | if not username and settings.EMAIL_AS_USERNAME: 75 | username = email 76 | 77 | user_details = {'email': email} 78 | if 'first_name' in user_fields and first_name: 79 | user_details['first_name'] = first_name 80 | if 'last_name' in user_fields and last_name: 81 | user_details['last_name'] = last_name 82 | if 'full_name' in user_fields: 83 | user_details['full_name'] = f'{first_name} {last_name}'.strip() 84 | if 'name' in user_fields: 85 | user_details['name'] = f'{first_name} {last_name}'.strip() 86 | 87 | if 'username' in user_fields and not username: 88 | # Set a random username if we need to set a username and 89 | # EMAIL_AS_USERNAME is False 90 | created = False 91 | while not created: 92 | user_details['username'] = get_random_string(length=10) 93 | try: 94 | user = User.objects.create(**user_details) 95 | created = True 96 | except IntegrityError: # pragma: no cover 97 | pass 98 | else: 99 | if 'username' in user_fields: 100 | user_details['username'] = username 101 | user = User.objects.create(**user_details) 102 | 103 | return user 104 | -------------------------------------------------------------------------------- /magiclink/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyepye/django-magiclink/70a3ea9e2c493e036042ab16ca85d841a14384ad/magiclink/management/__init__.py -------------------------------------------------------------------------------- /magiclink/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyepye/django-magiclink/70a3ea9e2c493e036042ab16ca85d841a14384ad/magiclink/management/commands/__init__.py -------------------------------------------------------------------------------- /magiclink/management/commands/magiclink_clear_logins.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.utils import timezone 5 | 6 | from ... import settings 7 | from ...models import MagicLink 8 | 9 | 10 | class Command(BaseCommand): 11 | help = 'Delete disabled Magic Links' 12 | 13 | def handle(self, *args, **options): 14 | limit = timezone.now() - timedelta(seconds=settings.LOGIN_REQUEST_TIME_LIMIT) # NOQA: E501 15 | week_before = limit - timedelta(days=7) 16 | 17 | for magic_links in MagicLink.objects.filter(expiry__lte=week_before): 18 | magic_links.disable() 19 | 20 | disabled_links = MagicLink.objects.filter(disabled=True) 21 | self.stdout.write(f'Deleting {disabled_links.count()} magic links') 22 | 23 | for magic_link in MagicLink.objects.filter(disabled=True): 24 | magic_link.delete() 25 | -------------------------------------------------------------------------------- /magiclink/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-15 17:17 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='MagicLink', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('email', models.EmailField(max_length=254)), 19 | ('token', models.TextField()), 20 | ('expiry', models.DateTimeField()), 21 | ('redirect_url', models.TextField()), 22 | ('disabled', models.BooleanField(default=False)), 23 | ('times_used', models.IntegerField(default=0)), 24 | ('cookie_value', models.TextField(blank=True)), 25 | ('ip_address', models.GenericIPAddressField(null=True)), 26 | ('created', models.DateTimeField(auto_now_add=True)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /magiclink/migrations/0002_magiclinkunsubscribe.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2022-01-23 19:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('magiclink', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='MagicLinkUnsubscribe', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('email', models.EmailField(max_length=254)), 18 | ], 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /magiclink/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyepye/django-magiclink/70a3ea9e2c493e036042ab16ca85d841a14384ad/magiclink/migrations/__init__.py -------------------------------------------------------------------------------- /magiclink/models.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode, urljoin 2 | 3 | from django.conf import settings as djsettings 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.models import AbstractUser 6 | from django.contrib.sites.shortcuts import get_current_site 7 | from django.core.mail import send_mail 8 | from django.db import models 9 | from django.http import HttpRequest 10 | from django.template.loader import render_to_string 11 | from django.urls import reverse 12 | from django.utils import timezone 13 | 14 | from . import settings 15 | from .utils import get_client_ip 16 | 17 | User = get_user_model() 18 | 19 | 20 | class MagicLinkError(Exception): 21 | pass 22 | 23 | 24 | class MagicLink(models.Model): 25 | email = models.EmailField() 26 | token = models.TextField() 27 | expiry = models.DateTimeField() 28 | redirect_url = models.TextField() 29 | disabled = models.BooleanField(default=False) 30 | times_used = models.IntegerField(default=0) 31 | cookie_value = models.TextField(blank=True) 32 | ip_address = models.GenericIPAddressField(null=True) 33 | created = models.DateTimeField(auto_now_add=True) 34 | 35 | def __str__(self): 36 | return f'{self.email} - {self.expiry}' 37 | 38 | def used(self) -> None: 39 | self.times_used += 1 40 | if self.times_used >= settings.TOKEN_USES: 41 | self.disabled = True 42 | self.save() 43 | 44 | def disable(self) -> None: 45 | self.times_used += 1 46 | self.disabled = True 47 | self.save() 48 | 49 | def generate_url(self, request: HttpRequest) -> str: 50 | url_path = reverse(settings.LOGIN_VERIFY_URL) 51 | 52 | params = {'token': self.token} 53 | if settings.VERIFY_INCLUDE_EMAIL: 54 | params['email'] = self.email 55 | query = urlencode(params) 56 | 57 | url_path = f'{url_path}?{query}' 58 | domain = get_current_site(request).domain 59 | scheme = request.is_secure() and 'https' or 'http' 60 | url = urljoin(f'{scheme}://{domain}', url_path) 61 | return url 62 | 63 | def send(self, request: HttpRequest) -> None: 64 | user = User.objects.get(email=self.email) 65 | 66 | if not settings.IGNORE_UNSUBSCRIBE_IF_USER: 67 | try: 68 | MagicLinkUnsubscribe.objects.get(email=self.email) 69 | raise MagicLinkError( 70 | 'Email address is on the unsubscribe list') 71 | except MagicLinkUnsubscribe.DoesNotExist: 72 | pass 73 | 74 | context = { 75 | 'subject': settings.EMAIL_SUBJECT, 76 | 'user': user, 77 | 'magiclink': self.generate_url(request), 78 | 'expiry': self.expiry, 79 | 'ip_address': self.ip_address, 80 | 'created': self.created, 81 | 'require_same_ip': settings.REQUIRE_SAME_IP, 82 | 'require_same_browser': settings.REQUIRE_SAME_BROWSER, 83 | 'token_uses': settings.TOKEN_USES, 84 | 'style': settings.EMAIL_STYLES, 85 | } 86 | plain = render_to_string(settings.EMAIL_TEMPLATE_NAME_TEXT, context) 87 | html = render_to_string(settings.EMAIL_TEMPLATE_NAME_HTML, context) 88 | send_mail( 89 | subject=settings.EMAIL_SUBJECT, 90 | message=plain, 91 | recipient_list=[user.email], 92 | from_email=djsettings.DEFAULT_FROM_EMAIL, 93 | html_message=html, 94 | ) 95 | 96 | def validate( 97 | self, 98 | request: HttpRequest, 99 | email: str = '', 100 | ) -> AbstractUser: 101 | if settings.EMAIL_IGNORE_CASE and email: 102 | email = email.lower() 103 | 104 | if settings.VERIFY_INCLUDE_EMAIL and self.email != email: 105 | raise MagicLinkError('Email address does not match') 106 | 107 | if timezone.now() > self.expiry: 108 | self.disable() 109 | raise MagicLinkError('Magic link has expired') 110 | 111 | if settings.REQUIRE_SAME_IP: 112 | client_ip = get_client_ip(request) 113 | if client_ip and settings.ANONYMIZE_IP: 114 | client_ip = client_ip[:client_ip.rfind('.')+1] + '0' 115 | if self.ip_address != client_ip: 116 | self.disable() 117 | raise MagicLinkError('IP address is different from the IP ' 118 | 'address used to request the magic link') 119 | 120 | if settings.REQUIRE_SAME_BROWSER: 121 | cookie_name = f'magiclink{self.pk}' 122 | if self.cookie_value != request.COOKIES.get(cookie_name): 123 | self.disable() 124 | raise MagicLinkError('Browser is different from the browser ' 125 | 'used to request the magic link') 126 | 127 | if self.times_used >= settings.TOKEN_USES: 128 | self.disable() 129 | raise MagicLinkError('Magic link has been used too many times') 130 | 131 | user = User.objects.get(email=self.email) 132 | 133 | if not settings.ALLOW_SUPERUSER_LOGIN and user.is_superuser: 134 | self.disable() 135 | raise MagicLinkError( 136 | 'You can not login to a super user account using a magic link') 137 | 138 | if not settings.ALLOW_STAFF_LOGIN and user.is_staff: 139 | self.disable() 140 | raise MagicLinkError( 141 | 'You can not login to a staff account using a magic link') 142 | 143 | return user 144 | 145 | 146 | class MagicLinkUnsubscribe(models.Model): 147 | email = models.EmailField() 148 | -------------------------------------------------------------------------------- /magiclink/settings.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: E501 2 | import warnings 3 | 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | 7 | LOGIN_SENT_REDIRECT = getattr(settings, 'MAGICLINK_LOGIN_SENT_REDIRECT', 'magiclink:login_sent') 8 | 9 | LOGIN_TEMPLATE_NAME = getattr(settings, 'MAGICLINK_LOGIN_TEMPLATE_NAME', 'magiclink/login.html') 10 | LOGIN_SENT_TEMPLATE_NAME = getattr(settings, 'MAGICLINK_LOGIN_SENT_TEMPLATE_NAME', 'magiclink/login_sent.html') 11 | LOGIN_FAILED_TEMPLATE_NAME = getattr(settings, 'MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME', 'magiclink/login_failed.html') 12 | # If LOGIN_FAILED_REDIRECT has a value the user will be redirected to this 13 | # URL instead of being shown the LOGIN_FAILED_TEMPLATE 14 | LOGIN_FAILED_REDIRECT = getattr(settings, 'MAGICLINK_LOGIN_FAILED_REDIRECT', '') 15 | 16 | # If this setting is set to False a user account will be created the first time 17 | # a user requests a login link. 18 | REQUIRE_SIGNUP = getattr(settings, 'MAGICLINK_REQUIRE_SIGNUP', True) 19 | if not isinstance(REQUIRE_SIGNUP, bool): 20 | raise ImproperlyConfigured('"MAGICLINK_REQUIRE_SIGNUP" must be a boolean') 21 | SIGNUP_LOGIN_REDIRECT = getattr(settings, 'MAGICLINK_SIGNUP_LOGIN_REDIRECT', '') 22 | 23 | SIGNUP_TEMPLATE_NAME = getattr(settings, 'MAGICLINK_SIGNUP_TEMPLATE_NAME', 'magiclink/signup.html') 24 | 25 | try: 26 | TOKEN_LENGTH = int(getattr(settings, 'MAGICLINK_TOKEN_LENGTH', 50)) 27 | except ValueError: 28 | raise ImproperlyConfigured('"MAGICLINK_TOKEN_LENGTH" must be an integer') 29 | else: 30 | if TOKEN_LENGTH < 20: 31 | warning = ('Shorter MAGICLINK_TOKEN_LENGTH values make your login more' 32 | 'sussptable to brute force attacks') 33 | warnings.warn(warning, RuntimeWarning) 34 | 35 | try: 36 | # In seconds 37 | AUTH_TIMEOUT = int(getattr(settings, 'MAGICLINK_AUTH_TIMEOUT', 300)) 38 | except ValueError: 39 | raise ImproperlyConfigured('"MAGICLINK_AUTH_TIMEOUT" must be an integer') 40 | 41 | try: 42 | TOKEN_USES = int(getattr(settings, 'MAGICLINK_TOKEN_USES', 1)) 43 | except ValueError: 44 | raise ImproperlyConfigured('"MAGICLINK_TOKEN_USES" must be an integer') 45 | 46 | EMAIL_IGNORE_CASE = getattr(settings, 'MAGICLINK_EMAIL_IGNORE_CASE', True) 47 | if not isinstance(EMAIL_IGNORE_CASE, bool): 48 | raise ImproperlyConfigured('"MAGICLINK_EMAIL_IGNORE_CASE" must be a boolean') 49 | 50 | EMAIL_AS_USERNAME = getattr(settings, 'MAGICLINK_EMAIL_AS_USERNAME', True) 51 | if not isinstance(EMAIL_AS_USERNAME, bool): 52 | raise ImproperlyConfigured('"MAGICLINK_EMAIL_AS_USERNAME" must be a boolean') 53 | 54 | ALLOW_SUPERUSER_LOGIN = getattr(settings, 'MAGICLINK_ALLOW_SUPERUSER_LOGIN', True) 55 | if not isinstance(ALLOW_SUPERUSER_LOGIN, bool): 56 | raise ImproperlyConfigured('"MAGICLINK_ALLOW_SUPERUSER_LOGIN" must be a boolean') 57 | 58 | ALLOW_STAFF_LOGIN = getattr(settings, 'MAGICLINK_ALLOW_STAFF_LOGIN', True) 59 | if not isinstance(ALLOW_STAFF_LOGIN, bool): 60 | raise ImproperlyConfigured('"MAGICLINK_ALLOW_STAFF_LOGIN" must be a boolean') 61 | 62 | IGNORE_IS_ACTIVE_FLAG = getattr(settings, 'MAGICLINK_IGNORE_IS_ACTIVE_FLAG', False) 63 | if not isinstance(IGNORE_IS_ACTIVE_FLAG, bool): 64 | raise ImproperlyConfigured('"MAGICLINK_IGNORE_IS_ACTIVE_FLAG" must be a boolean') 65 | 66 | VERIFY_INCLUDE_EMAIL = getattr(settings, 'MAGICLINK_VERIFY_INCLUDE_EMAIL', True) 67 | if not isinstance(VERIFY_INCLUDE_EMAIL, bool): 68 | raise ImproperlyConfigured('"MAGICLINK_VERIFY_INCLUDE_EMAIL" must be a boolean') 69 | 70 | REQUIRE_SAME_BROWSER = getattr(settings, 'MAGICLINK_REQUIRE_SAME_BROWSER', True) 71 | if not isinstance(REQUIRE_SAME_BROWSER, bool): 72 | raise ImproperlyConfigured('"MAGICLINK_REQUIRE_SAME_BROWSER" must be a boolean') 73 | 74 | REQUIRE_SAME_IP = getattr(settings, 'MAGICLINK_REQUIRE_SAME_IP', True) 75 | if not isinstance(REQUIRE_SAME_IP, bool): 76 | raise ImproperlyConfigured('"MAGICLINK_REQUIRE_SAME_IP" must be a boolean') 77 | 78 | ANONYMIZE_IP = getattr(settings, 'MAGICLINK_ANONYMIZE_IP', True) 79 | if not isinstance(ANONYMIZE_IP, bool): 80 | raise ImproperlyConfigured('"MAGICLINK_ANONYMIZE_IP" must be a boolean') 81 | 82 | ONE_TOKEN_PER_USER = getattr(settings, 'MAGICLINK_ONE_TOKEN_PER_USER', True) 83 | if not isinstance(ONE_TOKEN_PER_USER, bool): 84 | raise ImproperlyConfigured('"MAGICLINK_ONE_TOKEN_PER_USER" must be a boolean') 85 | 86 | try: 87 | LOGIN_REQUEST_TIME_LIMIT = int(getattr(settings, 'MAGICLINK_LOGIN_REQUEST_TIME_LIMIT', 30)) # In seconds 88 | except ValueError: 89 | raise ImproperlyConfigured('"MAGICLINK_LOGIN_REQUEST_TIME_LIMIT" must be an integer') 90 | 91 | 92 | EMAIL_STYLES = { 93 | 'logo_url': '', 94 | 'background_color': '#ffffff', 95 | 'main_text_color': '#000000', 96 | 'button_background_color': '#0078be', 97 | 'button_text_color': '#ffffff', 98 | } 99 | EMAIL_STYLES = getattr(settings, 'MAGICLINK_EMAIL_STYLES', EMAIL_STYLES) 100 | if EMAIL_STYLES and not isinstance(EMAIL_STYLES, dict): 101 | raise ImproperlyConfigured('"MAGICLINK_EMAIL_STYLES" must be a dict') 102 | 103 | EMAIL_SUBJECT = getattr(settings, 'MAGICLINK_EMAIL_SUBJECT', 'Your login magic link') 104 | EMAIL_TEMPLATE_NAME_TEXT = getattr(settings, 'MAGICLINK_EMAIL_TEMPLATE_NAME_TEXT', 'magiclink/login_email.txt') 105 | EMAIL_TEMPLATE_NAME_HTML = getattr(settings, 'MAGICLINK_EMAIL_TEMPLATE_NAME_HTML', 'magiclink/login_email.html') 106 | 107 | 108 | ANTISPAM_FORMS = getattr(settings, 'MAGICLINK_ANTISPAM_FORMS', False) 109 | if not isinstance(ANTISPAM_FORMS, bool): 110 | raise ImproperlyConfigured('"MAGICLINK_ANTISPAM_FORMS" must be a boolean') 111 | ANTISPAM_FIELD_TIME = getattr(settings, 'MAGICLINK_ANTISPAM_FIELD_TIME', 1) 112 | if ANTISPAM_FIELD_TIME is not None: 113 | try: 114 | ANTISPAM_FIELD_TIME = float(ANTISPAM_FIELD_TIME) 115 | except ValueError: 116 | raise ImproperlyConfigured('"MAGICLINK_ANTISPAM_FIELD_TIME" must be a float') 117 | 118 | LOGIN_VERIFY_URL = getattr(settings, 'MAGICLINK_LOGIN_VERIFY_URL', 'magiclink:login_verify') 119 | 120 | IGNORE_UNSUBSCRIBE_IF_USER = getattr(settings, 'MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER', False) 121 | if not isinstance(IGNORE_UNSUBSCRIBE_IF_USER, bool): 122 | raise ImproperlyConfigured('"MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER" must be a boolean') -------------------------------------------------------------------------------- /magiclink/templates/magiclink/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |Don't have an account? Sign up here
26 | {% endif %} 27 | 28 |If you are seeing this you have not yet overridden the 'MAGICLINK_LOGIN_TEMPLATE_NAME' setting yet.
29 |Please see the README on Github for more details on setting up django-magiclink correctly
30 | 31 | 32 | -------------------------------------------------------------------------------- /magiclink/templates/magiclink/login_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |94 | |
95 |
96 |
97 |
98 |
152 |
99 |
|
153 | 154 | |
It was not possible to log you in due to:
14 |{{ login_error }}
15 | 16 |There are several possible ways for logins to fail with the current Django MagicLink settings:
17 |If you are seeing this you have not yet overridden the 'MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME' setting yet.
36 |Please see the README on Github for more details on setting up django-magiclink correctly
37 | 38 | 39 | -------------------------------------------------------------------------------- /magiclink/templates/magiclink/login_sent.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
14 | We have sent you a magic link to your email address
15 | Click the link to login automatically
16 |
If you are seeing this you have not yet overridden the 'MAGICLINK_LOGIN_SENT_TEMPLATE_NAME' setting yet.
19 |Please see the README on Github for more details on setting up django-magiclink correctly
20 | 21 | 22 | -------------------------------------------------------------------------------- /magiclink/templates/magiclink/signup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |Already have an account? Log in here
25 | 26 |If you are seeing this you have not yet overridden the 'MAGICLINK_SIGNUP_TEMPLATE_NAME' setting yet.
27 |Please see the README on Github for more details on setting up django-magiclink correctly
28 | 29 | 30 | -------------------------------------------------------------------------------- /magiclink/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import Login, LoginSent, LoginVerify, Logout, Signup 4 | 5 | app_name = "magiclink" 6 | 7 | urlpatterns = [ 8 | path('login/', Login.as_view(), name='login'), 9 | path('login/sent/', LoginSent.as_view(), name='login_sent'), 10 | path('signup/', Signup.as_view(), name='signup'), 11 | path('login/verify/', LoginVerify.as_view(), name='login_verify'), 12 | path('logout/', Logout.as_view(), name='logout'), 13 | ] 14 | -------------------------------------------------------------------------------- /magiclink/utils.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from django.urls import reverse 3 | from django.urls.exceptions import NoReverseMatch 4 | 5 | 6 | def get_client_ip(request: HttpRequest) -> str: 7 | x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') 8 | if x_forwarded_for: 9 | ip = x_forwarded_for.split(',')[0].strip() 10 | else: 11 | ip = request.META.get('REMOTE_ADDR') 12 | return ip 13 | 14 | 15 | def get_url_path(url: str) -> str: 16 | """ 17 | url can either be a url name or a url path. First try and reverse a URL, 18 | if this does not exist then assume it's a url path 19 | """ 20 | try: 21 | return reverse(url) 22 | except NoReverseMatch: 23 | return url 24 | -------------------------------------------------------------------------------- /magiclink/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings as django_settings 4 | from django.contrib.auth import authenticate, get_user_model, login, logout 5 | from django.http import Http404, HttpResponse, HttpResponseRedirect 6 | from django.utils.decorators import method_decorator 7 | from django.views.decorators.cache import never_cache 8 | from django.views.generic import TemplateView 9 | from django.views.generic.base import RedirectView 10 | 11 | try: 12 | from django.utils.http import url_has_allowed_host_and_scheme as safe_url 13 | except ImportError: # pragma: no cover 14 | from django.utils.http import is_safe_url as safe_url # type: ignore 15 | 16 | from django.views.decorators.csrf import csrf_protect 17 | 18 | from . import settings 19 | from .forms import ( 20 | LoginForm, SignupForm, SignupFormEmailOnly, SignupFormFull, 21 | SignupFormWithUsername 22 | ) 23 | from .helpers import create_magiclink, get_or_create_user 24 | from .models import MagicLink, MagicLinkError 25 | from .utils import get_url_path 26 | 27 | User = get_user_model() 28 | log = logging.getLogger(__name__) 29 | 30 | 31 | @method_decorator(csrf_protect, name='dispatch') 32 | class Login(TemplateView): 33 | template_name = settings.LOGIN_TEMPLATE_NAME 34 | 35 | def get(self, request, *args, **kwargs): 36 | context = self.get_context_data(**kwargs) 37 | context['login_form'] = LoginForm() 38 | context['require_signup'] = settings.REQUIRE_SIGNUP 39 | return self.render_to_response(context) 40 | 41 | def post(self, request, *args, **kwargs): 42 | logout(request) 43 | context = self.get_context_data(**kwargs) 44 | context['require_signup'] = settings.REQUIRE_SIGNUP 45 | form = LoginForm(request.POST) 46 | if not form.is_valid(): 47 | context['login_form'] = form 48 | return self.render_to_response(context) 49 | 50 | email = form.cleaned_data['email'] 51 | if not settings.REQUIRE_SIGNUP: 52 | get_or_create_user(email) 53 | 54 | redirect_url = self.login_redirect_url(request.GET.get('next', '')) 55 | try: 56 | magiclink = create_magiclink( 57 | email, request, redirect_url=redirect_url 58 | ) 59 | except MagicLinkError as e: 60 | form.add_error('email', str(e)) 61 | context['login_form'] = form 62 | return self.render_to_response(context) 63 | 64 | magiclink.send(request) 65 | 66 | sent_url = get_url_path(settings.LOGIN_SENT_REDIRECT) 67 | response = HttpResponseRedirect(sent_url) 68 | if settings.REQUIRE_SAME_BROWSER: 69 | cookie_name = f'magiclink{magiclink.pk}' 70 | response.set_cookie(cookie_name, magiclink.cookie_value) 71 | log.info(f'Cookie {cookie_name} set for {email}') 72 | return response 73 | 74 | def login_redirect_url(self, next_url) -> str: 75 | redirect_url = '' 76 | allowed_hosts = django_settings.ALLOWED_HOSTS 77 | if '*' in allowed_hosts: 78 | allowed_hosts = [self.request.get_host()] 79 | url_is_safe = safe_url( 80 | url=next_url, 81 | allowed_hosts=allowed_hosts, 82 | require_https=self.request.is_secure(), 83 | ) 84 | if url_is_safe: 85 | redirect_url = next_url 86 | return redirect_url 87 | 88 | 89 | class LoginSent(TemplateView): 90 | template_name = settings.LOGIN_SENT_TEMPLATE_NAME 91 | 92 | 93 | @method_decorator(never_cache, name='dispatch') 94 | class LoginVerify(TemplateView): 95 | template_name = settings.LOGIN_FAILED_TEMPLATE_NAME 96 | 97 | def get(self, request, *args, **kwargs): 98 | token = request.GET.get('token') 99 | email = request.GET.get('email') 100 | user = authenticate(request, token=token, email=email) 101 | if not user: 102 | if settings.LOGIN_FAILED_REDIRECT: 103 | redirect_url = get_url_path(settings.LOGIN_FAILED_REDIRECT) 104 | return HttpResponseRedirect(redirect_url) 105 | 106 | if not settings.LOGIN_FAILED_TEMPLATE_NAME: 107 | raise Http404() 108 | 109 | context = self.get_context_data(**kwargs) 110 | # The below settings are left in for backward compatibility 111 | context['ONE_TOKEN_PER_USER'] = settings.ONE_TOKEN_PER_USER 112 | context['REQUIRE_SAME_BROWSER'] = settings.REQUIRE_SAME_BROWSER 113 | context['REQUIRE_SAME_IP'] = settings.REQUIRE_SAME_IP 114 | context['ALLOW_SUPERUSER_LOGIN'] = settings.ALLOW_SUPERUSER_LOGIN # NOQA: E501 115 | context['ALLOW_STAFF_LOGIN'] = settings.ALLOW_STAFF_LOGIN 116 | 117 | try: 118 | magiclink = MagicLink.objects.get(token=token) 119 | except MagicLink.DoesNotExist: 120 | error = 'A magic link with that token could not be found' 121 | context['login_error'] = error 122 | return self.render_to_response(context) 123 | 124 | try: 125 | magiclink.validate(request, email) 126 | except MagicLinkError as error: 127 | context['login_error'] = str(error) 128 | 129 | return self.render_to_response(context) 130 | 131 | login(request, user) 132 | log.info(f'Login successful for {email}') 133 | 134 | response = self.login_complete_action() 135 | if settings.REQUIRE_SAME_BROWSER: 136 | magiclink = MagicLink.objects.get(token=token) 137 | cookie_name = f'magiclink{magiclink.pk}' 138 | response.delete_cookie(cookie_name, magiclink.cookie_value) 139 | return response 140 | 141 | def login_complete_action(self) -> HttpResponse: 142 | token = self.request.GET.get('token') 143 | magiclink = MagicLink.objects.get(token=token) 144 | return HttpResponseRedirect(magiclink.redirect_url) 145 | 146 | 147 | @method_decorator(csrf_protect, name='dispatch') 148 | class Signup(TemplateView): 149 | template_name = settings.SIGNUP_TEMPLATE_NAME 150 | 151 | def get(self, request, *args, **kwargs): 152 | context = self.get_context_data(**kwargs) 153 | context['SignupForm'] = SignupForm() 154 | context['SignupFormEmailOnly'] = SignupFormEmailOnly() 155 | context['SignupFormWithUsername'] = SignupFormWithUsername() 156 | context['SignupFormFull'] = SignupFormFull() 157 | return self.render_to_response(context) 158 | 159 | def post(self, request, *args, **kwargs): 160 | logout(request) 161 | context = self.get_context_data(**kwargs) 162 | form_name = request.POST.get('form_name') 163 | from_list = [ 164 | 'SignupForm, SignupFormEmailOnly', 'SignupFormWithUsername', 165 | 'SignupFormFull', 166 | ] 167 | forms = __import__('magiclink.forms', fromlist=from_list) 168 | try: 169 | SignupForm = getattr(forms, form_name) 170 | except AttributeError: 171 | return HttpResponseRedirect(self.request.path_info) 172 | 173 | form = SignupForm(request.POST) 174 | if not form.is_valid(): 175 | context[form_name] = form 176 | return self.render_to_response(context) 177 | 178 | email = form.cleaned_data['email'] 179 | full_name = form.cleaned_data.get('name', '') 180 | try: 181 | first_name, last_name = full_name.split(' ', 1) 182 | except ValueError: 183 | first_name = full_name 184 | last_name = '' 185 | 186 | get_or_create_user( 187 | email=email, 188 | username=form.cleaned_data.get('username', ''), 189 | first_name=first_name, 190 | last_name=last_name 191 | ) 192 | default_signup_redirect = get_url_path(settings.SIGNUP_LOGIN_REDIRECT) 193 | next_url = request.GET.get('next', default_signup_redirect) 194 | magiclink = create_magiclink(email, request, redirect_url=next_url) 195 | magiclink.send(request) 196 | 197 | sent_url = get_url_path(settings.LOGIN_SENT_REDIRECT) 198 | response = HttpResponseRedirect(sent_url) 199 | if settings.REQUIRE_SAME_BROWSER: 200 | cookie_name = f'magiclink{magiclink.pk}' 201 | response.set_cookie(cookie_name, magiclink.cookie_value) 202 | log.info(f'Cookie {cookie_name} set for {email}') 203 | return response 204 | 205 | 206 | class Logout(RedirectView): 207 | 208 | def get(self, request, *args, **kwargs): 209 | logout(self.request) 210 | 211 | next_page = request.GET.get('next') 212 | if next_page: 213 | return HttpResponseRedirect(next_page) 214 | 215 | redirect_url = get_url_path(django_settings.LOGOUT_REDIRECT_URL) 216 | return HttpResponseRedirect(redirect_url) 217 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Mypy configuration: 3 | # https://mypy.readthedocs.io/en/latest/config_file.html 4 | allow_redefinition = False 5 | check_untyped_defs = True 6 | disallow_untyped_decorators = True 7 | disallow_any_explicit = True 8 | disallow_any_generics = True 9 | disallow_untyped_calls = True 10 | ignore_errors = False 11 | ignore_missing_imports = True 12 | implicit_reexport = False 13 | local_partial_types = True 14 | strict_optional = True 15 | strict_equality = True 16 | no_implicit_optional = True 17 | warn_unused_ignores = True 18 | warn_redundant_casts = True 19 | warn_unused_configs = True 20 | warn_unreachable = True 21 | warn_no_return = True 22 | 23 | plugins = 24 | mypy_django_plugin.main 25 | 26 | [mypy.plugins.django-stubs] 27 | django_settings_module = tests.settings 28 | 29 | [mypy-magiclink.migrations.*] 30 | # Django migrations should not produce any errors: 31 | ignore_errors = True 32 | 33 | [mypy-magiclink.models] 34 | # FIXME: remove this line, when `django-stubs` will stop 35 | # using `Any` inside. 36 | disallow_any_explicit = False 37 | 38 | [mypy-magiclink.views] 39 | disallow_untyped_calls = False 40 | 41 | [mypy-tests.models] 42 | # FIXME: remove this line, when `django-stubs` will stop 43 | # using `Any` inside. 44 | disallow_any_explicit = False 45 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asgiref" 5 | version = "3.4.1" 6 | description = "ASGI specs, helper code, and adapters" 7 | optional = false 8 | python-versions = ">=3.6" 9 | files = [ 10 | {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"}, 11 | {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"}, 12 | ] 13 | 14 | [package.dependencies] 15 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 16 | 17 | [package.extras] 18 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 19 | 20 | [[package]] 21 | name = "atomicwrites" 22 | version = "1.4.1" 23 | description = "Atomic file writes." 24 | optional = false 25 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 26 | files = [ 27 | {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, 28 | ] 29 | 30 | [[package]] 31 | name = "attrs" 32 | version = "22.2.0" 33 | description = "Classes Without Boilerplate" 34 | optional = false 35 | python-versions = ">=3.6" 36 | files = [ 37 | {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, 38 | {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, 39 | ] 40 | 41 | [package.extras] 42 | cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] 43 | dev = ["attrs[docs,tests]"] 44 | docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] 45 | tests = ["attrs[tests-no-zope]", "zope.interface"] 46 | tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] 47 | 48 | [[package]] 49 | name = "colorama" 50 | version = "0.4.5" 51 | description = "Cross-platform colored terminal text." 52 | optional = false 53 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 54 | files = [ 55 | {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, 56 | {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, 57 | ] 58 | 59 | [[package]] 60 | name = "coverage" 61 | version = "6.2" 62 | description = "Code coverage measurement for Python" 63 | optional = false 64 | python-versions = ">=3.6" 65 | files = [ 66 | {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"}, 67 | {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"}, 68 | {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"}, 69 | {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"}, 70 | {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"}, 71 | {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"}, 72 | {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"}, 73 | {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"}, 74 | {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"}, 75 | {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"}, 76 | {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"}, 77 | {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"}, 78 | {file = "coverage-6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f467bbb837691ab5a8ca359199d3429a11a01e6dfb3d9dcc676dc035ca93c0a9"}, 79 | {file = "coverage-6.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2641f803ee9f95b1f387f3e8f3bf28d83d9b69a39e9911e5bfee832bea75240d"}, 80 | {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1219d760ccfafc03c0822ae2e06e3b1248a8e6d1a70928966bafc6838d3c9e48"}, 81 | {file = "coverage-6.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a2b5b52be0a8626fcbffd7e689781bf8c2ac01613e77feda93d96184949a98e"}, 82 | {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8e2c35a4c1f269704e90888e56f794e2d9c0262fb0c1b1c8c4ee44d9b9e77b5d"}, 83 | {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5d6b09c972ce9200264c35a1d53d43ca55ef61836d9ec60f0d44273a31aa9f17"}, 84 | {file = "coverage-6.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e3db840a4dee542e37e09f30859f1612da90e1c5239a6a2498c473183a50e781"}, 85 | {file = "coverage-6.2-cp36-cp36m-win32.whl", hash = "sha256:4e547122ca2d244f7c090fe3f4b5a5861255ff66b7ab6d98f44a0222aaf8671a"}, 86 | {file = "coverage-6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:01774a2c2c729619760320270e42cd9e797427ecfddd32c2a7b639cdc481f3c0"}, 87 | {file = "coverage-6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb8b8ee99b3fffe4fd86f4c81b35a6bf7e4462cba019997af2fe679365db0c49"}, 88 | {file = "coverage-6.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:619346d57c7126ae49ac95b11b0dc8e36c1dd49d148477461bb66c8cf13bb521"}, 89 | {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0a7726f74ff63f41e95ed3a89fef002916c828bb5fcae83b505b49d81a066884"}, 90 | {file = "coverage-6.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cfd9386c1d6f13b37e05a91a8583e802f8059bebfccde61a418c5808dea6bbfa"}, 91 | {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:17e6c11038d4ed6e8af1407d9e89a2904d573be29d51515f14262d7f10ef0a64"}, 92 | {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c254b03032d5a06de049ce8bca8338a5185f07fb76600afff3c161e053d88617"}, 93 | {file = "coverage-6.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dca38a21e4423f3edb821292e97cec7ad38086f84313462098568baedf4331f8"}, 94 | {file = "coverage-6.2-cp37-cp37m-win32.whl", hash = "sha256:600617008aa82032ddeace2535626d1bc212dfff32b43989539deda63b3f36e4"}, 95 | {file = "coverage-6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bf154ba7ee2fd613eb541c2bc03d3d9ac667080a737449d1a3fb342740eb1a74"}, 96 | {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"}, 97 | {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"}, 98 | {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"}, 99 | {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"}, 100 | {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"}, 101 | {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"}, 102 | {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"}, 103 | {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"}, 104 | {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"}, 105 | {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"}, 106 | {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"}, 107 | {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"}, 108 | {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"}, 109 | {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"}, 110 | {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"}, 111 | {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"}, 112 | {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, 113 | ] 114 | 115 | [package.dependencies] 116 | tomli = {version = "*", optional = true, markers = "extra == \"toml\""} 117 | 118 | [package.extras] 119 | toml = ["tomli"] 120 | 121 | [[package]] 122 | name = "distlib" 123 | version = "0.3.6" 124 | description = "Distribution utilities" 125 | optional = false 126 | python-versions = "*" 127 | files = [ 128 | {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, 129 | {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, 130 | ] 131 | 132 | [[package]] 133 | name = "django" 134 | version = "3.2.20" 135 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 136 | optional = false 137 | python-versions = ">=3.6" 138 | files = [ 139 | {file = "Django-3.2.20-py3-none-any.whl", hash = "sha256:a477ab326ae7d8807dc25c186b951ab8c7648a3a23f9497763c37307a2b5ef87"}, 140 | {file = "Django-3.2.20.tar.gz", hash = "sha256:dec2a116787b8e14962014bf78e120bba454135108e1af9e9b91ade7b2964c40"}, 141 | ] 142 | 143 | [package.dependencies] 144 | asgiref = ">=3.3.2,<4" 145 | pytz = "*" 146 | sqlparse = ">=0.2.2" 147 | 148 | [package.extras] 149 | argon2 = ["argon2-cffi (>=19.1.0)"] 150 | bcrypt = ["bcrypt"] 151 | 152 | [[package]] 153 | name = "django-stubs" 154 | version = "1.9.0" 155 | description = "Mypy stubs for Django" 156 | optional = false 157 | python-versions = ">=3.6" 158 | files = [ 159 | {file = "django-stubs-1.9.0.tar.gz", hash = "sha256:664843091636a917faf5256d028476559dc360fdef9050b6df87ab61b21607bf"}, 160 | {file = "django_stubs-1.9.0-py3-none-any.whl", hash = "sha256:59c9f81af64d214b1954eaf90f037778c8d2b9c2de946a3cda177fefcf588fbd"}, 161 | ] 162 | 163 | [package.dependencies] 164 | django = "*" 165 | django-stubs-ext = ">=0.3.0" 166 | mypy = ">=0.910" 167 | toml = "*" 168 | types-pytz = "*" 169 | types-PyYAML = "*" 170 | typing-extensions = "*" 171 | 172 | [[package]] 173 | name = "django-stubs-ext" 174 | version = "0.5.0" 175 | description = "Monkey-patching and extensions for django-stubs" 176 | optional = false 177 | python-versions = ">=3.6" 178 | files = [ 179 | {file = "django-stubs-ext-0.5.0.tar.gz", hash = "sha256:9bd7418376ab00b7f88d6d56be9fece85bfa0c7c348ac621155fa4d7a91146f2"}, 180 | {file = "django_stubs_ext-0.5.0-py3-none-any.whl", hash = "sha256:c5d8db53d29c756e7e3d0820a5a079a43bc38d8fab0e1b8bd5df2f3366c54b5a"}, 181 | ] 182 | 183 | [package.dependencies] 184 | django = "*" 185 | typing-extensions = "*" 186 | 187 | [[package]] 188 | name = "filelock" 189 | version = "3.4.1" 190 | description = "A platform independent file lock." 191 | optional = false 192 | python-versions = ">=3.6" 193 | files = [ 194 | {file = "filelock-3.4.1-py3-none-any.whl", hash = "sha256:a4bc51381e01502a30e9f06dd4fa19a1712eab852b6fb0f84fd7cce0793d8ca3"}, 195 | {file = "filelock-3.4.1.tar.gz", hash = "sha256:0f12f552b42b5bf60dba233710bf71337d35494fc8bdd4fd6d9f6d082ad45e06"}, 196 | ] 197 | 198 | [package.extras] 199 | docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] 200 | testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] 201 | 202 | [[package]] 203 | name = "flake8" 204 | version = "4.0.1" 205 | description = "the modular source code checker: pep8 pyflakes and co" 206 | optional = false 207 | python-versions = ">=3.6" 208 | files = [ 209 | {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, 210 | {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, 211 | ] 212 | 213 | [package.dependencies] 214 | importlib-metadata = {version = "<4.3", markers = "python_version < \"3.8\""} 215 | mccabe = ">=0.6.0,<0.7.0" 216 | pycodestyle = ">=2.8.0,<2.9.0" 217 | pyflakes = ">=2.4.0,<2.5.0" 218 | 219 | [[package]] 220 | name = "freezegun" 221 | version = "1.2.2" 222 | description = "Let your Python tests travel through time" 223 | optional = false 224 | python-versions = ">=3.6" 225 | files = [ 226 | {file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"}, 227 | {file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"}, 228 | ] 229 | 230 | [package.dependencies] 231 | python-dateutil = ">=2.7" 232 | 233 | [[package]] 234 | name = "importlib-metadata" 235 | version = "4.2.0" 236 | description = "Read metadata from Python packages" 237 | optional = false 238 | python-versions = ">=3.6" 239 | files = [ 240 | {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, 241 | {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, 242 | ] 243 | 244 | [package.dependencies] 245 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 246 | zipp = ">=0.5" 247 | 248 | [package.extras] 249 | docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] 250 | testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] 251 | 252 | [[package]] 253 | name = "importlib-resources" 254 | version = "5.4.0" 255 | description = "Read resources from Python packages" 256 | optional = false 257 | python-versions = ">=3.6" 258 | files = [ 259 | {file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"}, 260 | {file = "importlib_resources-5.4.0.tar.gz", hash = "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"}, 261 | ] 262 | 263 | [package.dependencies] 264 | zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} 265 | 266 | [package.extras] 267 | docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] 268 | testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] 269 | 270 | [[package]] 271 | name = "iniconfig" 272 | version = "1.1.1" 273 | description = "iniconfig: brain-dead simple config-ini parsing" 274 | optional = false 275 | python-versions = "*" 276 | files = [ 277 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 278 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 279 | ] 280 | 281 | [[package]] 282 | name = "isort" 283 | version = "5.8.0" 284 | description = "A Python utility / library to sort Python imports." 285 | optional = false 286 | python-versions = ">=3.6,<4.0" 287 | files = [ 288 | {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"}, 289 | {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"}, 290 | ] 291 | 292 | [package.extras] 293 | colors = ["colorama (>=0.4.3,<0.5.0)"] 294 | pipfile-deprecated-finder = ["pipreqs", "requirementslib"] 295 | requirements-deprecated-finder = ["pip-api", "pipreqs"] 296 | 297 | [[package]] 298 | name = "mccabe" 299 | version = "0.6.1" 300 | description = "McCabe checker, plugin for flake8" 301 | optional = false 302 | python-versions = "*" 303 | files = [ 304 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 305 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 306 | ] 307 | 308 | [[package]] 309 | name = "mypy" 310 | version = "0.971" 311 | description = "Optional static typing for Python" 312 | optional = false 313 | python-versions = ">=3.6" 314 | files = [ 315 | {file = "mypy-0.971-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c"}, 316 | {file = "mypy-0.971-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:98e02d56ebe93981c41211c05adb630d1d26c14195d04d95e49cd97dbc046dc5"}, 317 | {file = "mypy-0.971-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:19830b7dba7d5356d3e26e2427a2ec91c994cd92d983142cbd025ebe81d69cf3"}, 318 | {file = "mypy-0.971-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:02ef476f6dcb86e6f502ae39a16b93285fef97e7f1ff22932b657d1ef1f28655"}, 319 | {file = "mypy-0.971-cp310-cp310-win_amd64.whl", hash = "sha256:25c5750ba5609a0c7550b73a33deb314ecfb559c350bb050b655505e8aed4103"}, 320 | {file = "mypy-0.971-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d3348e7eb2eea2472db611486846742d5d52d1290576de99d59edeb7cd4a42ca"}, 321 | {file = "mypy-0.971-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3fa7a477b9900be9b7dd4bab30a12759e5abe9586574ceb944bc29cddf8f0417"}, 322 | {file = "mypy-0.971-cp36-cp36m-win_amd64.whl", hash = "sha256:2ad53cf9c3adc43cf3bea0a7d01a2f2e86db9fe7596dfecb4496a5dda63cbb09"}, 323 | {file = "mypy-0.971-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:855048b6feb6dfe09d3353466004490b1872887150c5bb5caad7838b57328cc8"}, 324 | {file = "mypy-0.971-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:23488a14a83bca6e54402c2e6435467a4138785df93ec85aeff64c6170077fb0"}, 325 | {file = "mypy-0.971-cp37-cp37m-win_amd64.whl", hash = "sha256:4b21e5b1a70dfb972490035128f305c39bc4bc253f34e96a4adf9127cf943eb2"}, 326 | {file = "mypy-0.971-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9796a2ba7b4b538649caa5cecd398d873f4022ed2333ffde58eaf604c4d2cb27"}, 327 | {file = "mypy-0.971-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5a361d92635ad4ada1b1b2d3630fc2f53f2127d51cf2def9db83cba32e47c856"}, 328 | {file = "mypy-0.971-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b793b899f7cf563b1e7044a5c97361196b938e92f0a4343a5d27966a53d2ec71"}, 329 | {file = "mypy-0.971-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d1ea5d12c8e2d266b5fb8c7a5d2e9c0219fedfeb493b7ed60cd350322384ac27"}, 330 | {file = "mypy-0.971-cp38-cp38-win_amd64.whl", hash = "sha256:23c7ff43fff4b0df93a186581885c8512bc50fc4d4910e0f838e35d6bb6b5e58"}, 331 | {file = "mypy-0.971-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f7656b69974a6933e987ee8ffb951d836272d6c0f81d727f1d0e2696074d9e6"}, 332 | {file = "mypy-0.971-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d2022bfadb7a5c2ef410d6a7c9763188afdb7f3533f22a0a32be10d571ee4bbe"}, 333 | {file = "mypy-0.971-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef943c72a786b0f8d90fd76e9b39ce81fb7171172daf84bf43eaf937e9f220a9"}, 334 | {file = "mypy-0.971-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d744f72eb39f69312bc6c2abf8ff6656973120e2eb3f3ec4f758ed47e414a4bf"}, 335 | {file = "mypy-0.971-cp39-cp39-win_amd64.whl", hash = "sha256:77a514ea15d3007d33a9e2157b0ba9c267496acf12a7f2b9b9f8446337aac5b0"}, 336 | {file = "mypy-0.971-py3-none-any.whl", hash = "sha256:0d054ef16b071149917085f51f89555a576e2618d5d9dd70bd6eea6410af3ac9"}, 337 | {file = "mypy-0.971.tar.gz", hash = "sha256:40b0f21484238269ae6a57200c807d80debc6459d444c0489a102d7c6a75fa56"}, 338 | ] 339 | 340 | [package.dependencies] 341 | mypy-extensions = ">=0.4.3" 342 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 343 | typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} 344 | typing-extensions = ">=3.10" 345 | 346 | [package.extras] 347 | dmypy = ["psutil (>=4.0)"] 348 | python2 = ["typed-ast (>=1.4.0,<2)"] 349 | reports = ["lxml"] 350 | 351 | [[package]] 352 | name = "mypy-extensions" 353 | version = "1.0.0" 354 | description = "Type system extensions for programs checked with the mypy type checker." 355 | optional = false 356 | python-versions = ">=3.5" 357 | files = [ 358 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 359 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 360 | ] 361 | 362 | [[package]] 363 | name = "packaging" 364 | version = "21.3" 365 | description = "Core utilities for Python packages" 366 | optional = false 367 | python-versions = ">=3.6" 368 | files = [ 369 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 370 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 371 | ] 372 | 373 | [package.dependencies] 374 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 375 | 376 | [[package]] 377 | name = "platformdirs" 378 | version = "2.4.0" 379 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 380 | optional = false 381 | python-versions = ">=3.6" 382 | files = [ 383 | {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, 384 | {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, 385 | ] 386 | 387 | [package.extras] 388 | docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] 389 | test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] 390 | 391 | [[package]] 392 | name = "pluggy" 393 | version = "1.0.0" 394 | description = "plugin and hook calling mechanisms for python" 395 | optional = false 396 | python-versions = ">=3.6" 397 | files = [ 398 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 399 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 400 | ] 401 | 402 | [package.dependencies] 403 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 404 | 405 | [package.extras] 406 | dev = ["pre-commit", "tox"] 407 | testing = ["pytest", "pytest-benchmark"] 408 | 409 | [[package]] 410 | name = "py" 411 | version = "1.11.0" 412 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 413 | optional = false 414 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 415 | files = [ 416 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 417 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 418 | ] 419 | 420 | [[package]] 421 | name = "pycodestyle" 422 | version = "2.8.0" 423 | description = "Python style guide checker" 424 | optional = false 425 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 426 | files = [ 427 | {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, 428 | {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, 429 | ] 430 | 431 | [[package]] 432 | name = "pyflakes" 433 | version = "2.4.0" 434 | description = "passive checker of Python programs" 435 | optional = false 436 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 437 | files = [ 438 | {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, 439 | {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, 440 | ] 441 | 442 | [[package]] 443 | name = "pyparsing" 444 | version = "3.0.7" 445 | description = "Python parsing module" 446 | optional = false 447 | python-versions = ">=3.6" 448 | files = [ 449 | {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, 450 | {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, 451 | ] 452 | 453 | [package.extras] 454 | diagrams = ["jinja2", "railroad-diagrams"] 455 | 456 | [[package]] 457 | name = "pytest" 458 | version = "7.0.1" 459 | description = "pytest: simple powerful testing with Python" 460 | optional = false 461 | python-versions = ">=3.6" 462 | files = [ 463 | {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"}, 464 | {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"}, 465 | ] 466 | 467 | [package.dependencies] 468 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 469 | attrs = ">=19.2.0" 470 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 471 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 472 | iniconfig = "*" 473 | packaging = "*" 474 | pluggy = ">=0.12,<2.0" 475 | py = ">=1.8.2" 476 | tomli = ">=1.0.0" 477 | 478 | [package.extras] 479 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 480 | 481 | [[package]] 482 | name = "pytest-cov" 483 | version = "4.0.0" 484 | description = "Pytest plugin for measuring coverage." 485 | optional = false 486 | python-versions = ">=3.6" 487 | files = [ 488 | {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, 489 | {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, 490 | ] 491 | 492 | [package.dependencies] 493 | coverage = {version = ">=5.2.1", extras = ["toml"]} 494 | pytest = ">=4.6" 495 | 496 | [package.extras] 497 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 498 | 499 | [[package]] 500 | name = "pytest-django" 501 | version = "4.5.2" 502 | description = "A Django plugin for pytest." 503 | optional = false 504 | python-versions = ">=3.5" 505 | files = [ 506 | {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, 507 | {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, 508 | ] 509 | 510 | [package.dependencies] 511 | pytest = ">=5.4.0" 512 | 513 | [package.extras] 514 | docs = ["sphinx", "sphinx-rtd-theme"] 515 | testing = ["Django", "django-configurations (>=2.0)"] 516 | 517 | [[package]] 518 | name = "pytest-freezegun" 519 | version = "0.4.2" 520 | description = "Wrap tests with fixtures in freeze_time" 521 | optional = false 522 | python-versions = "*" 523 | files = [ 524 | {file = "pytest-freezegun-0.4.2.zip", hash = "sha256:19c82d5633751bf3ec92caa481fb5cffaac1787bd485f0df6436fd6242176949"}, 525 | {file = "pytest_freezegun-0.4.2-py2.py3-none-any.whl", hash = "sha256:5318a6bfb8ba4b709c8471c94d0033113877b3ee02da5bfcd917c1889cde99a7"}, 526 | ] 527 | 528 | [package.dependencies] 529 | freezegun = ">0.3" 530 | pytest = ">=3.0.0" 531 | 532 | [[package]] 533 | name = "pytest-mock" 534 | version = "3.6.1" 535 | description = "Thin-wrapper around the mock package for easier use with pytest" 536 | optional = false 537 | python-versions = ">=3.6" 538 | files = [ 539 | {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, 540 | {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, 541 | ] 542 | 543 | [package.dependencies] 544 | pytest = ">=5.0" 545 | 546 | [package.extras] 547 | dev = ["pre-commit", "pytest-asyncio", "tox"] 548 | 549 | [[package]] 550 | name = "python-dateutil" 551 | version = "2.8.2" 552 | description = "Extensions to the standard Python datetime module" 553 | optional = false 554 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 555 | files = [ 556 | {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, 557 | {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, 558 | ] 559 | 560 | [package.dependencies] 561 | six = ">=1.5" 562 | 563 | [[package]] 564 | name = "pytz" 565 | version = "2023.3" 566 | description = "World timezone definitions, modern and historical" 567 | optional = false 568 | python-versions = "*" 569 | files = [ 570 | {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, 571 | {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, 572 | ] 573 | 574 | [[package]] 575 | name = "six" 576 | version = "1.16.0" 577 | description = "Python 2 and 3 compatibility utilities" 578 | optional = false 579 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 580 | files = [ 581 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 582 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 583 | ] 584 | 585 | [[package]] 586 | name = "sqlparse" 587 | version = "0.4.4" 588 | description = "A non-validating SQL parser." 589 | optional = false 590 | python-versions = ">=3.5" 591 | files = [ 592 | {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, 593 | {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, 594 | ] 595 | 596 | [package.extras] 597 | dev = ["build", "flake8"] 598 | doc = ["sphinx"] 599 | test = ["pytest", "pytest-cov"] 600 | 601 | [[package]] 602 | name = "toml" 603 | version = "0.10.2" 604 | description = "Python Library for Tom's Obvious, Minimal Language" 605 | optional = false 606 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 607 | files = [ 608 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 609 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 610 | ] 611 | 612 | [[package]] 613 | name = "tomli" 614 | version = "2.0.1" 615 | description = "A lil' TOML parser" 616 | optional = false 617 | python-versions = ">=3.7" 618 | files = [ 619 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 620 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 621 | ] 622 | 623 | [[package]] 624 | name = "tox" 625 | version = "3.28.0" 626 | description = "tox is a generic virtualenv management and test command line tool" 627 | optional = false 628 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 629 | files = [ 630 | {file = "tox-3.28.0-py2.py3-none-any.whl", hash = "sha256:57b5ab7e8bb3074edc3c0c0b4b192a4f3799d3723b2c5b76f1fa9f2d40316eea"}, 631 | {file = "tox-3.28.0.tar.gz", hash = "sha256:d0d28f3fe6d6d7195c27f8b054c3e99d5451952b54abdae673b71609a581f640"}, 632 | ] 633 | 634 | [package.dependencies] 635 | colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} 636 | filelock = ">=3.0.0" 637 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 638 | packaging = ">=14" 639 | pluggy = ">=0.12.0" 640 | py = ">=1.4.17" 641 | six = ">=1.14.0" 642 | toml = {version = ">=0.10.2", markers = "python_version <= \"3.6\""} 643 | tomli = {version = ">=2.0.1", markers = "python_version >= \"3.7\" and python_version < \"3.11\""} 644 | virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" 645 | 646 | [package.extras] 647 | docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] 648 | testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)"] 649 | 650 | [[package]] 651 | name = "typed-ast" 652 | version = "1.5.5" 653 | description = "a fork of Python 2 and 3 ast modules with type comment support" 654 | optional = false 655 | python-versions = ">=3.6" 656 | files = [ 657 | {file = "typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b"}, 658 | {file = "typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686"}, 659 | {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769"}, 660 | {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41b7a686ce653e06c2609075d397ebd5b969d821b9797d029fccd71fdec8e04"}, 661 | {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5fe83a9a44c4ce67c796a1b466c270c1272e176603d5e06f6afbc101a572859d"}, 662 | {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d5c0c112a74c0e5db2c75882a0adf3133adedcdbfd8cf7c9d6ed77365ab90a1d"}, 663 | {file = "typed_ast-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:e1a976ed4cc2d71bb073e1b2a250892a6e968ff02aa14c1f40eba4f365ffec02"}, 664 | {file = "typed_ast-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c631da9710271cb67b08bd3f3813b7af7f4c69c319b75475436fcab8c3d21bee"}, 665 | {file = "typed_ast-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b445c2abfecab89a932b20bd8261488d574591173d07827c1eda32c457358b18"}, 666 | {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc95ffaaab2be3b25eb938779e43f513e0e538a84dd14a5d844b8f2932593d88"}, 667 | {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61443214d9b4c660dcf4b5307f15c12cb30bdfe9588ce6158f4a005baeb167b2"}, 668 | {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6eb936d107e4d474940469e8ec5b380c9b329b5f08b78282d46baeebd3692dc9"}, 669 | {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e48bf27022897577d8479eaed64701ecaf0467182448bd95759883300ca818c8"}, 670 | {file = "typed_ast-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:83509f9324011c9a39faaef0922c6f720f9623afe3fe220b6d0b15638247206b"}, 671 | {file = "typed_ast-1.5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:44f214394fc1af23ca6d4e9e744804d890045d1643dd7e8229951e0ef39429b5"}, 672 | {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:118c1ce46ce58fda78503eae14b7664163aa735b620b64b5b725453696f2a35c"}, 673 | {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4919b808efa61101456e87f2d4c75b228f4e52618621c77f1ddcaae15904fa"}, 674 | {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fc2b8c4e1bc5cd96c1a823a885e6b158f8451cf6f5530e1829390b4d27d0807f"}, 675 | {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:16f7313e0a08c7de57f2998c85e2a69a642e97cb32f87eb65fbfe88381a5e44d"}, 676 | {file = "typed_ast-1.5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2b946ef8c04f77230489f75b4b5a4a6f24c078be4aed241cfabe9cbf4156e7e5"}, 677 | {file = "typed_ast-1.5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2188bc33d85951ea4ddad55d2b35598b2709d122c11c75cffd529fbc9965508e"}, 678 | {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0635900d16ae133cab3b26c607586131269f88266954eb04ec31535c9a12ef1e"}, 679 | {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57bfc3cf35a0f2fdf0a88a3044aafaec1d2f24d8ae8cd87c4f58d615fb5b6311"}, 680 | {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fe58ef6a764de7b4b36edfc8592641f56e69b7163bba9f9c8089838ee596bfb2"}, 681 | {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d09d930c2d1d621f717bb217bf1fe2584616febb5138d9b3e8cdd26506c3f6d4"}, 682 | {file = "typed_ast-1.5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d40c10326893ecab8a80a53039164a224984339b2c32a6baf55ecbd5b1df6431"}, 683 | {file = "typed_ast-1.5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd946abf3c31fb50eee07451a6aedbfff912fcd13cf357363f5b4e834cc5e71a"}, 684 | {file = "typed_ast-1.5.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ed4a1a42df8a3dfb6b40c3d2de109e935949f2f66b19703eafade03173f8f437"}, 685 | {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045f9930a1550d9352464e5149710d56a2aed23a2ffe78946478f7b5416f1ede"}, 686 | {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:381eed9c95484ceef5ced626355fdc0765ab51d8553fec08661dce654a935db4"}, 687 | {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bfd39a41c0ef6f31684daff53befddae608f9daf6957140228a08e51f312d7e6"}, 688 | {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8c524eb3024edcc04e288db9541fe1f438f82d281e591c548903d5b77ad1ddd4"}, 689 | {file = "typed_ast-1.5.5-cp38-cp38-win_amd64.whl", hash = "sha256:7f58fabdde8dcbe764cef5e1a7fcb440f2463c1bbbec1cf2a86ca7bc1f95184b"}, 690 | {file = "typed_ast-1.5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:042eb665ff6bf020dd2243307d11ed626306b82812aba21836096d229fdc6a10"}, 691 | {file = "typed_ast-1.5.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:622e4a006472b05cf6ef7f9f2636edc51bda670b7bbffa18d26b255269d3d814"}, 692 | {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1efebbbf4604ad1283e963e8915daa240cb4bf5067053cf2f0baadc4d4fb51b8"}, 693 | {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0aefdd66f1784c58f65b502b6cf8b121544680456d1cebbd300c2c813899274"}, 694 | {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:48074261a842acf825af1968cd912f6f21357316080ebaca5f19abbb11690c8a"}, 695 | {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:429ae404f69dc94b9361bb62291885894b7c6fb4640d561179548c849f8492ba"}, 696 | {file = "typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155"}, 697 | {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, 698 | ] 699 | 700 | [[package]] 701 | name = "types-pytz" 702 | version = "2023.3.0.0" 703 | description = "Typing stubs for pytz" 704 | optional = false 705 | python-versions = "*" 706 | files = [ 707 | {file = "types-pytz-2023.3.0.0.tar.gz", hash = "sha256:ecdc70d543aaf3616a7e48631543a884f74205f284cefd6649ddf44c6a820aac"}, 708 | {file = "types_pytz-2023.3.0.0-py3-none-any.whl", hash = "sha256:4fc2a7fbbc315f0b6630e0b899fd6c743705abe1094d007b0e612d10da15e0f3"}, 709 | ] 710 | 711 | [[package]] 712 | name = "types-pyyaml" 713 | version = "6.0.12.10" 714 | description = "Typing stubs for PyYAML" 715 | optional = false 716 | python-versions = "*" 717 | files = [ 718 | {file = "types-PyYAML-6.0.12.10.tar.gz", hash = "sha256:ebab3d0700b946553724ae6ca636ea932c1b0868701d4af121630e78d695fc97"}, 719 | {file = "types_PyYAML-6.0.12.10-py3-none-any.whl", hash = "sha256:662fa444963eff9b68120d70cda1af5a5f2aa57900003c2006d7626450eaae5f"}, 720 | ] 721 | 722 | [[package]] 723 | name = "typing-extensions" 724 | version = "4.1.1" 725 | description = "Backported and Experimental Type Hints for Python 3.6+" 726 | optional = false 727 | python-versions = ">=3.6" 728 | files = [ 729 | {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, 730 | {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, 731 | ] 732 | 733 | [[package]] 734 | name = "virtualenv" 735 | version = "20.16.2" 736 | description = "Virtual Python Environment builder" 737 | optional = false 738 | python-versions = ">=3.6" 739 | files = [ 740 | {file = "virtualenv-20.16.2-py2.py3-none-any.whl", hash = "sha256:635b272a8e2f77cb051946f46c60a54ace3cb5e25568228bd6b57fc70eca9ff3"}, 741 | {file = "virtualenv-20.16.2.tar.gz", hash = "sha256:0ef5be6d07181946891f5abc8047fda8bc2f0b4b9bf222c64e6e8963baee76db"}, 742 | ] 743 | 744 | [package.dependencies] 745 | distlib = ">=0.3.1,<1" 746 | filelock = ">=3.2,<4" 747 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 748 | importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} 749 | platformdirs = ">=2,<3" 750 | 751 | [package.extras] 752 | docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] 753 | testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)"] 754 | 755 | [[package]] 756 | name = "zipp" 757 | version = "3.6.0" 758 | description = "Backport of pathlib-compatible object wrapper for zip files" 759 | optional = false 760 | python-versions = ">=3.6" 761 | files = [ 762 | {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, 763 | {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, 764 | ] 765 | 766 | [package.extras] 767 | docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] 768 | testing = ["func-timeout", "jaraco.itertools", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] 769 | 770 | [metadata] 771 | lock-version = "2.0" 772 | python-versions = "^3.6" 773 | content-hash = "a29396f41f6c8bd5279fbf9090fabe066a14ab1dca79f37d1b93a7445184b5d1" 774 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-magiclink" 3 | packages = [ 4 | {include = "magiclink"} 5 | ] 6 | version = "1.3.0" 7 | description = "Passwordless authentication for Django with Magic Links" 8 | authors = ["Matt Pye