├── .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 | ![](example.gif) 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 |
104 | {% csrf_token %} 105 | {{ login_form }} 106 | 107 |
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 |

Check your email

119 |

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 |
181 | {% csrf_token %} 182 | {{ SignupForm }} 183 | 184 |
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 | Django MagicLink Login - Please override this template 7 | 8 | 9 | 15 | 16 | 17 | 18 |

Login via email

19 |
20 | {% csrf_token %} 21 | {{ login_form }} 22 | 23 |
24 | {% if require_signup %} 25 |

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 | Simple Transactional Email 7 | 89 | 90 | 91 | 92 | 93 | 94 | 153 | 154 | 155 |
  95 |
96 | 97 | 98 | 99 | 100 | 101 | {% if style.logo_url %} 102 | logo 103 | {% endif %} 104 | 105 | 106 | 145 | 146 | 147 | 148 |
107 | 108 | 109 | 142 | 143 |
110 |

111 | Click to confirm and sign in. This link will expire in {{ expiry|timeuntil }} 112 |

113 | 114 | 115 | 116 | 127 | 128 | 129 |
117 | 118 | 119 | 120 | 123 | 124 | 125 |
121 | Click here to sign in 122 |
126 |
130 |

131 | Or sign in using this link: 132 |
133 | {{ magiclink }} 134 |

135 |

136 | Please do not forward this message to anyone else, if you do they will be able to access your account 137 |

138 |

139 | If you did not request this link, you can safely ignore this email. 140 |

141 |
144 |
149 | 150 | 151 |
152 |
 
156 | 157 | 158 | -------------------------------------------------------------------------------- /magiclink/templates/magiclink/login_email.txt: -------------------------------------------------------------------------------- 1 | Click to confirm and sign in. This link will expire in {{ expiry|timeuntil }} 2 | 3 | {{ magiclink }} 4 | 5 | Please do not forward this message to anyone else, if you do they will be able to access your account 6 | 7 | If you did not request this link, you can safely ignore this email. 8 | 9 | -------------------------------------------------------------------------------- /magiclink/templates/magiclink/login_failed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Django MagicLink Login sent - Please override this template 7 | 8 | 9 | 10 | 11 | 12 |

Login failed

13 |

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 | 34 | 35 |

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 | Django MagicLink Login sent - Please override this template 7 | 8 | 9 | 10 | 11 | 12 |

Check your email

13 |

14 | We have sent you a magic link to your email address
15 | Click the link to login automatically 16 |

17 | 18 |

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 | Django MagicLink Sign up - Please override this template 7 | 8 | 9 | 15 | 16 | 17 | 18 |

Create an account

19 |
20 | {% csrf_token %} 21 | {{ SignupForm }} 22 | 23 |
24 |

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 "] 9 | readme = "README.md" 10 | license = "MIT" 11 | repository = "https://github.com/pyepye/django-magiclink" 12 | homepage = "https://github.com/pyepye/django-magiclink" 13 | keywords = ["magic link", "authentication", "passwordless"] 14 | 15 | [tool.poetry.dependencies] 16 | python = "^3.6" 17 | Django = ">=2.1" 18 | packaging = ">=20.9,<22.0" 19 | 20 | [tool.poetry.dev-dependencies] 21 | flake8 = "^4.0.1" 22 | tox = "^3.28.0" 23 | isort = {extras = ["pyproject"], version = "^5.4.2"} 24 | django-stubs = "^1.5.0" 25 | pytest = "^7.0.1" 26 | pytest-cov = "^4.0.0" 27 | pytest-django = "^4.5.2" 28 | pytest-mock = "^3.2.0" 29 | pytest-freezegun = "^0.4.1" 30 | 31 | [tool.isort] 32 | line_length = 79 33 | multi_line_output = 5 34 | known_third_party = "pytest" 35 | known_first_party = "magiclink" 36 | skip_glob = "__pycache__/*,venv/*,.venv/*,.tox/*,.mypy_cache" 37 | 38 | [build-system] 39 | requires = ["poetry>=0.12"] 40 | build-backend = "poetry.masonry.api" 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyepye/django-magiclink/70a3ea9e2c493e036042ab16ca85d841a14384ad/tests/__init__.py -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth import get_user_model 3 | 4 | from magiclink.helpers import create_magiclink, get_or_create_user 5 | 6 | User = get_user_model() 7 | 8 | 9 | @pytest.fixture() 10 | def user(): 11 | user = get_or_create_user(email='test@example.com') 12 | return user 13 | 14 | 15 | @pytest.fixture 16 | def magic_link(user): 17 | 18 | def _create(request): 19 | return create_magiclink(user.email, request, redirect_url='') 20 | 21 | return _create 22 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db import models 3 | 4 | 5 | class CustomUserEmailOnly(AbstractUser): 6 | email = models.EmailField('email address', unique=True) 7 | 8 | 9 | class CustomUserFullName(CustomUserEmailOnly): 10 | full_name = models.TextField() 11 | 12 | 13 | class CustomUserName(CustomUserEmailOnly): 14 | name = models.TextField() 15 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | SECRET_KEY = 'magiclink-test' 4 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 5 | 6 | ALLOWED_HOSTS = ['*'] 7 | 8 | DATABASES = { 9 | 'default': { 10 | 'ENGINE': 'django.db.backends.sqlite3', 11 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 12 | } 13 | } 14 | 15 | ROOT_URLCONF = 'tests.urls' 16 | 17 | INSTALLED_APPS = [ 18 | 'django.contrib.admin', 19 | 'django.contrib.auth', 20 | 'django.contrib.contenttypes', 21 | 'django.contrib.sessions', 22 | 'django.contrib.messages', 23 | 'django.contrib.staticfiles', 24 | 'tests', 25 | 'magiclink', 26 | ] 27 | 28 | MIDDLEWARE = [ 29 | 'django.middleware.security.SecurityMiddleware', 30 | 'django.contrib.sessions.middleware.SessionMiddleware', 31 | 'django.middleware.common.CommonMiddleware', 32 | 'django.middleware.csrf.CsrfViewMiddleware', 33 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 34 | 'django.contrib.messages.middleware.MessageMiddleware', 35 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 36 | ] 37 | 38 | TEMPLATES = [ 39 | { 40 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 41 | 'DIRS': [], 42 | 'APP_DIRS': True, 43 | 'OPTIONS': { 44 | 'context_processors': [ 45 | 'django.template.context_processors.debug', 46 | 'django.template.context_processors.request', 47 | 'django.contrib.auth.context_processors.auth', 48 | 'django.contrib.messages.context_processors.messages', 49 | ], 50 | }, 51 | }, 52 | ] 53 | 54 | AUTHENTICATION_BACKENDS = [ 55 | 'magiclink.backends.MagicLinkBackend', 56 | 'django.contrib.auth.backends.ModelBackend', 57 | ] 58 | 59 | LOGIN_URL = 'magiclink:login' 60 | LOGIN_REDIRECT_URL = 'needs_login' 61 | LOGOUT_REDIRECT_URL = 'no_login' 62 | MAGICLINK_LOGIN_SENT_REDIRECT = 'magiclink:login_sent' 63 | MAGICLINK_SIGNUP_LOGIN_REDIRECT = 'no_login' 64 | -------------------------------------------------------------------------------- /tests/test_backend.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.http import HttpRequest 3 | 4 | from magiclink.backends import MagicLinkBackend 5 | from magiclink.models import MagicLink 6 | 7 | from .fixtures import magic_link, user # NOQA: F401 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_auth_backend_get_user(user): # NOQA: F811 12 | assert MagicLinkBackend().get_user(user.id) 13 | 14 | 15 | @pytest.mark.django_db 16 | def test_auth_backend_get_user_do_not_exist(user): # NOQA: F811 17 | assert MagicLinkBackend().get_user(123456) is None 18 | 19 | 20 | @pytest.mark.django_db 21 | def test_auth_backend(user, magic_link): # NOQA: F811 22 | request = HttpRequest() 23 | ml = magic_link(request) 24 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 25 | user = MagicLinkBackend().authenticate( 26 | request=request, token=ml.token, email=user.email 27 | ) 28 | assert user 29 | ml = MagicLink.objects.get(token=ml.token) 30 | assert ml.times_used == 1 31 | assert ml.disabled is True 32 | 33 | 34 | @pytest.mark.django_db 35 | def test_auth_backend_no_token(user, magic_link): # NOQA: F811 36 | request = HttpRequest() 37 | user = MagicLinkBackend().authenticate( 38 | request=request, token='fake', email=user.email 39 | ) 40 | assert user is None 41 | 42 | 43 | @pytest.mark.django_db 44 | def test_auth_backend_disabled_token(user, magic_link): # NOQA: F811 45 | request = HttpRequest() 46 | ml = magic_link(request) 47 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 48 | ml.disabled = True 49 | ml.save() 50 | user = MagicLinkBackend().authenticate( 51 | request=request, token=ml.token, email=user.email 52 | ) 53 | assert user is None 54 | 55 | 56 | @pytest.mark.django_db 57 | def test_auth_backend_no_email(user, magic_link): # NOQA: F811 58 | request = HttpRequest() 59 | ml = magic_link(request) 60 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 61 | user = MagicLinkBackend().authenticate(request=request, token=ml.token) 62 | assert user is None 63 | 64 | 65 | @pytest.mark.django_db 66 | def test_auth_backend_invalid(user, magic_link): # NOQA: F811 67 | request = HttpRequest() 68 | ml = magic_link(request) 69 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 70 | user = MagicLinkBackend().authenticate( 71 | request=request, token=ml.token, email='fake@email.com' 72 | ) 73 | assert user is None 74 | -------------------------------------------------------------------------------- /tests/test_clear_logins_command.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import pytest 4 | from django.core.management import call_command 5 | from django.utils import timezone 6 | 7 | from magiclink.models import MagicLink 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_magiclink_clear_logins(): 12 | # Valid Magic Links 13 | for index in range(2): 14 | MagicLink.objects.create( 15 | email='test@example.com', 16 | token='fake', 17 | expiry=timezone.now(), 18 | redirect_url='', 19 | ) 20 | 21 | # Magic Links which expired 2 weeks ago so should be removed 22 | two_weeks_ago = timezone.now() - timedelta(days=14) 23 | for index in range(2): 24 | MagicLink.objects.create( 25 | email='test@example.com', 26 | token='fake', 27 | expiry=two_weeks_ago, 28 | redirect_url='', 29 | ) 30 | 31 | # Disabled Magic Links which should be removed 32 | for index in range(2): 33 | magic_link = MagicLink.objects.create( 34 | email='test@example.com', 35 | token='fake', 36 | expiry=timezone.now(), 37 | redirect_url='', 38 | ) 39 | magic_link.disable() 40 | 41 | call_command('magiclink_clear_logins') 42 | 43 | all_magiclinks = MagicLink.objects.all() 44 | assert all_magiclinks.count() == 2 45 | for link in all_magiclinks: 46 | assert not link.disabled 47 | assert link.expiry > timezone.now() - timedelta(days=6) 48 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from importlib import reload 3 | 4 | import pytest 5 | from django.contrib.auth import get_user_model 6 | from django.http import HttpRequest 7 | from django.urls import reverse 8 | from django.utils import timezone 9 | 10 | from magiclink import settings as mlsettings 11 | from magiclink.helpers import create_magiclink, get_or_create_user 12 | from magiclink.models import MagicLink, MagicLinkError 13 | 14 | from .fixtures import user # NOQA: F401 15 | from .models import CustomUserEmailOnly, CustomUserFullName, CustomUserName 16 | 17 | User = get_user_model() 18 | 19 | 20 | @pytest.mark.django_db 21 | def test_create_magiclink(settings, freezer): 22 | freezer.move_to('2000-01-01T00:00:00') 23 | 24 | email = 'test@example.com' 25 | expiry = timezone.now() + timedelta(seconds=mlsettings.AUTH_TIMEOUT) 26 | request = HttpRequest() 27 | request.META['REMOTE_ADDR'] = '127.0.0.1' 28 | magic_link = create_magiclink(email, request) 29 | assert magic_link.email == email 30 | assert len(magic_link.token) == mlsettings.TOKEN_LENGTH 31 | assert magic_link.expiry == expiry 32 | assert magic_link.redirect_url == reverse(settings.LOGIN_REDIRECT_URL) 33 | assert len(magic_link.cookie_value) == 36 34 | assert magic_link.ip_address == '127.0.0.0' # Anonymize IP by default 35 | 36 | 37 | @pytest.mark.django_db 38 | def test_create_magiclink_require_same_ip_off_no_ip(settings): 39 | settings.MAGICLINK_REQUIRE_SAME_IP = False 40 | from magiclink import settings as mlsettings 41 | reload(mlsettings) 42 | 43 | request = HttpRequest() 44 | request.META['REMOTE_ADDR'] = '127.0.0.1' 45 | magic_link = create_magiclink('test@example.com', request) 46 | assert magic_link.ip_address is None 47 | 48 | 49 | @pytest.mark.django_db 50 | def test_create_magiclink_none_anonymized_ip(settings): 51 | settings.MAGICLINK_ANONYMIZE_IP = False 52 | from magiclink import settings as mlsettings 53 | reload(mlsettings) 54 | 55 | request = HttpRequest() 56 | ip_address = '127.0.0.1' 57 | request.META['REMOTE_ADDR'] = ip_address 58 | magic_link = create_magiclink('test@example.com', request) 59 | assert magic_link.ip_address == ip_address 60 | 61 | 62 | @pytest.mark.django_db 63 | def test_create_magiclink_redirect_url(): 64 | email = 'test@example.com' 65 | request = HttpRequest() 66 | redirect_url = '/test/' 67 | magic_link = create_magiclink(email, request, redirect_url=redirect_url) 68 | assert magic_link.email == email 69 | assert magic_link.redirect_url == redirect_url 70 | 71 | 72 | @pytest.mark.django_db 73 | def test_create_magiclink_email_ignore_case(): 74 | email = 'TEST@example.com' 75 | request = HttpRequest() 76 | magic_link = create_magiclink(email, request) 77 | assert magic_link.email == email.lower() 78 | 79 | 80 | @pytest.mark.django_db 81 | def test_create_magiclink_email_ignore_case_off(settings): 82 | settings.MAGICLINK_EMAIL_IGNORE_CASE = False 83 | from magiclink import settings 84 | reload(settings) 85 | 86 | email = 'TEST@example.com' 87 | request = HttpRequest() 88 | magic_link = create_magiclink(email, request) 89 | assert magic_link.email == email 90 | 91 | 92 | @pytest.mark.django_db 93 | def test_create_magiclink_one_token_per_user(freezer): 94 | email = 'test@example.com' 95 | request = HttpRequest() 96 | freezer.move_to('2000-01-01T00:00:00') 97 | magic_link = create_magiclink(email, request) 98 | assert magic_link.disabled is False 99 | 100 | freezer.move_to('2000-01-01T00:00:31') 101 | create_magiclink(email, request) 102 | 103 | magic_link = MagicLink.objects.get(token=magic_link.token) 104 | assert magic_link.disabled is True 105 | assert magic_link.email == email 106 | 107 | 108 | @pytest.mark.django_db 109 | def test_create_magiclink_login_request_time_limit(): 110 | email = 'test@example.com' 111 | request = HttpRequest() 112 | create_magiclink(email, request) 113 | with pytest.raises(MagicLinkError): 114 | create_magiclink(email, request) 115 | 116 | 117 | @pytest.mark.django_db 118 | def test_get_or_create_user_exists(user): # NOQA: F811 119 | usr = get_or_create_user(email=user.email) 120 | assert usr == user 121 | assert User.objects.count() == 1 122 | 123 | 124 | @pytest.mark.django_db 125 | def test_get_or_create_user_exists_ignore_case(settings, user): # NOQA: F811 126 | settings.MAGICLINK_EMAIL_IGNORE_CASE = True 127 | from magiclink import settings 128 | reload(settings) 129 | 130 | usr = get_or_create_user(email=user.email.upper()) 131 | assert usr == user 132 | assert User.objects.count() == 1 133 | 134 | 135 | @pytest.mark.django_db 136 | def test_get_or_create_user_email_as_username(): 137 | email = 'test@example.com' 138 | usr = get_or_create_user(email=email) 139 | assert usr.email == email 140 | assert usr.username == email 141 | 142 | 143 | @pytest.mark.django_db 144 | def test_get_or_create_user_random_username(settings): 145 | settings.MAGICLINK_EMAIL_AS_USERNAME = False 146 | from magiclink import settings 147 | reload(settings) 148 | 149 | email = 'test@example.com' 150 | usr = get_or_create_user(email=email) 151 | assert usr.email == email 152 | assert usr.username != email 153 | assert len(usr.username) == 10 154 | 155 | 156 | @pytest.mark.django_db 157 | def test_get_or_create_user_first_name(): 158 | first_name = 'fname' 159 | usr = get_or_create_user(email='test@example.com', first_name=first_name) 160 | assert usr.first_name == first_name 161 | 162 | 163 | @pytest.mark.django_db 164 | def test_get_or_create_user_last_name(): 165 | last_name = 'lname' 166 | usr = get_or_create_user(email='test@example.com', last_name=last_name) 167 | assert usr.last_name == last_name 168 | 169 | 170 | @pytest.mark.django_db 171 | def test_get_or_create_user_no_username(mocker): 172 | gum = mocker.patch('magiclink.helpers.get_user_model') 173 | gum.return_value = CustomUserEmailOnly 174 | 175 | from magiclink.helpers import get_or_create_user 176 | email = 'test@example.com' 177 | usr = get_or_create_user(email=email) 178 | assert usr.email == email 179 | 180 | 181 | @pytest.mark.django_db 182 | def test_get_or_create_user_full_name(mocker): 183 | gum = mocker.patch('magiclink.helpers.get_user_model') 184 | gum.return_value = CustomUserFullName 185 | 186 | from magiclink.helpers import get_or_create_user 187 | email = 'test@example.com' 188 | first = 'fname' 189 | last = 'lname' 190 | usr = get_or_create_user(email=email, first_name=first, last_name=last) 191 | assert usr.email == email 192 | assert usr.full_name == f'{first} {last}' 193 | 194 | 195 | @pytest.mark.django_db 196 | def test_get_or_create_user_name(mocker): 197 | gum = mocker.patch('magiclink.helpers.get_user_model') 198 | gum.return_value = CustomUserName 199 | 200 | from magiclink.helpers import get_or_create_user 201 | email = 'test@example.com' 202 | first = 'fname' 203 | last = 'lname' 204 | usr = get_or_create_user(email=email, first_name=first, last_name=last) 205 | assert usr.email == email 206 | assert usr.name == f'{first} {last}' 207 | -------------------------------------------------------------------------------- /tests/test_login.py: -------------------------------------------------------------------------------- 1 | from importlib import reload 2 | from time import time 3 | 4 | import pytest 5 | from django.contrib.auth import get_user_model 6 | from django.http import HttpRequest 7 | from django.urls import reverse 8 | 9 | from magiclink.models import MagicLink, MagicLinkUnsubscribe 10 | 11 | from .fixtures import magic_link, user # NOQA: F401 12 | 13 | User = get_user_model() 14 | 15 | 16 | @pytest.mark.django_db 17 | def test_login_end_to_end(mocker, settings, client, user): # NOQA: F811 18 | spy = mocker.spy(MagicLink, 'generate_url') 19 | 20 | login_url = reverse('magiclink:login') 21 | data = {'email': user.email} 22 | client.post(login_url, data, follow=True) 23 | verify_url = spy.spy_return 24 | response = client.get(verify_url, follow=True) 25 | assert response.status_code == 200 26 | assert response.request['PATH_INFO'] == reverse('needs_login') 27 | 28 | url = reverse('magiclink:logout') 29 | response = client.get(url, follow=True) 30 | assert response.status_code == 200 31 | assert response.request['PATH_INFO'] == reverse('no_login') 32 | 33 | url = reverse('needs_login') 34 | response = client.get(url, follow=True) 35 | assert response.status_code == 200 36 | assert response.request['PATH_INFO'] == reverse('magiclink:login') 37 | 38 | 39 | def test_login_page_get(client): 40 | url = reverse('magiclink:login') 41 | response = client.get(url) 42 | assert response.context_data['login_form'] 43 | assert response.status_code == 200 44 | 45 | 46 | def test_signup_require_signup_context(client): 47 | from magiclink import settings as mlsettings 48 | 49 | url = reverse('magiclink:login') 50 | response = client.get(url) 51 | assert response.context_data['require_signup'] == mlsettings.REQUIRE_SIGNUP 52 | response = client.post(url) 53 | assert response.context_data['require_signup'] == mlsettings.REQUIRE_SIGNUP 54 | 55 | 56 | @pytest.mark.django_db 57 | def test_login_post(mocker, client, user, settings): # NOQA: F811 58 | from magiclink import settings as mlsettings 59 | send_mail = mocker.patch('magiclink.models.send_mail') 60 | 61 | url = reverse('magiclink:login') 62 | data = {'email': user.email} 63 | response = client.post(url, data, enforce_csrf_checks=True) 64 | assert response.status_code == 302 65 | assert response.url == reverse('magiclink:login_sent') 66 | usr = User.objects.get(email=user.email) 67 | assert usr 68 | magiclink = MagicLink.objects.get(email=user.email) 69 | assert magiclink 70 | if mlsettings.REQUIRE_SAME_BROWSER: 71 | cookie_name = f'magiclink{magiclink.pk}' 72 | assert response.cookies[cookie_name].value == magiclink.cookie_value 73 | 74 | send_mail.assert_called_once_with( 75 | subject=mlsettings.EMAIL_SUBJECT, 76 | message=mocker.ANY, 77 | recipient_list=[user.email], 78 | from_email=settings.DEFAULT_FROM_EMAIL, 79 | html_message=mocker.ANY, 80 | ) 81 | 82 | 83 | @pytest.mark.django_db 84 | def test_login_post_no_user(client): 85 | url = reverse('magiclink:login') 86 | data = {'email': 'fake@example.com'} 87 | response = client.post(url, data) 88 | assert response.status_code == 200 89 | error = ['We could not find a user with that email address'] 90 | assert response.context_data['login_form'].errors['email'] == error 91 | 92 | 93 | @pytest.mark.django_db 94 | def test_login_email_wrong_case(settings, client, user): # NOQA: F811 95 | settings.MAGICLINK_EMAIL_IGNORE_CASE = False 96 | from magiclink import settings as mlsettings 97 | reload(mlsettings) 98 | 99 | url = reverse('magiclink:login') 100 | data = {'email': user.email.upper()} 101 | response = client.post(url, data) 102 | assert response.status_code == 200 103 | error = ['We could not find a user with that email address'] 104 | assert response.context_data['login_form'].errors['email'] == error 105 | 106 | 107 | @pytest.mark.django_db 108 | def test_login_email_ignore_case(settings, client, user): # NOQA: F811 109 | settings.MAGICLINK_EMAIL_IGNORE_CASE = True 110 | from magiclink import settings as mlsettings 111 | reload(mlsettings) 112 | 113 | url = reverse('magiclink:login') 114 | data = {'email': user.email.upper()} 115 | response = client.post(url, data) 116 | magiclink = MagicLink.objects.get(email=user.email) 117 | assert magiclink 118 | assert response.status_code == 302 119 | assert response.url == reverse('magiclink:login_sent') 120 | 121 | 122 | @pytest.mark.django_db 123 | def test_login_post_no_user_require_signup_false(settings, client): 124 | settings.MAGICLINK_REQUIRE_SIGNUP = False 125 | from magiclink import settings as mlsettings 126 | reload(mlsettings) 127 | 128 | email = 'fake@example.com' 129 | url = reverse('magiclink:login') 130 | data = {'email': email} 131 | response = client.post(url, data) 132 | assert response.status_code == 302 133 | assert response.url == reverse('magiclink:login_sent') 134 | usr = User.objects.get(email=email) 135 | assert usr 136 | magiclink = MagicLink.objects.get(email=email) 137 | assert magiclink 138 | 139 | 140 | @pytest.mark.django_db 141 | def test_login_post_invalid(client, user): # NOQA: F811 142 | url = reverse('magiclink:login') 143 | data = {'email': 'notanemail'} 144 | response = client.post(url, data) 145 | assert response.status_code == 200 146 | error = ['Enter a valid email address.'] 147 | assert response.context_data['login_form'].errors['email'] == error 148 | 149 | 150 | @pytest.mark.django_db 151 | def test_login_too_many_tokens(client, user, magic_link): # NOQA: F811 152 | request = HttpRequest() 153 | ml = magic_link(request) 154 | 155 | url = reverse('magiclink:login') 156 | data = {'email': ml.email} 157 | response = client.post(url, data) 158 | assert response.status_code == 200 159 | error = ['Too many magic login requests'] 160 | assert response.context_data['login_form'].errors['email'] == error 161 | 162 | 163 | @pytest.mark.django_db 164 | def test_login_antispam(settings, client, user, freezer): # NOQA: F811 165 | freezer.move_to('2000-01-01T00:00:00') 166 | 167 | submit_time = 1 168 | settings.MAGICLINK_ANTISPAM_FORMS = True 169 | settings.MAGICLINK_ANTISPAM_FIELD_TIME = submit_time 170 | from magiclink import settings as mlsettings 171 | reload(mlsettings) 172 | 173 | url = reverse('magiclink:login') 174 | data = {'email': user.email, 'load_time': time() - submit_time} 175 | 176 | response = client.post(url, data) 177 | magiclink = MagicLink.objects.get(email=user.email) 178 | assert magiclink 179 | assert response.status_code == 302 180 | assert response.url == reverse('magiclink:login_sent') 181 | 182 | 183 | @pytest.mark.django_db 184 | def test_login_not_active(settings, client, user): # NOQA: F811 185 | user.is_active = False 186 | user.save() 187 | 188 | url = reverse('magiclink:login') 189 | data = {'email': user.email} 190 | 191 | response = client.post(url, data) 192 | assert response.status_code == 200 193 | error = ['This user has been deactivated'] 194 | assert response.context_data['login_form'].errors['email'] == error 195 | 196 | 197 | @pytest.mark.django_db 198 | def test_login_not_active_ignore_flag(settings, client, user): # NOQA: F811 199 | settings.MAGICLINK_IGNORE_IS_ACTIVE_FLAG = True 200 | from magiclink import settings as mlsettings 201 | reload(mlsettings) 202 | 203 | user.is_active = False 204 | user.save() 205 | 206 | url = reverse('magiclink:login') 207 | data = {'email': user.email} 208 | 209 | response = client.post(url, data) 210 | assert response.status_code == 302 211 | assert response.url == reverse('magiclink:login_sent') 212 | magiclink = MagicLink.objects.get(email=user.email) 213 | assert magiclink 214 | 215 | 216 | @pytest.mark.django_db 217 | def test_login_antispam_submit_too_fast(settings, client, user, freezer): # NOQA: F811,E501 218 | freezer.move_to('2000-01-01T00:00:00') 219 | 220 | settings.MAGICLINK_ANTISPAM_FORMS = True 221 | from magiclink import settings as mlsettings 222 | reload(mlsettings) 223 | 224 | url = reverse('magiclink:login') 225 | data = {'email': user.email, 'load_time': time()} 226 | 227 | response = client.post(url, data) 228 | assert response.status_code == 200 229 | form_errors = response.context['login_form'].errors 230 | assert form_errors['load_time'] == ['Form filled out too fast - bot detected'] # NOQA: E501 231 | 232 | 233 | @pytest.mark.django_db 234 | def test_login_antispam_missing_load_time(settings, client, user): # NOQA: F811,E501 235 | settings.MAGICLINK_ANTISPAM_FORMS = True 236 | from magiclink import settings as mlsettings 237 | reload(mlsettings) 238 | 239 | url = reverse('magiclink:login') 240 | data = {'email': user.email} 241 | 242 | response = client.post(url, data) 243 | assert response.status_code == 200 244 | form_errors = response.context['login_form'].errors 245 | assert form_errors['load_time'] == ['This field is required.'] 246 | 247 | 248 | @pytest.mark.django_db 249 | def test_login_antispam_invalid_load_time(settings, client, user): # NOQA: F811,E501 250 | settings.MAGICLINK_ANTISPAM_FORMS = True 251 | from magiclink import settings as mlsettings 252 | reload(mlsettings) 253 | 254 | url = reverse('magiclink:login') 255 | data = {'email': user.email, 'load_time': 'test'} 256 | 257 | response = client.post(url, data) 258 | assert response.status_code == 200 259 | form_errors = response.context['login_form'].errors 260 | assert form_errors['load_time'] == ['Invalid value'] 261 | 262 | 263 | @pytest.mark.django_db 264 | def test_login_antispam_url_value(settings, client, user): # NOQA: F811 265 | settings.MAGICLINK_ANTISPAM_FORMS = True 266 | from magiclink import settings as mlsettings 267 | reload(mlsettings) 268 | 269 | url = reverse('magiclink:login') 270 | data = {'email': user.email, 'url': 'test'} 271 | 272 | response = client.post(url, data) 273 | assert response.status_code == 200 274 | form_errors = response.context['login_form'].errors 275 | assert form_errors['url'] == ['url should be empty'] 276 | 277 | 278 | @pytest.mark.django_db 279 | def test_login_post_redirect_url(mocker, client, user, settings): # NOQA: F811 280 | from magiclink import settings as mlsettings 281 | reload(mlsettings) 282 | 283 | url = reverse('magiclink:login') 284 | redirect_url = reverse('no_login') 285 | url = f'{url}?next={redirect_url}' 286 | data = {'email': user.email} 287 | response = client.post(url, data) 288 | assert response.status_code == 302 289 | assert response.url == reverse('magiclink:login_sent') 290 | usr = User.objects.get(email=user.email) 291 | assert usr 292 | magiclink = MagicLink.objects.get(email=user.email) 293 | assert magiclink.redirect_url == redirect_url 294 | 295 | 296 | @pytest.mark.django_db 297 | def test_login_post_redirect_url_unsafe(mocker, client, user, settings): # NOQA: F811,E501 298 | from magiclink import settings as mlsettings 299 | reload(mlsettings) 300 | 301 | url = reverse('magiclink:login') 302 | url = f'{url}?next=https://test.com/' 303 | data = {'email': user.email} 304 | response = client.post(url, data) 305 | assert response.status_code == 302 306 | assert response.url == reverse('magiclink:login_sent') 307 | usr = User.objects.get(email=user.email) 308 | assert usr 309 | magiclink = MagicLink.objects.get(email=user.email) 310 | assert magiclink.redirect_url == reverse('needs_login') 311 | 312 | 313 | @pytest.mark.django_db 314 | def test_login_error_email_in_unsubscribe(client, user): # NOQA: F811,E501 315 | MagicLinkUnsubscribe.objects.create(email=user.email) 316 | 317 | url = reverse('magiclink:login') 318 | data = {'email': user.email} 319 | 320 | response = client.post(url, data) 321 | assert response.status_code == 200 322 | form_errors = response.context['login_form'].errors 323 | assert form_errors['email'] == ['Email address is on the unsubscribe list'] 324 | 325 | 326 | @pytest.mark.django_db 327 | def test_login_pass_email_in_unsubscribe(settings, client, user): # NOQA: F811,E501 328 | settings.MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER = True 329 | from magiclink import settings as mlsettings 330 | reload(mlsettings) 331 | 332 | MagicLinkUnsubscribe.objects.create(email=user.email) 333 | 334 | url = reverse('magiclink:login') 335 | data = {'email': user.email} 336 | 337 | response = client.post(url, data) 338 | assert response.status_code == 302 339 | assert response.url == reverse('magiclink:login_sent') 340 | magiclink = MagicLink.objects.get(email=user.email) 341 | assert magiclink 342 | -------------------------------------------------------------------------------- /tests/test_login_verify.py: -------------------------------------------------------------------------------- 1 | from importlib import reload 2 | from urllib.parse import urlencode 3 | 4 | import pytest 5 | from django.contrib.auth import get_user_model 6 | from django.http import HttpRequest 7 | from django.http.cookie import SimpleCookie 8 | from django.urls import reverse 9 | 10 | from .fixtures import magic_link, user # NOQA: F401 11 | 12 | User = get_user_model() 13 | 14 | 15 | @pytest.mark.django_db 16 | def test_login_verify(client, settings, magic_link): # NOQA: F811 17 | url = reverse('magiclink:login_verify') 18 | request = HttpRequest() 19 | ml = magic_link(request) 20 | ml.ip_address = '127.0.0.0' # This is a little hacky 21 | ml.save() 22 | 23 | params = {'token': ml.token} 24 | params['email'] = ml.email 25 | query = urlencode(params) 26 | url = f'{url}?{query}' 27 | 28 | cookie_name = f'magiclink{ml.pk}' 29 | client.cookies = SimpleCookie({cookie_name: ml.cookie_value}) 30 | response = client.get(url) 31 | assert response.status_code == 302 32 | assert response.url == reverse(settings.LOGIN_REDIRECT_URL) 33 | assert client.cookies[cookie_name].value == '' 34 | assert client.cookies[cookie_name]['expires'].startswith('Thu, 01 Jan 1970') # NOQA: E501 35 | 36 | needs_login_url = reverse('needs_login') 37 | needs_login_response = client.get(needs_login_url) 38 | assert needs_login_response.status_code == 200 39 | 40 | 41 | @pytest.mark.django_db 42 | def test_login_verify_with_redirect(client, magic_link): # NOQA: F811 43 | url = reverse('magiclink:login_verify') 44 | request = HttpRequest() 45 | request.META['SERVER_NAME'] = '127.0.0.1' 46 | request.META['SERVER_PORT'] = 80 47 | ml = magic_link(request) 48 | ml.ip_address = '127.0.0.0' # This is a little hacky 49 | redirect_url = reverse('no_login') 50 | ml.redirect_url = redirect_url 51 | ml.save() 52 | url = ml.generate_url(request) 53 | 54 | client.cookies = SimpleCookie({f'magiclink{ml.pk}': ml.cookie_value}) 55 | response = client.get(url) 56 | assert response.status_code == 302 57 | assert response.url == redirect_url 58 | 59 | 60 | @pytest.mark.django_db 61 | def test_login_verify_no_token_404(client, settings): 62 | settings.MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = '' 63 | from magiclink import settings as mlsettings 64 | reload(mlsettings) 65 | 66 | url = reverse('magiclink:login_verify') 67 | response = client.get(url) 68 | assert response.status_code == 404 69 | 70 | 71 | @pytest.mark.django_db 72 | def test_login_verify_failed(client, settings): 73 | settings.MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = 'magiclink/login_failed.html' # NOQA: E501 74 | from magiclink import settings as mlsettings 75 | reload(mlsettings) 76 | 77 | url = reverse('magiclink:login_verify') 78 | response = client.get(url) 79 | assert response.status_code == 200 80 | context = response.context_data 81 | assert context['login_error'] == 'A magic link with that token could not be found' # NOQA: E501 82 | assert context['ONE_TOKEN_PER_USER'] == mlsettings.ONE_TOKEN_PER_USER 83 | assert context['REQUIRE_SAME_BROWSER'] == mlsettings.REQUIRE_SAME_BROWSER 84 | assert context['REQUIRE_SAME_IP'] == mlsettings.REQUIRE_SAME_IP 85 | assert context['ALLOW_SUPERUSER_LOGIN'] == mlsettings.ALLOW_SUPERUSER_LOGIN 86 | assert context['ALLOW_STAFF_LOGIN'] == mlsettings.ALLOW_STAFF_LOGIN 87 | 88 | 89 | @pytest.mark.django_db 90 | def test_login_verify_failed_validation(client, settings, magic_link): # NOQA: F811,E501 91 | settings.MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = 'magiclink/login_failed.html' # NOQA: E501 92 | from magiclink import settings as mlsettings 93 | reload(mlsettings) 94 | 95 | url = reverse('magiclink:login_verify') 96 | request = HttpRequest() 97 | ml = magic_link(request) 98 | params = {'token': ml.token} 99 | params['email'] = ml.email 100 | query = urlencode(params) 101 | url = f'{url}?{query}' 102 | 103 | response = client.get(url) 104 | assert response.status_code == 200 105 | context = response.context_data 106 | assert context['login_error'] == 'IP address is different from the IP address used to request the magic link' # NOQA: E501 107 | assert context['ONE_TOKEN_PER_USER'] == mlsettings.ONE_TOKEN_PER_USER 108 | assert context['REQUIRE_SAME_BROWSER'] == mlsettings.REQUIRE_SAME_BROWSER 109 | assert context['REQUIRE_SAME_IP'] == mlsettings.REQUIRE_SAME_IP 110 | assert context['ALLOW_SUPERUSER_LOGIN'] == mlsettings.ALLOW_SUPERUSER_LOGIN 111 | assert context['ALLOW_STAFF_LOGIN'] == mlsettings.ALLOW_STAFF_LOGIN 112 | 113 | 114 | @pytest.mark.django_db 115 | def test_login_verify_failed_redirect(client, settings): 116 | fail_redirect_url = '/failedredirect' 117 | settings.MAGICLINK_LOGIN_FAILED_REDIRECT = fail_redirect_url 118 | from magiclink import settings as mlsettings 119 | reload(mlsettings) 120 | 121 | url = reverse('magiclink:login_verify') 122 | response = client.get(url) 123 | assert response.url == fail_redirect_url 124 | 125 | 126 | @pytest.mark.django_db 127 | def test_login_verify_custom_verify(client, settings, magic_link): # NOQA: F811,E501 128 | settings.MAGICLINK_LOGIN_VERIFY_URL = 'custom_login_verify' 129 | from magiclink import settings 130 | reload(settings) 131 | 132 | url = reverse(settings.LOGIN_VERIFY_URL) 133 | request = HttpRequest() 134 | request.META['SERVER_NAME'] = '127.0.0.1' 135 | request.META['SERVER_PORT'] = 80 136 | ml = magic_link(request) 137 | ml.ip_address = '127.0.0.0' 138 | ml.redirect_url = reverse('needs_login') # Should be ignored 139 | ml.save() 140 | url = ml.generate_url(request) 141 | 142 | cookie_name = f'magiclink{ml.pk}' 143 | client.cookies = SimpleCookie({cookie_name: ml.cookie_value}) 144 | response = client.get(url) 145 | assert response.status_code == 302 146 | assert response.url == reverse('no_login') 147 | assert client.cookies[cookie_name].value == '' 148 | assert client.cookies[cookie_name]['expires'].startswith('Thu, 01 Jan 1970') # NOQA: E501 149 | 150 | settings.MAGICLINK_LOGIN_VERIFY_URL = 'magiclink:login_verify' 151 | from magiclink import settings 152 | reload(settings) 153 | -------------------------------------------------------------------------------- /tests/test_logout.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth import get_user_model 3 | from django.urls import reverse 4 | 5 | from .fixtures import user # NOQA: F401 6 | 7 | User = get_user_model() 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_logout(client, user, settings): # NOQA: F811 12 | client.force_login(user) 13 | url = reverse('magiclink:logout') 14 | response = client.get(url) 15 | assert response.status_code == 302 16 | assert response.url == reverse('no_login') 17 | 18 | needs_login_url = reverse('needs_login') 19 | needs_login_response = client.get(needs_login_url) 20 | assert needs_login_response.status_code == 302 21 | assert response.url == reverse(settings.LOGOUT_REDIRECT_URL) 22 | 23 | 24 | @pytest.mark.django_db 25 | def test_logout_with_next(client, user, settings): # NOQA: F811 26 | client.force_login(user) 27 | url = reverse('magiclink:logout') 28 | empty_url = reverse('no_login') 29 | url = f'{url}?next={empty_url}' 30 | response = client.get(url) 31 | assert response.status_code == 302 32 | assert response.url == empty_url 33 | 34 | needs_login_url = reverse('needs_login') 35 | needs_login_response = client.get(needs_login_url) 36 | assert needs_login_response.status_code == 302 37 | assert response.url == reverse(settings.LOGOUT_REDIRECT_URL) 38 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from importlib import reload 3 | from urllib.parse import quote 4 | 5 | import pytest 6 | from django.contrib.auth import get_user_model 7 | from django.http import HttpRequest 8 | from django.urls import reverse 9 | from django.utils import timezone 10 | 11 | from magiclink import settings 12 | from magiclink.models import MagicLink, MagicLinkError, MagicLinkUnsubscribe 13 | 14 | from .fixtures import magic_link, user # NOQA: F401 15 | 16 | User = get_user_model() 17 | 18 | 19 | @pytest.mark.django_db 20 | def test_model_string(magic_link): # NOQA: F811 21 | request = HttpRequest() 22 | ml = magic_link(request) 23 | assert str(ml) == f'{ml.email} - {ml.expiry}' 24 | 25 | 26 | @pytest.mark.django_db 27 | def test_generate_url(settings, magic_link): # NOQA: F811 28 | settings.MAGICLINK_LOGIN_VERIFY_URL = 'magiclink:login_verify' 29 | from magiclink import settings 30 | reload(settings) 31 | 32 | request = HttpRequest() 33 | host = '127.0.0.1' 34 | login_url = reverse('magiclink:login_verify') 35 | request.META['SERVER_NAME'] = host 36 | request.META['SERVER_PORT'] = 80 37 | ml = magic_link(request) 38 | url = f'http://{host}{login_url}?token={ml.token}&email={quote(ml.email)}' 39 | assert ml.generate_url(request) == url 40 | 41 | 42 | @pytest.mark.django_db 43 | def test_generate_url_custom_verify(settings, magic_link): # NOQA: F811 44 | settings.MAGICLINK_LOGIN_VERIFY_URL = 'custom_login_verify' 45 | from magiclink import settings 46 | reload(settings) 47 | 48 | request = HttpRequest() 49 | host = '127.0.0.1' 50 | login_url = reverse('custom_login_verify') 51 | request.META['SERVER_NAME'] = host 52 | request.META['SERVER_PORT'] = 80 53 | ml = magic_link(request) 54 | url = f'http://{host}{login_url}?token={ml.token}&email={quote(ml.email)}' 55 | assert ml.generate_url(request) == url 56 | 57 | settings.MAGICLINK_LOGIN_VERIFY_URL = 'magiclink:login_verify' 58 | from magiclink import settings 59 | reload(settings) 60 | 61 | 62 | @pytest.mark.django_db 63 | def test_send_email(mocker, settings, magic_link): # NOQA: F811 64 | from magiclink import settings as mlsettings 65 | send_mail = mocker.patch('magiclink.models.send_mail') 66 | render_to_string = mocker.patch('magiclink.models.render_to_string') 67 | 68 | request = HttpRequest() 69 | request.META['SERVER_NAME'] = '127.0.0.1' 70 | request.META['SERVER_PORT'] = 80 71 | 72 | ml = magic_link(request) 73 | ml.send(request) 74 | 75 | usr = User.objects.get(email=ml.email) 76 | context = { 77 | 'subject': mlsettings.EMAIL_SUBJECT, 78 | 'user': usr, 79 | 'magiclink': ml.generate_url(request), 80 | 'expiry': ml.expiry, 81 | 'ip_address': ml.ip_address, 82 | 'created': ml.created, 83 | 'require_same_ip': mlsettings.REQUIRE_SAME_IP, 84 | 'require_same_browser': mlsettings.REQUIRE_SAME_BROWSER, 85 | 'token_uses': mlsettings.TOKEN_USES, 86 | 'style': mlsettings.EMAIL_STYLES, 87 | } 88 | render_to_string.assert_has_calls([ 89 | mocker.call(mlsettings.EMAIL_TEMPLATE_NAME_TEXT, context), 90 | mocker.call(mlsettings.EMAIL_TEMPLATE_NAME_HTML, context), 91 | ]) 92 | send_mail.assert_called_once_with( 93 | subject=mlsettings.EMAIL_SUBJECT, 94 | message=mocker.ANY, 95 | recipient_list=[ml.email], 96 | from_email=settings.DEFAULT_FROM_EMAIL, 97 | html_message=mocker.ANY, 98 | ) 99 | 100 | 101 | @pytest.mark.django_db 102 | def test_send_email_error_email_in_unsubscribe(magic_link): # NOQA: F811 103 | request = HttpRequest() 104 | ml = magic_link(request) 105 | MagicLinkUnsubscribe.objects.create(email=ml.email) 106 | 107 | with pytest.raises(MagicLinkError) as error: 108 | ml.send(request) 109 | 110 | error.match('Email address is on the unsubscribe list') 111 | 112 | 113 | @pytest.mark.django_db 114 | def test_send_email_pass_email_in_unsubscribe(mocker, settings, magic_link): # NOQA: E501, F811 115 | settings.MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER = True 116 | from magiclink import settings as mlsettings 117 | reload(mlsettings) 118 | 119 | send_mail = mocker.patch('magiclink.models.send_mail') 120 | 121 | request = HttpRequest() 122 | request.META['SERVER_NAME'] = '127.0.0.1' 123 | request.META['SERVER_PORT'] = 80 124 | ml = magic_link(request) 125 | MagicLinkUnsubscribe.objects.create(email=ml.email) 126 | 127 | ml.send(request) 128 | 129 | send_mail.assert_called_once_with( 130 | subject=mlsettings.EMAIL_SUBJECT, 131 | message=mocker.ANY, 132 | recipient_list=[ml.email], 133 | from_email=settings.DEFAULT_FROM_EMAIL, 134 | html_message=mocker.ANY, 135 | ) 136 | 137 | 138 | @pytest.mark.django_db 139 | def test_validate(user, magic_link): # NOQA: F811 140 | request = HttpRequest() 141 | ml = magic_link(request) 142 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 143 | ml_user = ml.validate(request=request, email=user.email) 144 | assert ml_user == user 145 | 146 | 147 | @pytest.mark.django_db 148 | def test_validate_email_ignore_case(user, magic_link): # NOQA: F811 149 | request = HttpRequest() 150 | ml = magic_link(request) 151 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 152 | ml_user = ml.validate(request=request, email=user.email.upper()) 153 | assert ml_user == user 154 | 155 | 156 | @pytest.mark.django_db 157 | def test_validate_wrong_email(user, magic_link): # NOQA: F811 158 | request = HttpRequest() 159 | ml = magic_link(request) 160 | email = 'fake@email.com' 161 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 162 | with pytest.raises(MagicLinkError) as error: 163 | ml.validate(request=request, email=email) 164 | 165 | error.match('Email address does not match') 166 | 167 | 168 | @pytest.mark.django_db 169 | def test_validate_expired(user, magic_link): # NOQA: F811 170 | request = HttpRequest() 171 | ml = magic_link(request) 172 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 173 | ml.expiry = timezone.now() - timedelta(seconds=1) 174 | ml.save() 175 | 176 | with pytest.raises(MagicLinkError) as error: 177 | ml.validate(request=request, email=user.email) 178 | 179 | error.match('Magic link has expired') 180 | 181 | ml = MagicLink.objects.get(token=ml.token) 182 | assert ml.times_used == 1 183 | assert ml.disabled is True 184 | 185 | 186 | @pytest.mark.django_db 187 | def test_validate_wrong_ip(user, magic_link): # NOQA: F811 188 | request = HttpRequest() 189 | ml = magic_link(request) 190 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 191 | ml.ip_address = '255.255.255.255' 192 | ml.save() 193 | with pytest.raises(MagicLinkError) as error: 194 | ml.validate(request=request, email=user.email) 195 | 196 | error.match('IP address is different from the IP address used to request ' 197 | 'the magic link') 198 | 199 | ml = MagicLink.objects.get(token=ml.token) 200 | assert ml.times_used == 1 201 | assert ml.disabled is True 202 | 203 | 204 | @pytest.mark.django_db 205 | def test_validate_different_browser(user, magic_link): # NOQA: F811 206 | request = HttpRequest() 207 | ml = magic_link(request) 208 | request.COOKIES[f'magiclink{ml.pk}'] = 'bad_value' 209 | with pytest.raises(MagicLinkError) as error: 210 | ml.validate(request=request, email=user.email) 211 | 212 | error.match('Browser is different from the browser used to request the ' 213 | 'magic link') 214 | 215 | ml = MagicLink.objects.get(token=ml.token) 216 | assert ml.times_used == 1 217 | assert ml.disabled is True 218 | 219 | 220 | @pytest.mark.django_db 221 | def test_validate_used_times(user, magic_link): # NOQA: F811 222 | request = HttpRequest() 223 | ml = magic_link(request) 224 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 225 | ml.times_used = settings.TOKEN_USES 226 | ml.save() 227 | with pytest.raises(MagicLinkError) as error: 228 | ml.validate(request=request, email=user.email) 229 | 230 | error.match('Magic link has been used too many times') 231 | 232 | ml = MagicLink.objects.get(token=ml.token) 233 | assert ml.times_used == settings.TOKEN_USES + 1 234 | assert ml.disabled is True 235 | 236 | 237 | @pytest.mark.django_db 238 | def test_validate_superuser(settings, user, magic_link): # NOQA: F811 239 | settings.MAGICLINK_ALLOW_SUPERUSER_LOGIN = False 240 | from magiclink import settings 241 | reload(settings) 242 | 243 | request = HttpRequest() 244 | ml = magic_link(request) 245 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 246 | user.is_superuser = True 247 | user.save() 248 | with pytest.raises(MagicLinkError) as error: 249 | ml.validate(request=request, email=user.email) 250 | 251 | error.match('You can not login to a super user account using a magic link') 252 | 253 | ml = MagicLink.objects.get(token=ml.token) 254 | assert ml.times_used == 1 255 | assert ml.disabled is True 256 | 257 | 258 | @pytest.mark.django_db 259 | def test_validate_staff(settings, user, magic_link): # NOQA: F811 260 | settings.MAGICLINK_ALLOW_STAFF_LOGIN = False 261 | from magiclink import settings 262 | reload(settings) 263 | 264 | request = HttpRequest() 265 | ml = magic_link(request) 266 | request.COOKIES[f'magiclink{ml.pk}'] = ml.cookie_value 267 | user.is_staff = True 268 | user.save() 269 | with pytest.raises(MagicLinkError) as error: 270 | ml.validate(request=request, email=user.email) 271 | 272 | error.match('You can not login to a staff account using a magic link') 273 | 274 | ml = MagicLink.objects.get(token=ml.token) 275 | assert ml.times_used == 1 276 | assert ml.disabled is True 277 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from importlib import reload 2 | 3 | import pytest 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | 7 | def test_login_sent_redirect(settings): 8 | settings.MAGICLINK_LOGIN_SENT_REDIRECT = '/sent' 9 | from magiclink import settings as mlsettings 10 | reload(mlsettings) 11 | assert mlsettings.LOGIN_SENT_REDIRECT == settings.MAGICLINK_LOGIN_SENT_REDIRECT # NOQA: E501 12 | 13 | 14 | def test_login_template_name(settings): 15 | settings.MAGICLINK_LOGIN_TEMPLATE_NAME = 'login.html' 16 | from magiclink import settings as mlsettings 17 | reload(mlsettings) 18 | assert mlsettings.LOGIN_TEMPLATE_NAME == settings.MAGICLINK_LOGIN_TEMPLATE_NAME # NOQA: E501 19 | 20 | 21 | def test_login_sent_template_name(settings): 22 | settings.MAGICLINK_LOGIN_SENT_TEMPLATE_NAME = 'login_sent.html' 23 | from magiclink import settings as mlsettings 24 | reload(mlsettings) 25 | assert mlsettings.LOGIN_SENT_TEMPLATE_NAME == settings.MAGICLINK_LOGIN_SENT_TEMPLATE_NAME # NOQA: E501 26 | 27 | 28 | def test_login_failed_template_name(settings): 29 | settings.MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME = 'login_failed.html' 30 | from magiclink import settings as mlsettings 31 | reload(mlsettings) 32 | assert mlsettings.LOGIN_FAILED_TEMPLATE_NAME == settings.MAGICLINK_LOGIN_FAILED_TEMPLATE_NAME # NOQA: E501 33 | 34 | 35 | def test_login_failed_redirect(settings): 36 | settings.MAGICLINK_LOGIN_FAILED_REDIRECT = '/loginfailed' 37 | from magiclink import settings as mlsettings 38 | reload(mlsettings) 39 | assert mlsettings.LOGIN_FAILED_REDIRECT == settings.MAGICLINK_LOGIN_FAILED_REDIRECT # NOQA: E501 40 | 41 | 42 | def test_signup_template_name(settings): 43 | settings.MAGICLINK_SIGNUP_TEMPLATE_NAME = 'signup.html' 44 | from magiclink import settings as mlsettings 45 | reload(mlsettings) 46 | assert mlsettings.SIGNUP_TEMPLATE_NAME == settings.MAGICLINK_SIGNUP_TEMPLATE_NAME # NOQA: E501 47 | 48 | 49 | def test_signup_login_redirect(settings): 50 | settings.MAGICLINK_SIGNUP_LOGIN_REDIRECT = '/loggedin' 51 | from magiclink import settings as mlsettings 52 | reload(mlsettings) 53 | assert mlsettings.SIGNUP_LOGIN_REDIRECT == settings.MAGICLINK_SIGNUP_LOGIN_REDIRECT # NOQA: E501 54 | 55 | 56 | def test_email_subject(settings): 57 | settings.MAGICLINK_EMAIL_SUBJECT = 'Test Email subject' 58 | from magiclink import settings as mlsettings 59 | reload(mlsettings) 60 | assert mlsettings.EMAIL_SUBJECT == settings.MAGICLINK_EMAIL_SUBJECT 61 | 62 | 63 | def test_email_template_name_text(settings): 64 | settings.MAGICLINK_EMAIL_TEMPLATE_NAME_TEXT = 'email.txt' 65 | from magiclink import settings as mlsettings 66 | reload(mlsettings) 67 | assert mlsettings.EMAIL_TEMPLATE_NAME_TEXT == settings.MAGICLINK_EMAIL_TEMPLATE_NAME_TEXT # NOQA: E501 68 | 69 | 70 | def test_email_template_name_html(settings): 71 | settings.MAGICLINK_EMAIL_TEMPLATE_NAME_HTML = 'email.html' 72 | from magiclink import settings as mlsettings 73 | reload(mlsettings) 74 | assert mlsettings.EMAIL_TEMPLATE_NAME_HTML == settings.MAGICLINK_EMAIL_TEMPLATE_NAME_HTML # NOQA: E501 75 | 76 | 77 | def test_token_length(settings): 78 | settings.MAGICLINK_TOKEN_LENGTH = 100 79 | from magiclink import settings as mlsettings 80 | reload(mlsettings) 81 | assert mlsettings.TOKEN_LENGTH == settings.MAGICLINK_TOKEN_LENGTH 82 | 83 | 84 | def test_token_length_bad_value(settings): 85 | settings.MAGICLINK_TOKEN_LENGTH = 'Test' 86 | 87 | with pytest.raises(ImproperlyConfigured): 88 | from magiclink import settings 89 | reload(settings) 90 | 91 | 92 | def test_token_length_low_value_warning(settings): 93 | settings.MAGICLINK_TOKEN_LENGTH = 1 94 | 95 | with pytest.warns(RuntimeWarning): 96 | from magiclink import settings 97 | reload(settings) 98 | 99 | 100 | def test_auth_timeout(settings): 101 | settings.MAGICLINK_AUTH_TIMEOUT = 100 102 | from magiclink import settings as mlsettings 103 | reload(mlsettings) 104 | assert mlsettings.AUTH_TIMEOUT == settings.MAGICLINK_AUTH_TIMEOUT 105 | 106 | 107 | def test_auth_timeout_bad_value(settings): 108 | settings.MAGICLINK_AUTH_TIMEOUT = 'Test' 109 | 110 | with pytest.raises(ImproperlyConfigured): 111 | from magiclink import settings 112 | reload(settings) 113 | 114 | 115 | def test_token_uses(settings): 116 | settings.MAGICLINK_TOKEN_USES = 100 117 | from magiclink import settings as mlsettings 118 | reload(mlsettings) 119 | assert mlsettings.TOKEN_USES == settings.MAGICLINK_TOKEN_USES 120 | 121 | 122 | def test_token_uses_bad_value(settings): 123 | settings.MAGICLINK_TOKEN_USES = 'Test' 124 | 125 | with pytest.raises(ImproperlyConfigured): 126 | from magiclink import settings 127 | reload(settings) 128 | 129 | 130 | def test_email_ignore_case(settings): 131 | settings.MAGICLINK_EMAIL_IGNORE_CASE = True 132 | from magiclink import settings as mlsettings 133 | reload(mlsettings) 134 | assert mlsettings.EMAIL_IGNORE_CASE == settings.MAGICLINK_EMAIL_IGNORE_CASE 135 | 136 | 137 | def test_email_ignore_case_bad_value(settings): 138 | settings.MAGICLINK_EMAIL_IGNORE_CASE = 'Test' 139 | 140 | with pytest.raises(ImproperlyConfigured): 141 | from magiclink import settings 142 | reload(settings) 143 | 144 | 145 | def test_require_signup(settings): 146 | settings.MAGICLINK_REQUIRE_SIGNUP = True 147 | from magiclink import settings as mlsettings 148 | reload(mlsettings) 149 | assert mlsettings.REQUIRE_SIGNUP == settings.MAGICLINK_REQUIRE_SIGNUP 150 | 151 | 152 | def test_require_signup_bad_value(settings): 153 | settings.MAGICLINK_REQUIRE_SIGNUP = 'Test' 154 | 155 | with pytest.raises(ImproperlyConfigured): 156 | from magiclink import settings 157 | reload(settings) 158 | 159 | 160 | def test_email_as_username(settings): 161 | settings.MAGICLINK_EMAIL_AS_USERNAME = True 162 | from magiclink import settings as mlsettings 163 | reload(mlsettings) 164 | assert mlsettings.EMAIL_AS_USERNAME == settings.MAGICLINK_EMAIL_AS_USERNAME 165 | 166 | 167 | def test_email_as_username_bad_value(settings): 168 | settings.MAGICLINK_EMAIL_AS_USERNAME = 'Test' 169 | 170 | with pytest.raises(ImproperlyConfigured): 171 | from magiclink import settings 172 | reload(settings) 173 | 174 | 175 | def test_allow_superuser_login(settings): 176 | settings.MAGICLINK_ALLOW_SUPERUSER_LOGIN = True 177 | from magiclink import settings as mlsettings 178 | reload(mlsettings) 179 | assert mlsettings.ALLOW_SUPERUSER_LOGIN == settings.MAGICLINK_ALLOW_SUPERUSER_LOGIN # NOQA: E501 180 | 181 | 182 | def test_allow_superuser_login_bad_value(settings): 183 | settings.MAGICLINK_ALLOW_SUPERUSER_LOGIN = 'Test' 184 | 185 | with pytest.raises(ImproperlyConfigured): 186 | from magiclink import settings 187 | reload(settings) 188 | 189 | 190 | def test_allow_staff_login(settings): 191 | settings.MAGICLINK_ALLOW_STAFF_LOGIN = True 192 | from magiclink import settings as mlsettings 193 | reload(mlsettings) 194 | assert mlsettings.ALLOW_STAFF_LOGIN == settings.MAGICLINK_ALLOW_STAFF_LOGIN 195 | 196 | 197 | def test_allow_staff_login_bad_value(settings): 198 | settings.MAGICLINK_ALLOW_STAFF_LOGIN = 'Test' 199 | 200 | with pytest.raises(ImproperlyConfigured): 201 | from magiclink import settings 202 | reload(settings) 203 | 204 | 205 | def test_ignore_active_flag_bad_value(settings): 206 | settings.MAGICLINK_IGNORE_IS_ACTIVE_FLAG = 'Test' 207 | 208 | with pytest.raises(ImproperlyConfigured): 209 | from magiclink import settings 210 | reload(settings) 211 | 212 | 213 | def test_verify_include_email(settings): 214 | settings.MAGICLINK_VERIFY_INCLUDE_EMAIL = True 215 | from magiclink import settings as mlsettings 216 | reload(mlsettings) 217 | assert mlsettings.VERIFY_INCLUDE_EMAIL == settings.MAGICLINK_VERIFY_INCLUDE_EMAIL # NOQA: E501 218 | 219 | 220 | def test_verify_include_email_bad_value(settings): 221 | settings.MAGICLINK_VERIFY_INCLUDE_EMAIL = 'Test' 222 | 223 | with pytest.raises(ImproperlyConfigured): 224 | from magiclink import settings 225 | reload(settings) 226 | 227 | 228 | def test_require_same_browser(settings): 229 | settings.MAGICLINK_REQUIRE_SAME_BROWSER = True 230 | from magiclink import settings as mlsettings 231 | reload(mlsettings) 232 | assert mlsettings.REQUIRE_SAME_BROWSER == settings.MAGICLINK_REQUIRE_SAME_BROWSER # NOQA: E501 233 | 234 | 235 | def test_require_same_browser_bad_value(settings): 236 | settings.MAGICLINK_REQUIRE_SAME_BROWSER = 'Test' 237 | 238 | with pytest.raises(ImproperlyConfigured): 239 | from magiclink import settings 240 | reload(settings) 241 | 242 | 243 | def test_require_same_ip(settings): 244 | settings.MAGICLINK_REQUIRE_SAME_IP = True 245 | from magiclink import settings as mlsettings 246 | reload(mlsettings) 247 | assert mlsettings.REQUIRE_SAME_IP == settings.MAGICLINK_REQUIRE_SAME_IP 248 | 249 | 250 | def test_require_same_ip_bad_value(settings): 251 | settings.MAGICLINK_REQUIRE_SAME_IP = 'Test' 252 | 253 | with pytest.raises(ImproperlyConfigured): 254 | from magiclink import settings 255 | reload(settings) 256 | 257 | 258 | def test_anonymize_ip(settings): 259 | settings.MAGICLINK_ANONYMIZE_IP = False 260 | from magiclink import settings as mlsettings 261 | reload(mlsettings) 262 | assert mlsettings.ANONYMIZE_IP == settings.MAGICLINK_ANONYMIZE_IP 263 | 264 | 265 | def test_anonymize_ip_bad_value(settings): 266 | settings.MAGICLINK_ANONYMIZE_IP = 'Test' 267 | 268 | with pytest.raises(ImproperlyConfigured): 269 | from magiclink import settings 270 | reload(settings) 271 | 272 | 273 | def test_one_token_per_user(settings): 274 | settings.MAGICLINK_ONE_TOKEN_PER_USER = True 275 | from magiclink import settings as mlsettings 276 | reload(mlsettings) 277 | assert mlsettings.ONE_TOKEN_PER_USER == settings.MAGICLINK_ONE_TOKEN_PER_USER # NOQA: E501 278 | 279 | 280 | def test_one_token_per_user_bad_value(settings): 281 | settings.MAGICLINK_ONE_TOKEN_PER_USER = 'Test' 282 | 283 | with pytest.raises(ImproperlyConfigured): 284 | from magiclink import settings 285 | reload(settings) 286 | 287 | 288 | def test_token_request_time_limit(settings): 289 | settings.MAGICLINK_LOGIN_REQUEST_TIME_LIMIT = True 290 | from magiclink import settings as mlsettings 291 | reload(mlsettings) 292 | assert mlsettings.LOGIN_REQUEST_TIME_LIMIT == settings.MAGICLINK_LOGIN_REQUEST_TIME_LIMIT # NOQA: E501 293 | 294 | 295 | def test_token_request_time_limit_bad_value(settings): 296 | settings.MAGICLINK_LOGIN_REQUEST_TIME_LIMIT = 'Test' 297 | 298 | with pytest.raises(ImproperlyConfigured): 299 | from magiclink import settings 300 | reload(settings) 301 | 302 | 303 | def test_email_styles(settings): 304 | settings.MAGICLINK_EMAIL_STYLES = { 305 | 'logo_url': '', 306 | 'background_color': '#ffffff', 307 | 'main_text_color': '#000000', 308 | 'button_background_color': '#0078be', 309 | 'button_text_color': '#ffffff', 310 | } 311 | from magiclink import settings as mlsettings 312 | reload(mlsettings) 313 | assert mlsettings.EMAIL_STYLES == settings.MAGICLINK_EMAIL_STYLES 314 | 315 | 316 | def test_email_styles_bad_value(settings): 317 | settings.MAGICLINK_EMAIL_STYLES = 'Test' 318 | 319 | with pytest.raises(ImproperlyConfigured): 320 | from magiclink import settings 321 | reload(settings) 322 | 323 | 324 | def test_antispam_forms(settings): 325 | settings.MAGICLINK_ANTISPAM_FORMS = True 326 | from magiclink import settings as mlsettings 327 | reload(mlsettings) 328 | assert mlsettings.ANTISPAM_FORMS == settings.MAGICLINK_ANTISPAM_FORMS 329 | 330 | 331 | def test_antispam_forms_bad_value(settings): 332 | settings.MAGICLINK_ANTISPAM_FORMS = 'Test' 333 | 334 | with pytest.raises(ImproperlyConfigured): 335 | from magiclink import settings 336 | reload(settings) 337 | 338 | 339 | def test_antispam_form_submit_time(settings): 340 | settings.MAGICLINK_ANTISPAM_FIELD_TIME = 5 341 | from magiclink import settings as mlsettings 342 | reload(mlsettings) 343 | assert mlsettings.ANTISPAM_FIELD_TIME == settings.MAGICLINK_ANTISPAM_FIELD_TIME # NOQA: E501 344 | 345 | 346 | def test_antispam_form_submit_time_bad_value(settings): 347 | settings.MAGICLINK_ANTISPAM_FIELD_TIME = 'Test' 348 | 349 | with pytest.raises(ImproperlyConfigured): 350 | from magiclink import settings 351 | reload(settings) 352 | 353 | 354 | def test_login_verify_url(settings): 355 | settings.MAGICLINK_LOGIN_VERIFY_URL = 'custom_login_verify' 356 | from magiclink import settings as mlsettings 357 | reload(mlsettings) 358 | assert mlsettings.LOGIN_VERIFY_URL == settings.MAGICLINK_LOGIN_VERIFY_URL 359 | 360 | 361 | def test_ignore_unsubscribe_if_user(settings): 362 | settings.MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER = True 363 | from magiclink import settings as mlsettings 364 | reload(mlsettings) 365 | assert mlsettings.IGNORE_UNSUBSCRIBE_IF_USER == settings.MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER # NOQA: E501 366 | 367 | 368 | def test_ignore_unsubscribe_if_user_bad_value(settings): 369 | settings.MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER = 'Test' 370 | 371 | with pytest.raises(ImproperlyConfigured): 372 | from magiclink import settings 373 | reload(settings) 374 | -------------------------------------------------------------------------------- /tests/test_signup.py: -------------------------------------------------------------------------------- 1 | from importlib import reload 2 | from time import time 3 | 4 | import pytest 5 | from django.contrib.auth import get_user_model 6 | from django.urls import reverse 7 | 8 | from magiclink.models import MagicLink, MagicLinkUnsubscribe 9 | 10 | User = get_user_model() 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_signup_end_to_end(mocker, settings, client): 15 | from magiclink import settings as mlsettings 16 | spy = mocker.spy(MagicLink, 'generate_url') 17 | 18 | login_url = reverse('magiclink:signup') 19 | email = 'test@example.com' 20 | first_name = 'test' 21 | last_name = 'name' 22 | data = { 23 | 'form_name': 'SignupForm', 24 | 'email': email, 25 | 'name': f'{first_name} {last_name}', 26 | } 27 | client.post(login_url, data, follow=True) 28 | verify_url = spy.spy_return 29 | response = client.get(verify_url, follow=True) 30 | assert response.status_code == 200 31 | signup_redirect_page = reverse(mlsettings.SIGNUP_LOGIN_REDIRECT) 32 | assert response.request['PATH_INFO'] == signup_redirect_page 33 | user = User.objects.get(email=email) 34 | assert user.first_name == first_name 35 | assert user.last_name == last_name 36 | 37 | url = reverse('magiclink:logout') 38 | response = client.get(url, follow=True) 39 | assert response.status_code == 200 40 | assert response.request['PATH_INFO'] == reverse('no_login') 41 | 42 | url = reverse('needs_login') 43 | response = client.get(url, follow=True) 44 | assert response.status_code == 200 45 | assert response.request['PATH_INFO'] == reverse('magiclink:login') 46 | 47 | 48 | def test_signup_get(client): 49 | url = reverse('magiclink:signup') 50 | response = client.get(url) 51 | assert response.context_data['SignupForm'] 52 | assert response.context_data['SignupFormEmailOnly'] 53 | assert response.context_data['SignupFormWithUsername'] 54 | assert response.context_data['SignupFormFull'] 55 | assert response.status_code == 200 56 | 57 | 58 | @pytest.mark.django_db 59 | def test_signup_post(mocker, client, settings): # NOQA: F811 60 | from magiclink import settings as mlsettings 61 | send_mail = mocker.patch('magiclink.models.send_mail') 62 | 63 | url = reverse('magiclink:signup') 64 | email = 'test@example.com' 65 | data = { 66 | 'form_name': 'SignupForm', 67 | 'email': email, 68 | 'name': 'testname', 69 | } 70 | response = client.post(url, data) 71 | assert response.status_code == 302 72 | assert response.url == reverse('magiclink:login_sent') 73 | 74 | usr = User.objects.get(email=email) 75 | assert usr 76 | magiclink = MagicLink.objects.get(email=email) 77 | assert magiclink 78 | if mlsettings.REQUIRE_SAME_BROWSER: 79 | cookie_name = f'magiclink{magiclink.pk}' 80 | assert response.cookies[cookie_name].value == magiclink.cookie_value 81 | 82 | send_mail.assert_called_once_with( 83 | subject=mlsettings.EMAIL_SUBJECT, 84 | message=mocker.ANY, 85 | recipient_list=[email], 86 | from_email=settings.DEFAULT_FROM_EMAIL, 87 | html_message=mocker.ANY, 88 | ) 89 | 90 | 91 | @pytest.mark.django_db 92 | def test_signup_signup_form_missing_name(mocker, client, settings): # NOQA: F811, E501 93 | url = reverse('magiclink:signup') 94 | data = { 95 | 'form_name': 'SignupForm', 96 | 'email': 'test@example.com', 97 | } 98 | response = client.post(url, data) 99 | assert response.status_code == 200 100 | error = ['This field is required.'] 101 | assert response.context_data['SignupForm'].errors['name'] == error 102 | 103 | 104 | @pytest.mark.django_db 105 | def test_signup_form_user_exists(mocker, client): 106 | email = 'test@example.com' 107 | User.objects.create(email=email) 108 | url = reverse('magiclink:signup') 109 | 110 | data = { 111 | 'form_name': 'SignupFormEmailOnly', 112 | 'email': email, 113 | } 114 | response = client.post(url, data) 115 | assert response.status_code == 200 116 | error = ['Email address is already linked to an account'] 117 | response.context_data['SignupFormEmailOnly'].errors['email'] == error 118 | 119 | 120 | @pytest.mark.django_db 121 | def test_signup_form_user_exists_inactive(mocker, client): 122 | email = 'test@example.com' 123 | User.objects.create(email=email, is_active=False) 124 | url = reverse('magiclink:signup') 125 | 126 | data = { 127 | 'form_name': 'SignupFormEmailOnly', 128 | 'email': email, 129 | } 130 | response = client.post(url, data) 131 | assert response.status_code == 200 132 | error = ['This user has been deactivated'] 133 | response.context_data['SignupFormEmailOnly'].errors['email'] == error 134 | 135 | 136 | @pytest.mark.django_db 137 | def test_signup_form_user_exists_ignore_active_flag(mocker, client, settings): 138 | settings.MAGICLINK_IGNORE_IS_ACTIVE_FLAG = True 139 | from magiclink import settings as mlsettings 140 | reload(mlsettings) 141 | 142 | email = 'test@example.com' 143 | User.objects.create(email=email, is_active=False) 144 | url = reverse('magiclink:signup') 145 | 146 | data = { 147 | 'form_name': 'SignupFormEmailOnly', 148 | 'email': email, 149 | } 150 | response = client.post(url, data) 151 | assert response.status_code == 200 152 | error = ['Email address is already linked to an account'] 153 | response.context_data['SignupFormEmailOnly'].errors['email'] == error 154 | 155 | 156 | @pytest.mark.django_db 157 | def test_signup_form_email_only(mocker, client): 158 | url = reverse('magiclink:signup') 159 | email = 'test@example.com' 160 | data = { 161 | 'form_name': 'SignupFormEmailOnly', 162 | 'email': email, 163 | } 164 | response = client.post(url, data) 165 | assert response.status_code == 302 166 | assert response.url == reverse('magiclink:login_sent') 167 | 168 | 169 | @pytest.mark.django_db 170 | def test_signup_form_with_username(mocker, client): 171 | url = reverse('magiclink:signup') 172 | email = 'test@example.com' 173 | username = 'usrname' 174 | data = { 175 | 'form_name': 'SignupFormWithUsername', 176 | 'email': email, 177 | 'username': username, 178 | } 179 | response = client.post(url, data) 180 | assert response.status_code == 302 181 | assert response.url == reverse('magiclink:login_sent') 182 | usr = User.objects.get(email=email) 183 | assert usr.username == username 184 | 185 | 186 | @pytest.mark.django_db 187 | def test_signup_form_with_username_taken(mocker, client): 188 | username = 'usrname' 189 | email = 'test@example.com' 190 | User.objects.create(username=username, email=email) 191 | url = reverse('magiclink:signup') 192 | data = { 193 | 'form_name': 'SignupFormWithUsername', 194 | 'email': email, 195 | 'username': username, 196 | } 197 | response = client.post(url, data) 198 | assert response.status_code == 200 199 | error = ['username is already linked to an account'] 200 | response.context_data['SignupFormWithUsername'].errors['username'] == error 201 | 202 | 203 | @pytest.mark.django_db 204 | def test_signup_form_with_username_required(mocker, client): 205 | username = 'usrname' 206 | email = 'test@example.com' 207 | User.objects.create(username=username, email=email) 208 | url = reverse('magiclink:signup') 209 | data = { 210 | 'form_name': 'SignupFormWithUsername', 211 | 'email': email, 212 | } 213 | response = client.post(url, data) 214 | assert response.status_code == 200 215 | error = ['This field is required.'] 216 | response.context_data['SignupFormWithUsername'].errors['username'] == error 217 | 218 | 219 | @pytest.mark.django_db 220 | def test_signup_form_full(mocker, client): 221 | url = reverse('magiclink:signup') 222 | email = 'test@example.com' 223 | username = 'usrname' 224 | first_name = 'fname' 225 | last_name = 'lname' 226 | data = { 227 | 'form_name': 'SignupFormFull', 228 | 'email': email, 229 | 'username': username, 230 | 'name': f'{first_name} {last_name}' 231 | } 232 | response = client.post(url, data) 233 | assert response.status_code == 302 234 | assert response.url == reverse('magiclink:login_sent') 235 | usr = User.objects.get(email=email) 236 | assert usr.username == username 237 | assert usr.first_name == first_name 238 | assert usr.last_name == last_name 239 | 240 | 241 | @pytest.mark.django_db 242 | def test_signup_form_invalid_name(mocker, client): 243 | url = reverse('magiclink:signup') 244 | email = 'test@example.com' 245 | data = { 246 | 'form_name': 'FakeName', 247 | 'email': email, 248 | } 249 | response = client.post(url, data) 250 | assert response.status_code == 302 251 | assert response.url == reverse('magiclink:signup') 252 | 253 | 254 | @pytest.mark.django_db 255 | def test_signup_antispam(settings, client, freezer): # NOQA: F811 256 | freezer.move_to('2000-01-01T00:00:00') 257 | 258 | submit_time = 1 259 | settings.MAGICLINK_ANTISPAM_FORMS = True 260 | settings.MAGICLINK_ANTISPAM_FIELD_TIME = submit_time 261 | from magiclink import settings as mlsettings 262 | reload(mlsettings) 263 | 264 | url = reverse('magiclink:signup') 265 | signup_form = 'SignupFormFull' 266 | email = 'test@example.com' 267 | data = { 268 | 'form_name': signup_form, 269 | 'email': email, 270 | 'username': 'uname', 271 | 'name': 'Fake name', 272 | 'load_time': time() - (submit_time * 3), 273 | } 274 | response = client.post(url, data) 275 | assert response.status_code == 302 276 | assert response.url == reverse('magiclink:login_sent') 277 | 278 | 279 | @pytest.mark.django_db 280 | def test_signup_antispam_submit_too_fast(settings, client, freezer): # NOQA: F811,E501 281 | freezer.move_to('2000-01-01T00:00:00') 282 | 283 | settings.MAGICLINK_ANTISPAM_FORMS = True 284 | from magiclink import settings as mlsettings 285 | reload(mlsettings) 286 | 287 | url = reverse('magiclink:signup') 288 | signup_form = 'SignupFormEmailOnly' 289 | data = { 290 | 'form_name': signup_form, 291 | 'email': 'test@example.com', 292 | 'load_time': time() 293 | } 294 | 295 | response = client.post(url, data) 296 | assert response.status_code == 200 297 | form_errors = response.context[signup_form].errors 298 | assert form_errors['load_time'] == ['Form filled out too fast - bot detected'] # NOQA: E501 299 | 300 | 301 | @pytest.mark.django_db 302 | def test_signup_antispam_missing_load_time(settings, client): # NOQA: F811 303 | settings.MAGICLINK_ANTISPAM_FORMS = True 304 | from magiclink import settings as mlsettings 305 | reload(mlsettings) 306 | 307 | url = reverse('magiclink:signup') 308 | signup_form = 'SignupFormEmailOnly' 309 | data = {'form_name': signup_form, 'email': 'test@example.com'} 310 | 311 | response = client.post(url, data) 312 | assert response.status_code == 200 313 | form_errors = response.context[signup_form].errors 314 | assert form_errors['load_time'] == ['This field is required.'] 315 | 316 | 317 | @pytest.mark.django_db 318 | def test_signup_antispam_invalid_load_time(settings, client): # NOQA: F811 319 | settings.MAGICLINK_ANTISPAM_FORMS = True 320 | from magiclink import settings as mlsettings 321 | reload(mlsettings) 322 | 323 | url = reverse('magiclink:signup') 324 | signup_form = 'SignupFormEmailOnly' 325 | data = { 326 | 'form_name': signup_form, 327 | 'email': 'test@example.com', 328 | 'load_time': 'test' 329 | } 330 | 331 | response = client.post(url, data) 332 | assert response.status_code == 200 333 | form_errors = response.context[signup_form].errors 334 | assert form_errors['load_time'] == ['Invalid value'] 335 | 336 | 337 | @pytest.mark.django_db 338 | def test_signup_antispam_url_value(settings, client): # NOQA: F811 339 | settings.MAGICLINK_ANTISPAM_FORMS = True 340 | from magiclink import settings as mlsettings 341 | reload(mlsettings) 342 | 343 | url = reverse('magiclink:signup') 344 | signup_form = 'SignupFormEmailOnly' 345 | data = { 346 | 'form_name': signup_form, 347 | 'email': 'test@example.com', 348 | 'url': 'test', 349 | } 350 | response = client.post(url, data) 351 | assert response.status_code == 200 352 | form_errors = response.context[signup_form].errors 353 | assert form_errors['url'] == ['url should be empty'] 354 | 355 | 356 | @pytest.mark.django_db 357 | def test_signup_email_in_unsubscribe(client): 358 | email = 'test@example.com' 359 | MagicLinkUnsubscribe.objects.create(email=email) 360 | 361 | url = reverse('magiclink:signup') 362 | signup_form = 'SignupFormEmailOnly' 363 | data = { 364 | 'form_name': signup_form, 365 | 'email': email, 366 | 'url': 'test', 367 | } 368 | response = client.post(url, data) 369 | assert response.status_code == 200 370 | form_errors = response.context[signup_form].errors 371 | assert form_errors['email'] == ['Email address is on the unsubscribe list'] 372 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | 3 | from magiclink.utils import get_client_ip, get_url_path 4 | 5 | 6 | def test_get_client_ip_http_x_forwarded_for(): 7 | request = HttpRequest() 8 | ip_addr = '127.0.0.1' 9 | request.META['HTTP_X_FORWARDED_FOR'] = f'{ip_addr}, 127.0.0.1' 10 | ip_address = get_client_ip(request) 11 | assert ip_address == ip_addr 12 | 13 | 14 | def test_get_client_ip_remote_addr(): 15 | request = HttpRequest() 16 | remote_addr = '127.0.0.1' 17 | request.META['REMOTE_ADDR'] = remote_addr 18 | ip_address = get_client_ip(request) 19 | assert ip_address == remote_addr 20 | 21 | 22 | def test_get_url_path_with_name(): 23 | url_name = 'no_login' 24 | url = get_url_path(url_name) 25 | assert url == '/no-login/' 26 | 27 | 28 | def test_get_url_path_with_path(): 29 | url_name = '/test/' 30 | url = get_url_path(url_name) 31 | assert url == '/test/' 32 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.http.response import HttpResponse, HttpResponseRedirect 3 | from django.urls import include, path, reverse 4 | 5 | from magiclink.views import LoginVerify 6 | 7 | 8 | @login_required 9 | def needs_login(request): 10 | return HttpResponse() 11 | 12 | 13 | def no_login(request): 14 | return HttpResponse() 15 | 16 | 17 | class CustomLoginVerify(LoginVerify): 18 | 19 | def login_complete_action(self) -> HttpResponse: 20 | url = reverse('no_login') 21 | return HttpResponseRedirect(url) 22 | 23 | 24 | urlpatterns = [ 25 | path('no-login/', no_login, name='no_login'), 26 | path('needs-login/', needs_login, name='needs_login'), 27 | path('custom-login-verify/', CustomLoginVerify.as_view(), name='custom_login_verify'), # NOQA: E501 28 | path('auth/', include('magiclink.urls', namespace='magiclink')), 29 | ] 30 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | lint 5 | mypy 6 | python3.8-django{32,40,41,42} 7 | python3.9-django{32,40,41,42} 8 | python3.10-django{32,40,41,42} 9 | python3.11-django{32,40,41,42} 10 | 11 | 12 | [testenv] 13 | deps = 14 | django32: Django>=3.2,<3.3 15 | django40: Django>=4.0,<4.1 16 | django41: Django>=4.1,<4.2 17 | django41: Django>=4.2,<4.3 18 | pytest-cov 19 | pytest-mock 20 | pytest-django 21 | pytest-freezegun 22 | commands = 23 | pytest tests/ 24 | 25 | 26 | [testenv:lint] 27 | deps = 28 | flake8 29 | isort[pyproject] 30 | commands = 31 | flake8 ./ 32 | isort --check ./ 33 | 34 | 35 | [testenv:mypy] 36 | deps = 37 | packaging 38 | mypy 39 | django-stubs 40 | commands = 41 | mypy magiclink/ 42 | 43 | 44 | [pytest] 45 | DJANGO_SETTINGS_MODULE = tests.settings 46 | python_files = tests.py test_*.py *_tests.py 47 | addopts = 48 | --cov=magiclink 49 | --cov-report html 50 | --cov-report term-missing 51 | 52 | 53 | [flake8] 54 | exclude = .git,*/migrations/*,.tox 55 | --------------------------------------------------------------------------------