Anwendungsspezifische Passwörter (#110)
Fixes #107. Co-authored-by: Tobias Bölz <tobias@boelz.eu> Co-authored-by: Tobias Bölz <tobias@boelz.eu> Reviewed-on: foerderverein/antragsverwaltung#110pull/112/head
parent
c3a1a91852
commit
0302e14e3f
@ -1,3 +1,7 @@
|
||||
{
|
||||
"python.pythonPath": "venv/bin/python"
|
||||
"python.testing.pytestArgs": [
|
||||
"."
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
}
|
||||
|
@ -0,0 +1,35 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.forms import ReadOnlyPasswordHashField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import AppSpecificPassword
|
||||
|
||||
|
||||
class AppSpecificPasswordAddForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = AppSpecificPassword
|
||||
fields = ['user', 'name', 'password']
|
||||
|
||||
|
||||
class AppSpecificPasswordChangeForm(AppSpecificPasswordAddForm):
|
||||
password = ReadOnlyPasswordHashField(
|
||||
label=_('Password'),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(AppSpecificPassword)
|
||||
class AppSpecificPasswordAdmin(admin.ModelAdmin):
|
||||
form = AppSpecificPasswordChangeForm
|
||||
add_form = AppSpecificPasswordAddForm
|
||||
list_display = ('name', 'user')
|
||||
list_filter = ('user',)
|
||||
raw_id_fields = ('user',)
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
"""Switch form when adding a new object."""
|
||||
defaults = {}
|
||||
if not obj:
|
||||
defaults['form'] = self.add_form
|
||||
defaults.update(kwargs)
|
||||
return super().get_form(request, obj, **defaults)
|
@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class AppSpecificPasswordsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'appspecificpasswords'
|
||||
verbose_name = _('App Specific Passwords')
|
@ -0,0 +1,30 @@
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.forms import ModelForm, ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import AppSpecificPassword
|
||||
|
||||
|
||||
class AppSpecificPasswordForm(ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_name(self):
|
||||
name = self.cleaned_data.get('name')
|
||||
if name and AppSpecificPassword.objects \
|
||||
.filter(name=name, user=self.user).exists():
|
||||
raise ValidationError(
|
||||
_('An app specific password with this name already exists.')
|
||||
)
|
||||
return name
|
||||
|
||||
def clean_password(self):
|
||||
password = self.cleaned_data.get('password')
|
||||
if password: # pragma: no branch
|
||||
validate_password(password, self.user)
|
||||
return password
|
||||
|
||||
class Meta:
|
||||
model = AppSpecificPassword
|
||||
fields = ('name', 'password')
|
@ -0,0 +1,34 @@
|
||||
# Generated by Django 3.2.13 on 2022-05-23 11:45
|
||||
|
||||
import appspecificpasswords.models
|
||||
import appspecificpasswords.utils
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AppSpecificPassword',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255, verbose_name='Name')),
|
||||
('password', appspecificpasswords.models.PasswordField(default=appspecificpasswords.utils.generate_password, max_length=255, verbose_name='Password')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'App Specific Password',
|
||||
'verbose_name_plural': 'App Specific Passwords',
|
||||
'unique_together': {('user', 'name')},
|
||||
},
|
||||
),
|
||||
]
|
@ -0,0 +1,48 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .utils import generate_password
|
||||
|
||||
|
||||
class PasswordField(models.CharField):
|
||||
def pre_save(self, model_instance, add):
|
||||
if not add:
|
||||
return super().pre_save(model_instance, add)
|
||||
password = getattr(model_instance, self.attname)
|
||||
encoded = make_password(password)
|
||||
setattr(model_instance, self.attname, encoded)
|
||||
return encoded
|
||||
|
||||
|
||||
class AppSpecificPassword(models.Model):
|
||||
id = models.UUIDField(
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_('User')
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_('Name')
|
||||
)
|
||||
password = PasswordField(
|
||||
max_length=255,
|
||||
default=generate_password,
|
||||
verbose_name=_('Password')
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
unique_together = ['user', 'name']
|
||||
verbose_name = _('App Specific Password')
|
||||
verbose_name_plural = _('App Specific Passwords')
|
@ -0,0 +1,21 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% translate 'Delete App Specific Password' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content">
|
||||
<h1>{% translate 'Delete App Specific Password' %}</h1>
|
||||
|
||||
<form method="post">{% csrf_token %}
|
||||
<p>
|
||||
{% blocktranslate %}Are you sure you want to delete the app specific password “{{ object }}”?{% endblocktranslate%}
|
||||
</p>
|
||||
<input type="submit" value="{% translate 'Delete' %}" class="btn btn-danger float-right ml-2">
|
||||
<a href="{% url 'appspecificpasswords:list' %}" class="btn btn-light float-right" role="button">{% translate 'Cancel' %}</a>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% endblock content %}
|
@ -0,0 +1,21 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% translate 'Add App Specific Password' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content">
|
||||
<h1>{% translate 'Add App Specific Password' %}</h1>
|
||||
|
||||
<form method="post">{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<input type="submit" value="{% translate 'Add' %}" class="btn btn-primary float-right ml-2">
|
||||
<a href="{% url 'appspecificpasswords:list' %}" class="btn btn-light float-right" role="button">
|
||||
{% translate 'Cancel' %}
|
||||
</a>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% endblock content %}
|
@ -0,0 +1,49 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% translate 'App Specific Passwords' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content">
|
||||
<h1>{% translate 'App Specific Passwords' %}</h1>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% translate 'Name' %}</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for object in object_list %}
|
||||
<tr>
|
||||
<td class="align-middle">{{ object.name }}</td>
|
||||
<td>
|
||||
<span style="float: right;">
|
||||
{% if perms.appspecificpasswords.delete_appspecificpassword %}
|
||||
<a href="{% url 'appspecificpasswords:delete' object.id %}" class="btn btn-danger" role="button">{% translate 'Delete' %}</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="2" class="text-center">
|
||||
{% translate 'No App Specific Passwords' %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if perms.appspecificpasswords.add_appspecificpassword %}
|
||||
<p>
|
||||
<a href="{% url 'appspecificpasswords:create' %}" class="btn btn-primary">
|
||||
{% translate 'Add App Specific Password' %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock content %}
|
@ -0,0 +1,8 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(django_user_model):
|
||||
"""User with username test."""
|
||||
user = django_user_model.objects.create(username='test')
|
||||
return user
|
@ -0,0 +1,60 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.contrib.auth.forms import ReadOnlyPasswordHashField
|
||||
from django.forms import CharField
|
||||
|
||||
from ..admin import (AppSpecificPasswordAddForm, AppSpecificPasswordAdmin,
|
||||
AppSpecificPasswordChangeForm)
|
||||
from ..models import AppSpecificPassword
|
||||
|
||||
|
||||
def test_app_specific_password_add_form(test_user):
|
||||
form = AppSpecificPasswordAddForm(data={
|
||||
'user': test_user,
|
||||
'name': 'foo',
|
||||
'password': '92ml.(L_[U}-OF(M-eZ3B0515Kiym#!a',
|
||||
})
|
||||
assert isinstance(form.fields['password'], CharField)
|
||||
assert form.is_valid()
|
||||
|
||||
|
||||
def test_app_specific_password_change_form():
|
||||
form = AppSpecificPasswordChangeForm()
|
||||
assert isinstance(form.fields['password'], ReadOnlyPasswordHashField)
|
||||
|
||||
|
||||
@patch('appspecificpasswords.admin.admin.ModelAdmin.get_form')
|
||||
def test_app_specific_password_admin_get_change_form(mock_super_get_form,
|
||||
test_user, rf):
|
||||
app_specific_password = AppSpecificPassword.objects.create(
|
||||
user=test_user,
|
||||
name='Test',
|
||||
password='password',
|
||||
)
|
||||
admin = AppSpecificPasswordAdmin(
|
||||
model=AppSpecificPassword,
|
||||
admin_site=AdminSite()
|
||||
)
|
||||
request = rf.get('/doesnt_matter')
|
||||
admin.get_form(request, obj=app_specific_password)
|
||||
mock_super_get_form.assert_called_once_with(
|
||||
request,
|
||||
app_specific_password
|
||||
)
|
||||
|
||||
|
||||
@patch('appspecificpasswords.admin.admin.ModelAdmin.get_form')
|
||||
def test_app_specific_password_admin_get_add_form(mock_model_admin_get_form,
|
||||
rf):
|
||||
admin = AppSpecificPasswordAdmin(
|
||||
model=AppSpecificPassword,
|
||||
admin_site=AdminSite()
|
||||
)
|
||||
request = rf.get('/doesnt_matter')
|
||||
admin.get_form(request, obj=None)
|
||||
mock_model_admin_get_form.assert_called_once_with(
|
||||
request,
|
||||
None,
|
||||
form=admin.add_form
|
||||
)
|
@ -0,0 +1,47 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from ..forms import AppSpecificPasswordForm
|
||||
from ..models import AppSpecificPassword
|
||||
|
||||
|
||||
def test_app_specific_password_form(test_user):
|
||||
form = AppSpecificPasswordForm(data={
|
||||
'name': 'foo',
|
||||
'password': '92ml.(L_[U}-OF(M-eZ3B0515Kiym#!a'
|
||||
},
|
||||
user=test_user
|
||||
)
|
||||
assert form.is_valid()
|
||||
|
||||
|
||||
@patch('appspecificpasswords.forms.validate_password')
|
||||
def test_app_specific_password_form_validate_password(mock_validate_password,
|
||||
test_user):
|
||||
mock_validate_password.side_effect = ValidationError(['foobar'])
|
||||
form = AppSpecificPasswordForm(data={
|
||||
'name': 'foo',
|
||||
'password': 'password'
|
||||
},
|
||||
user=test_user
|
||||
)
|
||||
assert not form.is_valid()
|
||||
mock_validate_password.assert_called_once_with('password', test_user)
|
||||
assert form.errors['password'] == ['foobar']
|
||||
|
||||
|
||||
def test_app_specific_password_form_name_already_exists(test_user):
|
||||
AppSpecificPassword.objects.create(
|
||||
user=test_user,
|
||||
name='foo',
|
||||
password='password'
|
||||
)
|
||||
form = AppSpecificPasswordForm(data={
|
||||
'name': 'foo',
|
||||
'password': '92ml.(L_[U}-OF(M-eZ3B0515Kiym#!a'
|
||||
},
|
||||
user=test_user
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors['name']
|
@ -0,0 +1,43 @@
|
||||
import pytest
|
||||
from django.contrib.auth.hashers import check_password
|
||||
|
||||
from ..models import AppSpecificPassword
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_specific_password(test_user):
|
||||
password = AppSpecificPassword.objects.create(
|
||||
user=test_user,
|
||||
name='foo',
|
||||
password='password'
|
||||
)
|
||||
return password
|
||||
|
||||
|
||||
def test_app_specific_password_str(app_specific_password):
|
||||
assert str(app_specific_password) == 'foo'
|
||||
|
||||
|
||||
def test_app_specific_password_user(app_specific_password):
|
||||
assert app_specific_password.user.username == 'test'
|
||||
|
||||
|
||||
def test_app_specific_password_name(app_specific_password):
|
||||
assert app_specific_password.name == 'foo'
|
||||
|
||||
|
||||
def test_app_specific_password_password_is_encoded(app_specific_password):
|
||||
assert app_specific_password.password != 'password'
|
||||
|
||||
|
||||
def test_app_specific_password_check_password(app_specific_password):
|
||||
assert check_password('password', app_specific_password.password)
|
||||
|
||||
|
||||
def test_app_specific_password_not_encoded_twice(app_specific_password):
|
||||
encoded = app_specific_password.password
|
||||
app_specific_password.name = 'bar'
|
||||
app_specific_password.save()
|
||||
assert app_specific_password.password == encoded
|
@ -0,0 +1,16 @@
|
||||
import pytest
|
||||
|
||||
from ..utils import generate_password
|
||||
|
||||
|
||||
def test_generate_password():
|
||||
password = generate_password(length=42)
|
||||
assert len(password) == 42
|
||||
assert any(c.islower() for c in password)
|
||||
assert any(c.isupper() for c in password)
|
||||
assert any(c.isdigit() for c in password)
|
||||
|
||||
|
||||
def test_generate_password_too_short():
|
||||
with pytest.raises(ValueError):
|
||||
generate_password(length=1)
|
@ -0,0 +1,204 @@
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from pytest_django.asserts import assertQuerysetEqual, assertTemplateUsed
|
||||
|
||||
from ..models import AppSpecificPassword
|
||||
from ..views import AppSpecificPasswordCreateView
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_specific_password(test_user):
|
||||
password = AppSpecificPassword.objects.create(
|
||||
id=UUID(int=0),
|
||||
user=test_user,
|
||||
name='foo',
|
||||
password='password'
|
||||
)
|
||||
return password
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_permission():
|
||||
def func(permission):
|
||||
app_label, codename = permission.split('.', maxsplit=1)
|
||||
return Permission.objects.get(
|
||||
content_type__app_label=app_label,
|
||||
codename=codename
|
||||
)
|
||||
return func
|
||||
|
||||
|
||||
class TestAppSpecificPasswordsListView:
|
||||
@pytest.mark.urls('appspecificpasswords.urls')
|
||||
def test_get_view(self, client):
|
||||
response = client.get('/')
|
||||
assert response.status_code == 302
|
||||
assert response.url.startswith('/accounts/login')
|
||||
|
||||
def test_get_view_by_name(self, client):
|
||||
response = client.get(reverse('appspecificpasswords:list'))
|
||||
assert response.status_code == 302
|
||||
assert response.url.startswith('/accounts/login')
|
||||
|
||||
def test_get_view_access_denied(self, client, test_user):
|
||||
client.force_login(test_user)
|
||||
response = client.get(reverse('appspecificpasswords:list'))
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_get_view_with_permission(self, client, test_user, get_permission):
|
||||
test_user.user_permissions.add(get_permission(
|
||||
'appspecificpasswords.view_appspecificpassword'
|
||||
))
|
||||
client.force_login(test_user)
|
||||
response = client.get(reverse('appspecificpasswords:list'))
|
||||
assert response.status_code == 200
|
||||
assertTemplateUsed(
|
||||
response,
|
||||
'appspecificpasswords/appspecificpassword_list.html'
|
||||
)
|
||||
|
||||
def test_queryset(self, client, test_user, get_permission,
|
||||
app_specific_password):
|
||||
test_user.user_permissions.add(get_permission(
|
||||
'appspecificpasswords.view_appspecificpassword'
|
||||
))
|
||||
client.force_login(test_user)
|
||||
response = client.get(reverse('appspecificpasswords:list'))
|
||||
assertQuerysetEqual(
|
||||
response.context_data['object_list'],
|
||||
[app_specific_password]
|
||||
)
|
||||
|
||||
def test_queryset_other_user(self, client, test_user, get_permission,
|
||||
app_specific_password, django_user_model):
|
||||
other_user = django_user_model.objects.create(username='other')
|
||||
app_specific_password.user = other_user
|
||||
app_specific_password.save()
|
||||
test_user.user_permissions.add(get_permission(
|
||||
'appspecificpasswords.view_appspecificpassword'
|
||||
))
|
||||
client.force_login(test_user)
|
||||
response = client.get(reverse('appspecificpasswords:list'))
|
||||
assertQuerysetEqual(
|
||||
response.context_data['object_list'],
|
||||
[]
|
||||
)
|
||||
|
||||
|
||||
class TestAppSpecificPasswordCreateView:
|
||||
@pytest.mark.urls('appspecificpasswords.urls')
|
||||
def test_get_view(self, client):
|
||||
response = client.get('/create/')
|
||||
assert response.status_code == 302
|
||||
assert response.url.startswith('/accounts/login')
|
||||
|
||||
def test_get_view_by_name(self, client):
|
||||
response = client.get(reverse('appspecificpasswords:create'))
|
||||
assert response.status_code == 302
|
||||
assert response.url.startswith('/accounts/login')
|
||||
|
||||
def test_get_view_access_denied(self, client, test_user):
|
||||
client.force_login(test_user)
|
||||
response = client.get(reverse('appspecificpasswords:create'))
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_get_view_with_permission(self, client, test_user, get_permission):
|
||||
client.force_login(test_user)
|
||||
view_permission = get_permission(
|
||||
'appspecificpasswords.add_appspecificpassword'
|
||||
)
|
||||
test_user.user_permissions.add(view_permission)
|
||||
response = client.get(reverse('appspecificpasswords:create'))
|
||||
assert response.status_code == 200
|
||||
assertTemplateUsed(
|
||||
response,
|
||||
'appspecificpasswords/appspecificpassword_form.html'
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create(self, rf, test_user, get_permission):
|
||||
test_user.user_permissions.add(
|
||||
get_permission('appspecificpasswords.add_appspecificpassword')
|
||||
)
|
||||
request = rf.post(
|
||||
reverse('appspecificpasswords:create'),
|
||||
data={
|
||||
'name': 'foo',
|
||||
'password': '92ml.(L_[U}-OF(M-eZ3B0515Kiym#!a'
|
||||
}
|
||||
)
|
||||
request.user = test_user
|
||||
response = AppSpecificPasswordCreateView.as_view()(request)
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse('appspecificpasswords:list')
|
||||
assert AppSpecificPassword.objects.filter(user=test_user, name='foo') \
|
||||
.exists()
|
||||
|
||||
|
||||
class TestAppSpecificPasswordDeleteView:
|
||||
@pytest.mark.urls('appspecificpasswords.urls')
|
||||
def test_get_view(self, client):
|
||||
response = client.get('/00000000-0000-0000-0000-000000000000/delete/')
|
||||
assert response.status_code == 302
|
||||
assert response.url.startswith('/accounts/login')
|
||||
|
||||
def test_get_view_by_name(self, client):
|
||||
response = client.get(
|
||||
reverse('appspecificpasswords:delete', args=[UUID(int=42)])
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response.url.startswith('/accounts/login')
|
||||
|
||||
def test_get_view_access_denied(self, client, test_user,
|
||||
app_specific_password):
|
||||
client.force_login(test_user)
|
||||
response = client.get(
|
||||
reverse('appspecificpasswords:delete', args=[UUID(int=0)])
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_get_view_with_permission(self, client, test_user, get_permission,
|
||||
app_specific_password):
|
||||
test_user.user_permissions.add(
|
||||
get_permission('appspecificpasswords.delete_appspecificpassword')
|
||||
)
|
||||
client.force_login(test_user)
|
||||
response = client.get(
|
||||
reverse('appspecificpasswords:delete', args=[UUID(int=0)])
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assertTemplateUsed(
|
||||
response,
|
||||
'appspecificpasswords/appspecificpassword_confirm_delete.html'
|
||||
)
|
||||
|
||||
def test_delete(self, client, test_user, get_permission,
|
||||
app_specific_password):
|
||||
test_user.user_permissions.add(
|
||||
get_permission('appspecificpasswords.delete_appspecificpassword')
|
||||
)
|
||||
client.force_login(test_user)
|
||||
response = client.post(
|
||||
reverse('appspecificpasswords:delete', args=[UUID(int=0)])
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response.url == reverse('appspecificpasswords:list')
|
||||
assert app_specific_password not in AppSpecificPassword.objects.all()
|
||||
|
||||
def test_delete_other_user(self, client, test_user, get_permission,
|
||||
app_specific_password, django_user_model):
|
||||
other_user = django_user_model.objects.create(username='other')
|
||||
app_specific_password.user = other_user
|
||||
app_specific_password.save()
|
||||
|
||||
test_user.user_permissions.add(
|
||||
get_permission('appspecificpasswords.delete_appspecificpassword')
|
||||
)
|
||||
client.force_login(test_user)
|
||||
response = client.post(
|
||||
reverse('appspecificpasswords:delete', args=[UUID(int=0)])
|
||||
)
|
||||
assert response.status_code == 404
|
@ -0,0 +1,23 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'appspecificpasswords'
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
'',
|
||||
views.AppSpecificPasswordListView.as_view(),
|
||||
name='list'
|
||||
),
|
||||
path(
|
||||
'create/',
|
||||
views.AppSpecificPasswordCreateView.as_view(),
|
||||
name='create'
|
||||
),
|
||||
path(
|
||||
'<uuid:pk>/delete/',
|
||||
views.AppSpecificPasswordDeleteView.as_view(),
|
||||
name='delete'
|
||||
),
|
||||
]
|
@ -0,0 +1,20 @@
|
||||
import secrets
|
||||
import string
|
||||
|
||||
|
||||
def generate_password(length=32):
|
||||
"""Generate random password of the specified length.
|
||||
|
||||
The password will contain at least one uppercase letter, one lowercase
|
||||
letter and one digit.
|
||||
"""
|
||||
if length < 3:
|
||||
raise ValueError('Length must be >= 3.')
|
||||
# https://docs.python.org/3/library/secrets.html#recipes-and-best-practices
|
||||
alphabet = string.ascii_letters + string.digits + string.punctuation
|
||||
while True:
|
||||
password = ''.join(secrets.choice(alphabet) for i in range(length))
|
||||
if (any(c.islower() for c in password) # pragma: no branch
|
||||
and any(c.isupper() for c in password)
|
||||
and any(c.isdigit() for c in password)):
|
||||
return password
|
@ -0,0 +1,46 @@
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
from django.views.generic.edit import CreateView, DeleteView
|
||||
from django.views.generic.list import ListView
|
||||
|
||||
from .forms import AppSpecificPasswordForm
|
||||
from .models import AppSpecificPassword
|
||||
|
||||
|
||||
class AppSpecificPasswordCreateView(PermissionRequiredMixin, CreateView):
|
||||
form_class = AppSpecificPasswordForm
|
||||
permission_required = 'appspecificpasswords.add_appspecificpassword'
|
||||
template_name = 'appspecificpasswords/appspecificpassword_form.html'
|
||||
success_url = reverse_lazy('appspecificpasswords:list')
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['user'] = self.request.user
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.user = self.request.user
|
||||
return super().form_valid(form)
|
||||
|
||||
@method_decorator(sensitive_post_parameters('password'))
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class AppSpecificPasswordListView(PermissionRequiredMixin, ListView):
|
||||
model = AppSpecificPassword
|
||||
permission_required = 'appspecificpasswords.view_appspecificpassword'
|
||||
|
||||
def get_queryset(self):
|
||||
return AppSpecificPassword.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
class AppSpecificPasswordDeleteView(PermissionRequiredMixin, DeleteView):
|
||||
model = AppSpecificPassword
|
||||
permission_required = 'appspecificpasswords.delete_appspecificpassword'
|
||||
success_url = reverse_lazy('appspecificpasswords:list')
|
||||
|
||||
def get_queryset(self):
|
||||
return AppSpecificPassword.objects.filter(user=self.request.user)
|
@ -0,0 +1,935 @@
|
||||
[[package]]
|
||||
name = "argon2-cffi"
|
||||
version = "21.3.0"
|
||||
description = "The secure Argon2 password hashing algorithm."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
argon2-cffi-bindings = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "cogapp", "tomli", "coverage[toml] (>=5.0.2)", "hypothesis", "pytest", "sphinx", "sphinx-notfound-page", "furo"]
|
||||
docs = ["sphinx", "sphinx-notfound-page", "furo"]
|
||||
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "argon2-cffi-bindings"
|
||||
version = "21.2.0"
|
||||
description = "Low-level CFFI bindings for Argon2"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
cffi = ">=1.0.1"
|
||||
|
||||
[package.extras]
|
||||
dev = ["pytest", "cogapp", "pre-commit", "wheel"]
|
||||
tests = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.5.2"
|
||||
description = "ASGI specs, helper code, and adapters"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.extras]
|
||||
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
|
||||
|
||||
[[package]]
|
||||
name = "atomicwrites"
|
||||
version = "1.4.0"
|
||||
description = "Atomic file writes."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "21.4.0"
|
||||
description = "Classes Without Boilerplate"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
|
||||
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
|
||||
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
|
||||
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "3.2.2"
|
||||
description = "Modern password hashing for your software and your servers"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
cffi = ">=1.1"
|
||||
|
||||
[package.extras]
|
||||
tests = ["pytest (>=3.2.1,!=3.3.0)"]
|
||||
typecheck = ["mypy"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2022.5.18.1"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "1.15.0"
|
||||
description = "Foreign Function Interface for Python calling C code."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
pycparser = "*"
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "2.0.12"
|
||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5.0"
|
||||
|
||||
[package.extras]
|
||||
unicode_backport = ["unicodedata2"]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.4"
|
||||
description = "Cross-platform colored terminal text."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "6.3.3"
|
||||
description = "Code coverage measurement for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
|
||||
|
||||
[package.extras]
|
||||
toml = ["tomli"]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "3.3.2"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
|
||||
|
||||
[package.dependencies]
|
||||
cffi = ">=1.12"
|
||||
six = ">=1.4.1"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
|
||||
docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
|
||||
pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["pytest (>=3.6.0,!=3.9.0,!=3.9.1,!=3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.2.13"
|
||||
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[package.dependencies]
|
||||
wrapt = ">=1.10,<2"
|
||||
|
||||
[package.extras]
|
||||
dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "3.2.13"
|
||||
description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
argon2-cffi = {version = ">=19.1.0", optional = true, markers = "extra == \"argon2\""}
|
||||
asgiref = ">=3.3.2,<4"
|
||||
bcrypt = {version = "*", optional = true, markers = "extra == \"bcrypt\""}
|
||||
pytz = "*"
|
||||
sqlparse = ">=0.2.2"
|
||||
|
||||
[package.extras]
|
||||
argon2 = ["argon2-cffi (>=19.1.0)"]
|
||||
bcrypt = ["bcrypt"]
|
||||
|
||||
[[package]]
|
||||
name = "django-crispy-forms"
|
||||
version = "1.11.2"
|
||||
description = "Best way to have Django DRY forms"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "django-oauth-toolkit"
|
||||
version = "1.7.1"
|
||||
description = "OAuth2 Provider for Django"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
django = ">=2.2,<4.0.0 || >4.0.0"
|
||||
jwcrypto = ">=0.8.0"
|
||||
oauthlib = ">=3.1.0"
|
||||
requests = ">=2.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "django-registration-redux"
|
||||
version = "2.10"
|
||||
description = "An extensible user-registration application for Django"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "flake8"
|
||||
version = "4.0.1"
|
||||
description = "the modular source code checker: pep8 pyflakes and co"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
mccabe = ">=0.6.0,<0.7.0"
|
||||
pycodestyle = ">=2.8.0,<2.9.0"
|
||||
pyflakes = ">=2.4.0,<2.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "flake8-bugbear"
|
||||
version = "22.4.25"
|
||||
description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=19.2.0"
|
||||
flake8 = ">=3.0.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit"]
|
||||
|
||||
[[package]]
|
||||
name = "flake8-debugger"
|
||||
version = "4.1.2"
|
||||
description = "ipdb/pdb statement checker plugin for flake8"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
flake8 = ">=3.0"
|
||||
pycodestyle = "*"
|
||||
|
||||
[[package]]
|
||||
name = "flake8-pep3101"
|
||||
version = "1.3.0"
|
||||
description = "Checks for old string formatting."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
flake8 = ">=3.0"
|
||||
|
||||
[package.extras]
|
||||
test = ["pytest", "testfixtures"]
|
||||
|
||||
[[package]]
|
||||
name = "flake8-print"
|
||||
version = "5.0.0"
|
||||
description = "print statement checker plugin for flake8"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
flake8 = ">=3.0"
|
||||
pycodestyle = "*"
|
||||
|
||||
[[package]]
|
||||
name = "flake8-quotes"
|
||||
version = "3.3.1"
|
||||
description = "Flake8 lint for quotes."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
flake8 = "*"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.3"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "1.1.1"
|
||||
description = "iniconfig: brain-dead simple config-ini parsing"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "iso3166"
|
||||
version = "2.0.2"
|
||||
description = "Self-contained ISO 3166-1 country definitions."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">= 3.6"
|
||||
|
||||
[[package]]
|
||||
name = "jwcrypto"
|
||||
version = "1.3.1"
|
||||
description = "Implementation of JOSE Web standards"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">= 3.6"
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = ">=2.3"
|
||||
deprecated = "*"
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.6.1"
|
||||
description = "McCabe checker, plugin for flake8"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "oauthlib"
|
||||
version = "3.2.0"
|
||||
description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.extras]
|
||||
rsa = ["cryptography (>=3.0.0)"]
|
||||
signals = ["blinker (>=1.4.0)"]
|
||||
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "21.3"
|
||||
description = "Core utilities for Python packages"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.0.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "py"
|
||||
version = "1.11.0"
|
||||
description = "library with cross-python path, ini-parsing, io, code, log facilities"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "pycodestyle"
|
||||
version = "2.8.0"
|
||||
description = "Python style guide checker"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "pycountry"
|
||||
version = "22.3.5"
|
||||
description = "ISO country, subdivision, language, currency and script definitions and their translations"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6, <4"
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.21"
|
||||
description = "C parser in Python"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[[package]]
|
||||
name = "pyflakes"
|
||||
version = "2.4.0"
|
||||
description = "passive checker of Python programs"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.0.9"
|
||||
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6.8"
|
||||
|
||||
[package.extras]
|
||||
diagrams = ["railroad-diagrams", "jinja2"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "7.1.2"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
|
||||
attrs = ">=19.2.0"
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
iniconfig = "*"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<2.0"
|
||||
py = ">=1.8.2"
|
||||
tomli = ">=1.0.0"
|
||||
|
||||
[package.extras]
|
||||
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-django"
|
||||
version = "4.5.2"
|
||||
description = "A Django plugin for pytest."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=5.4.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx", "sphinx-rtd-theme"]
|
||||
testing = ["django", "django-configurations (>=2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.8.2"
|
||||
description = "Extensions to the standard Python datetime module"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
|
||||
|
||||
[package.dependencies]
|
||||
six = ">=1.5"
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2022.1"
|
||||
description = "World timezone definitions, modern and historical"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.27.1"
|
||||
description = "Python HTTP for Humans."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
|
||||
idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
|
||||
urllib3 = ">=1.21.1,<1.27"
|
||||
|
||||
[package.extras]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
|
||||
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
|
||||
|
||||
[[package]]
|
||||
name = "schwifty"
|
||||
version = "2021.6.1"
|
||||
description = "Validate/generate IBANs and BICs"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.dependencies]
|
||||
iso3166 = "*"
|
||||
pycountry = "*"
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.16.0"
|
||||
description = "Python 2 and 3 compatibility utilities"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.4.2"
|
||||
description = "A non-validating SQL parser."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.0.1"
|
||||
description = "A lil' TOML parser"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "1.26.9"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
|
||||
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "1.14.1"
|
||||
description = "Module for decorators, wrappers and monkey patching."
|
||||
category = "main"
|
||||