diff --git a/src/Pipfile.lock b/src/Pipfile.lock index d13ed6382..3e7ae367d 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -353,7 +353,7 @@ }, "fred-epplib": { "git": "https://github.com/cisagov/epplib.git", - "ref": "f818cbf0b069a12f03e1d72e4b9f4900924b832d" + "ref": "d56d183f1664f34c40ca9716a3a9a345f0ef561c" }, "furl": { "hashes": [ diff --git a/src/epplibwrapper/__init__.py b/src/epplibwrapper/__init__.py index 27c299d1b..dd6664a3a 100644 --- a/src/epplibwrapper/__init__.py +++ b/src/epplibwrapper/__init__.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) NAMESPACE = SimpleNamespace( EPP="urn:ietf:params:xml:ns:epp-1.0", + SEC_DNS="urn:ietf:params:xml:ns:secDNS-1.1", XSI="http://www.w3.org/2001/XMLSchema-instance", FRED="noop", NIC_CONTACT="urn:ietf:params:xml:ns:contact-1.0", @@ -25,6 +26,7 @@ NAMESPACE = SimpleNamespace( SCHEMA_LOCATION = SimpleNamespace( XSI="urn:ietf:params:xml:ns:epp-1.0 epp-1.0.xsd", FRED="noop fred-1.5.0.xsd", + SEC_DNS="urn:ietf:params:xml:ns:secDNS-1.1 secDNS-1.1.xsd", NIC_CONTACT="urn:ietf:params:xml:ns:contact-1.0 contact-1.0.xsd", NIC_DOMAIN="urn:ietf:params:xml:ns:domain-1.0 domain-1.0.xsd", NIC_ENUMVAL="noop enumval-1.2.0.xsd", @@ -45,6 +47,7 @@ try: from .client import CLIENT, commands from .errors import RegistryError, ErrorCode from epplib.models import common, info + from epplib.responses import extensions from epplib import responses except ImportError: pass @@ -53,6 +56,7 @@ __all__ = [ "CLIENT", "commands", "common", + "extensions", "responses", "info", "ErrorCode", diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index 521d632d6..e1db054b1 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -87,6 +87,11 @@ class UserFixture: "first_name": "Erin", "last_name": "Song", }, + { + "username": "e0ea8b94-6e53-4430-814a-849a7ca45f21", + "first_name": "Kristina", + "last_name": "Yin", + }, ] STAFF = [ @@ -145,6 +150,12 @@ class UserFixture: "last_name": "Song-Analyst", "email": "erin.song+1@gsa.gov", }, + { + "username": "9a98e4c9-9409-479d-964e-4aec7799107f", + "first_name": "Kristina-Analyst", + "last_name": "Yin-Analyst", + "email": "kristina.yin+1@gsa.gov", + }, ] STAFF_PERMISSIONS = [ diff --git a/src/registrar/migrations/0033_alter_userdomainrole_role.py b/src/registrar/migrations/0033_alter_userdomainrole_role.py new file mode 100644 index 000000000..bdfcb6257 --- /dev/null +++ b/src/registrar/migrations/0033_alter_userdomainrole_role.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.1 on 2023-10-02 22:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0032_alter_transitiondomain_status"), + ] + + operations = [ + migrations.AlterField( + model_name="userdomainrole", + name="role", + field=models.TextField(choices=[("manager", "Admin")]), + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index ee63362fb..59edb707a 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -10,11 +10,12 @@ from epplibwrapper import ( CLIENT as registry, commands, common as epp, + extensions, info as eppInfo, RegistryError, ErrorCode, ) -from registrar.models.utility.contact_error import ContactError, ContactErrorCodes +from registrar.models.utility.contact_error import ContactError from .utility.domain_field import DomainField from .utility.domain_helper import DomainHelper @@ -281,6 +282,27 @@ class Domain(TimeStampedModel, DomainHelper): logger.error("Error _create_host, code was %s error was %s" % (e.code, e)) return e.code + @Cache + def dnssecdata(self) -> extensions.DNSSECExtension: + return self._get_property("dnssecdata") + + @dnssecdata.setter # type: ignore + def dnssecdata(self, _dnssecdata: extensions.DNSSECExtension): + updateParams = { + "maxSigLife": _dnssecdata.get("maxSigLife", None), + "dsData": _dnssecdata.get("dsData", None), + "keyData": _dnssecdata.get("keyData", None), + "remAllDsKeyData": True, + } + request = commands.UpdateDomain(name=self.name) + extension = commands.UpdateDomainDNSSECExtension(**updateParams) + request.add_extension(extension) + try: + registry.send(request, cleaned=True) + except RegistryError as e: + logger.error("Error adding DNSSEC, code was %s error was %s" % (e.code, e)) + raise e + @nameservers.setter # type: ignore def nameservers(self, hosts: list[tuple[str]]): """host should be a tuple of type str, str,... where the elements are @@ -676,10 +698,10 @@ class Domain(TimeStampedModel, DomainHelper): return None if contact_type is None: - raise ContactError(code=ContactErrorCodes.CONTACT_TYPE_NONE) + raise ContactError("contact_type is None") if contact_id is None: - raise ContactError(code=ContactErrorCodes.CONTACT_ID_NONE) + raise ContactError("contact_id is None") # Since contact_id is registry_id, # check that its the right length @@ -688,10 +710,14 @@ class Domain(TimeStampedModel, DomainHelper): contact_id_length > PublicContact.get_max_id_length() or contact_id_length < 1 ): - raise ContactError(code=ContactErrorCodes.CONTACT_ID_INVALID_LENGTH) + raise ContactError( + "contact_id is of invalid length. " + "Cannot exceed 16 characters, " + f"got {contact_id} with a length of {contact_id_length}" + ) if not isinstance(contact, eppInfo.InfoContactResultData): - raise ContactError(code=ContactErrorCodes.CONTACT_INVALID_TYPE) + raise ContactError("Contact must be of type InfoContactResultData") auth_info = contact.auth_info postal_info = contact.postal_info @@ -801,7 +827,7 @@ class Domain(TimeStampedModel, DomainHelper): cached_contact = self.get_contact_in_keys(contacts, contact_type_choice) if cached_contact is None: # TODO - #1103 - raise ContactError(code=ContactErrorCodes.CONTACT_NOT_FOUND) + raise ContactError("No contact was found in cache or the registry") return cached_contact @@ -924,9 +950,9 @@ class Domain(TimeStampedModel, DomainHelper): try: logger.info("Getting domain info from epp") req = commands.InfoDomain(name=self.name) - domainInfo = registry.send(req, cleaned=True).res_data[0] + domainInfoResponse = registry.send(req, cleaned=True) exitEarly = True - return domainInfo + return domainInfoResponse except RegistryError as e: count += 1 @@ -1200,7 +1226,8 @@ class Domain(TimeStampedModel, DomainHelper): """Contact registry for info about a domain.""" try: # get info from registry - data = self._get_or_create_domain() + dataResponse = self._get_or_create_domain() + data = dataResponse.res_data[0] # extract properties from response # (Ellipsis is used to mean "null") cache = { @@ -1222,6 +1249,14 @@ class Domain(TimeStampedModel, DomainHelper): if "statuses" in cleaned: cleaned["statuses"] = [status.state for status in cleaned["statuses"]] + # get extensions info, if there is any + # DNSSECExtension is one possible extension, make sure to handle + # only DNSSECExtension and not other type extensions + returned_extensions = dataResponse.extensions + cleaned["dnssecdata"] = None + for extension in returned_extensions: + if isinstance(extension, extensions.DNSSECExtension): + cleaned["dnssecdata"] = extension # Capture and store old hosts and contacts from cache if they exist old_cache_hosts = self._cache.get("hosts") old_cache_contacts = self._cache.get("contacts") diff --git a/src/registrar/models/user_domain_role.py b/src/registrar/models/user_domain_role.py index 5a5219543..e5cb01cc1 100644 --- a/src/registrar/models/user_domain_role.py +++ b/src/registrar/models/user_domain_role.py @@ -15,7 +15,7 @@ class UserDomainRole(TimeStampedModel): elsewhere. """ - ADMIN = "admin" + ADMIN = "manager" user = models.ForeignKey( "registrar.User", diff --git a/src/registrar/models/utility/contact_error.py b/src/registrar/models/utility/contact_error.py index f525c358b..af6dca818 100644 --- a/src/registrar/models/utility/contact_error.py +++ b/src/registrar/models/utility/contact_error.py @@ -20,32 +20,4 @@ class ContactErrorCodes(IntEnum): class ContactError(Exception): - """ - Overview of contact error codes: - - 2000 CONTACT_TYPE_NONE - - 2001 CONTACT_ID_NONE - - 2002 CONTACT_ID_INVALID_LENGTH - - 2003 CONTACT_INVALID_TYPE - - 2004 CONTACT_NOT_FOUND - """ - - # For linter - _contact_id_error = "contact_id has an invalid length. Cannot exceed 16 characters." - _contact_invalid_error = "Contact must be of type InfoContactResultData" - _contact_not_found_error = "No contact was found in cache or the registry" - _error_mapping = { - ContactErrorCodes.CONTACT_TYPE_NONE: "contact_type is None", - ContactErrorCodes.CONTACT_ID_NONE: "contact_id is None", - ContactErrorCodes.CONTACT_ID_INVALID_LENGTH: _contact_id_error, - ContactErrorCodes.CONTACT_INVALID_TYPE: _contact_invalid_error, - ContactErrorCodes.CONTACT_NOT_FOUND: _contact_not_found_error - } - - def __init__(self, *args, code=None, **kwargs): - super().__init__(*args, **kwargs) - self.code = code - if self.code in self._error_mapping: - self.message = self._error_mapping.get(self.code) - - def __str__(self): - return f"{self.message}" + ... diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index ea3efd68c..6a700b393 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -46,11 +46,8 @@ {% include "includes/summary_item.html" with title='Your contact information' value=request.user.contact contact='true' edit_link=url %} {% url 'domain-security-email' pk=domain.id as url %} - {% if security_email is not None and security_email != "dotgov@cisa.dhs.gov"%} - {% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url %} - {% else %} - {% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url %} - {% endif %} + {% include "includes/summary_item.html" with title='Security email' value=domain.security_email edit_link=url %} + {% url 'domain-users' pk=domain.id as url %} {% include "includes/summary_item.html" with title='User management' users='true' list=True value=domain.permissions.all edit_link=url %} diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 38259af04..50456c2d5 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -3,6 +3,7 @@ Feature being tested: Registry Integration This file tests the various ways in which the registrar interacts with the registry. """ +from typing import Mapping, Any from django.test import TestCase from django.db.utils import IntegrityError from unittest.mock import MagicMock, patch, call @@ -20,6 +21,7 @@ from django_fsm import TransitionNotAllowed # type: ignore from epplibwrapper import ( commands, common, + extensions, responses, RegistryError, ErrorCode, @@ -979,44 +981,372 @@ class TestRegistrantNameservers(TestCase): raise -class TestRegistrantDNSSEC(TestCase): +class TestRegistrantDNSSEC(MockEppLib): """Rule: Registrants may modify their secure DNS data""" + # helper function to create UpdateDomainDNSSECExtention object for verification + def createUpdateExtension(self, dnssecdata: extensions.DNSSECExtension): + return commands.UpdateDomainDNSSECExtension( + maxSigLife=dnssecdata.maxSigLife, + dsData=dnssecdata.dsData, + keyData=dnssecdata.keyData, + remDsData=None, + remKeyData=None, + remAllDsKeyData=True, + ) + def setUp(self): """ Background: - Given the registrant is logged in - And the registrant is the admin on a domain + Given the analyst is logged in + And a domain exists in the registry """ - pass + super().setUp() + # for the tests, need a domain in the unknown state + self.domain, _ = Domain.objects.get_or_create(name="fake.gov") + self.addDsData1 = { + "keyTag": 1234, + "alg": 3, + "digestType": 1, + "digest": "ec0bdd990b39feead889f0ba613db4adec0bdd99", + } + self.addDsData2 = { + "keyTag": 2345, + "alg": 3, + "digestType": 1, + "digest": "ec0bdd990b39feead889f0ba613db4adecb4adec", + } + self.keyDataDict = { + "flags": 257, + "protocol": 3, + "alg": 1, + "pubKey": "AQPJ////4Q==", + } + self.dnssecExtensionWithDsData: Mapping[str, Any] = { + "dsData": [common.DSData(**self.addDsData1)] + } + self.dnssecExtensionWithMultDsData: Mapping[str, Any] = { + "dsData": [ + common.DSData(**self.addDsData1), + common.DSData(**self.addDsData2), + ], + } + self.dnssecExtensionWithKeyData: Mapping[str, Any] = { + "maxSigLife": 3215, + "keyData": [common.DNSSECKeyData(**self.keyDataDict)], + } - @skip("not implemented yet") - def test_user_adds_dns_data(self): + def tearDown(self): + Domain.objects.all().delete() + super().tearDown() + + def test_user_adds_dnssec_data(self): """ - Scenario: Registrant adds DNS data + Scenario: Registrant adds DNSSEC data. + Verify that both the setter and getter are functioning properly + + This test verifies: + 1 - setter calls UpdateDomain command + 2 - setter adds the UpdateDNSSECExtension extension to the command + 3 - setter causes the getter to call info domain on next get from cache + 4 - getter properly parses dnssecdata from InfoDomain response and sets to cache """ - raise - @skip("not implemented yet") + # make sure to stop any other patcher so there are no conflicts + self.mockSendPatch.stop() + + def side_effect(_request, cleaned): + return MagicMock( + res_data=[self.mockDataInfoDomain], + extensions=[ + extensions.DNSSECExtension(**self.dnssecExtensionWithDsData) + ], + ) + + patcher = patch("registrar.models.domain.registry.send") + mocked_send = patcher.start() + mocked_send.side_effect = side_effect + + self.domain.dnssecdata = self.dnssecExtensionWithDsData + # get the DNS SEC extension added to the UpdateDomain command and + # verify that it is properly sent + # args[0] is the _request sent to registry + args, _ = mocked_send.call_args + # assert that the extension matches + self.assertEquals( + args[0].extensions[0], + self.createUpdateExtension( + extensions.DNSSECExtension(**self.dnssecExtensionWithDsData) + ), + ) + # test that the dnssecdata getter is functioning properly + dnssecdata_get = self.domain.dnssecdata + mocked_send.assert_has_calls( + [ + call( + commands.UpdateDomain( + name="fake.gov", + nsset=None, + keyset=None, + registrant=None, + auth_info=None, + ), + cleaned=True, + ), + call( + commands.InfoDomain( + name="fake.gov", + ), + cleaned=True, + ), + ] + ) + + self.assertEquals( + dnssecdata_get.dsData, self.dnssecExtensionWithDsData["dsData"] + ) + + patcher.stop() + def test_dnssec_is_idempotent(self): """ Scenario: Registrant adds DNS data twice, due to a UI glitch - """ # implementation note: this requires seeing what happens when these are actually # sent like this, and then implementing appropriate mocks for any errors the # registry normally sends in this case - raise - @skip("not implemented yet") + This test verifies: + 1 - UpdateDomain command called twice + 2 - setter causes the getter to call info domain on next get from cache + 3 - getter properly parses dnssecdata from InfoDomain response and sets to cache + + """ + + # make sure to stop any other patcher so there are no conflicts + self.mockSendPatch.stop() + + def side_effect(_request, cleaned): + return MagicMock( + res_data=[self.mockDataInfoDomain], + extensions=[ + extensions.DNSSECExtension(**self.dnssecExtensionWithDsData) + ], + ) + + patcher = patch("registrar.models.domain.registry.send") + mocked_send = patcher.start() + mocked_send.side_effect = side_effect + + # set the dnssecdata once + self.domain.dnssecdata = self.dnssecExtensionWithDsData + # set the dnssecdata again + self.domain.dnssecdata = self.dnssecExtensionWithDsData + # test that the dnssecdata getter is functioning properly + dnssecdata_get = self.domain.dnssecdata + mocked_send.assert_has_calls( + [ + call( + commands.UpdateDomain( + name="fake.gov", + nsset=None, + keyset=None, + registrant=None, + auth_info=None, + ), + cleaned=True, + ), + call( + commands.UpdateDomain( + name="fake.gov", + nsset=None, + keyset=None, + registrant=None, + auth_info=None, + ), + cleaned=True, + ), + call( + commands.InfoDomain( + name="fake.gov", + ), + cleaned=True, + ), + ] + ) + + self.assertEquals( + dnssecdata_get.dsData, self.dnssecExtensionWithDsData["dsData"] + ) + + patcher.stop() + + def test_user_adds_dnssec_data_multiple_dsdata(self): + """ + Scenario: Registrant adds DNSSEC data with multiple DSData. + Verify that both the setter and getter are functioning properly + + This test verifies: + 1 - setter calls UpdateDomain command + 2 - setter adds the UpdateDNSSECExtension extension to the command + 3 - setter causes the getter to call info domain on next get from cache + 4 - getter properly parses dnssecdata from InfoDomain response and sets to cache + + """ + + # make sure to stop any other patcher so there are no conflicts + self.mockSendPatch.stop() + + def side_effect(_request, cleaned): + return MagicMock( + res_data=[self.mockDataInfoDomain], + extensions=[ + extensions.DNSSECExtension(**self.dnssecExtensionWithMultDsData) + ], + ) + + patcher = patch("registrar.models.domain.registry.send") + mocked_send = patcher.start() + mocked_send.side_effect = side_effect + + self.domain.dnssecdata = self.dnssecExtensionWithMultDsData + # get the DNS SEC extension added to the UpdateDomain command + # and verify that it is properly sent + # args[0] is the _request sent to registry + args, _ = mocked_send.call_args + # assert that the extension matches + self.assertEquals( + args[0].extensions[0], + self.createUpdateExtension( + extensions.DNSSECExtension(**self.dnssecExtensionWithMultDsData) + ), + ) + # test that the dnssecdata getter is functioning properly + dnssecdata_get = self.domain.dnssecdata + mocked_send.assert_has_calls( + [ + call( + commands.UpdateDomain( + name="fake.gov", + nsset=None, + keyset=None, + registrant=None, + auth_info=None, + ), + cleaned=True, + ), + call( + commands.InfoDomain( + name="fake.gov", + ), + cleaned=True, + ), + ] + ) + + self.assertEquals( + dnssecdata_get.dsData, self.dnssecExtensionWithMultDsData["dsData"] + ) + + patcher.stop() + + def test_user_adds_dnssec_keydata(self): + """ + Scenario: Registrant adds DNSSEC data. + Verify that both the setter and getter are functioning properly + + This test verifies: + 1 - setter calls UpdateDomain command + 2 - setter adds the UpdateDNSSECExtension extension to the command + 3 - setter causes the getter to call info domain on next get from cache + 4 - getter properly parses dnssecdata from InfoDomain response and sets to cache + + """ + + # make sure to stop any other patcher so there are no conflicts + self.mockSendPatch.stop() + + def side_effect(_request, cleaned): + return MagicMock( + res_data=[self.mockDataInfoDomain], + extensions=[ + extensions.DNSSECExtension(**self.dnssecExtensionWithKeyData) + ], + ) + + patcher = patch("registrar.models.domain.registry.send") + mocked_send = patcher.start() + mocked_send.side_effect = side_effect + + self.domain.dnssecdata = self.dnssecExtensionWithKeyData + # get the DNS SEC extension added to the UpdateDomain command + # and verify that it is properly sent + # args[0] is the _request sent to registry + args, _ = mocked_send.call_args + # assert that the extension matches + self.assertEquals( + args[0].extensions[0], + self.createUpdateExtension( + extensions.DNSSECExtension(**self.dnssecExtensionWithKeyData) + ), + ) + # test that the dnssecdata getter is functioning properly + dnssecdata_get = self.domain.dnssecdata + mocked_send.assert_has_calls( + [ + call( + commands.UpdateDomain( + name="fake.gov", + nsset=None, + keyset=None, + registrant=None, + auth_info=None, + ), + cleaned=True, + ), + call( + commands.InfoDomain( + name="fake.gov", + ), + cleaned=True, + ), + ] + ) + + self.assertEquals( + dnssecdata_get.keyData, self.dnssecExtensionWithKeyData["keyData"] + ) + + patcher.stop() + def test_update_is_unsuccessful(self): """ Scenario: An update to the dns data is unsuccessful When an error is returned from epplibwrapper Then a user-friendly error message is returned for displaying on the web """ - raise + + # make sure to stop any other patcher so there are no conflicts + self.mockSendPatch.stop() + + def side_effect(_request, cleaned): + raise RegistryError(code=ErrorCode.PARAMETER_VALUE_RANGE_ERROR) + + patcher = patch("registrar.models.domain.registry.send") + mocked_send = patcher.start() + mocked_send.side_effect = side_effect + + # if RegistryError is raised, view formats user-friendly + # error message if error is_client_error, is_session_error, or + # is_server_error; so test for those conditions + with self.assertRaises(RegistryError) as err: + self.domain.dnssecdata = self.dnssecExtensionWithDsData + self.assertTrue( + err.is_client_error() or err.is_session_error() or err.is_server_error() + ) + + patcher.stop() class TestAnalystClientHold(MockEppLib): diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 6e379a5e2..68aaf0ed8 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1487,7 +1487,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest, MockEppLib): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_page = result.follow() self.assertContains( - success_page, "The security email for this domain have been updated" + success_page, "The security email for this domain has been updated" ) def test_domain_overview_blocked_for_ineligible_user(self): diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index a3903367e..d8c3c80fa 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -42,14 +42,6 @@ class DomainView(DomainPermissionView): template_name = "domain_detail.html" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - security_email = self.get_object().get_security_email() - if security_email is None or security_email == "dotgov@cisa.dhs.gov": - context["security_email"] = None - return context - context["security_email"] = security_email - return context class DomainOrgNameAddressView(DomainPermissionView, FormMixin): """Organization name and mailing address view""" @@ -300,7 +292,7 @@ class DomainSecurityEmailView(DomainPermissionView, FormMixin): contact.save() messages.success( - self.request, "The security email for this domain have been updated." + self.request, "The security email for this domain has been updated." ) # superclass has the redirect diff --git a/src/requirements.txt b/src/requirements.txt index a5972c4dc..ae6ed90df 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -22,7 +22,7 @@ django-phonenumber-field[phonenumberslite]==7.1.0 django-widget-tweaks==1.4.12 environs[django]==9.5.0 faker==18.10.0 -git+https://github.com/cisagov/epplib.git@f818cbf0b069a12f03e1d72e4b9f4900924b832d#egg=fred-epplib +git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c#egg=fred-epplib furl==2.1.3 future==0.18.3 ; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' gunicorn==20.1.0 @@ -49,5 +49,5 @@ setuptools==67.8.0 ; python_version >= '3.7' six==1.16.0 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' sqlparse==0.4.4 ; python_version >= '3.5' typing-extensions==4.6.3 -urllib3==1.26.16 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' +urllib3==1.26.17 ; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' whitenoise==6.4.0