From 07e8ff79c1f10bd6f34b87f1842c4af6df68e9eb Mon Sep 17 00:00:00 2001 From: Nawaz Date: Thu, 8 Feb 2024 12:15:14 +0000 Subject: [PATCH 1/7] add CH proxy --- Pipfile | 1 + Pipfile.lock | 36 +++++ config/settings/base.py | 4 + report_a_breach/core/services/base.py | 152 ++++++++++++++++++++ report_a_breach/core/services/ch_proxy.py | 36 +++++ report_a_breach/core/services/exceptions.py | 58 ++++++++ 6 files changed, 287 insertions(+) create mode 100644 report_a_breach/core/services/base.py create mode 100644 report_a_breach/core/services/ch_proxy.py create mode 100644 report_a_breach/core/services/exceptions.py diff --git a/Pipfile b/Pipfile index a6b767f8..184ca2f5 100644 --- a/Pipfile +++ b/Pipfile @@ -22,6 +22,7 @@ sentry-sdk = "*" whitenoise = "*" django-simple-history = "~=3.4.0" pyproject-flake8 = "*" +djangorestframework = "*" [dev-packages] black = "~=23.12" diff --git a/Pipfile.lock b/Pipfile.lock index 5c149fcb..49660d65 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -166,6 +166,7 @@ "sha256:b1260ed381b10a11753c73444408e19869f3241fc45c985cd55a30177c789d13" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==4.2.10" }, "django-audit-log-middleware": { @@ -173,6 +174,7 @@ "sha256:e92b1b594db68720ac35edfecc21daaf8d1c446af00622ade4de14bcbc43329b" ], "index": "pypi", + "markers": "python_version >= '3.6'", "version": "==0.0.4" }, "django-chunk-upload-handlers": { @@ -180,6 +182,7 @@ "sha256:9959a4e8211ec7afee266f02034441898f31540e84b558d6ec6509c408af211c" ], "index": "pypi", + "markers": "python_version >= '3.6'", "version": "==0.0.13" }, "django-countries": { @@ -196,6 +199,7 @@ "sha256:d592044771412ae1bd539cc377203aa61d4eebe77fcbc07fbc8f12d3746d4f6b" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==2.1" }, "django-environ": { @@ -204,6 +208,7 @@ "sha256:f32a87aa0899894c27d4e1776fa6b477e8164ed7f6b3e410a62a6d72caaf64be" ], "index": "pypi", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==0.11.2" }, "django-formtools": { @@ -212,6 +217,7 @@ "sha256:bce9b64eda52cc1eef6961cc649cf75aacd1a707c2fff08d6c3efcbc8e7e761a" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==2.5.1" }, "django-ipware": { @@ -228,6 +234,7 @@ "sha256:992dcca3cddc0b67b470fc91f77292e2d2a6010d37c9eac3536e9d80e8754032" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==3.4.0" }, "django-storages": { @@ -236,8 +243,18 @@ "sha256:51b36af28cc5813b98d5f3dfe7459af638d84428c8df4a03990c7d74d1bea4e5" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==1.14.2" }, + "djangorestframework": { + "hashes": [ + "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", + "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==3.14.0" + }, "docopt": { "hashes": [ "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" @@ -369,6 +386,7 @@ "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033" ], "index": "pypi", + "markers": "python_version >= '3.5'", "version": "==21.2.0" }, "idna": { @@ -400,6 +418,7 @@ "sha256:664a5b5da2aa1a00efa8106bfa4855db04da95d79586e5edfb0411637d20d2d9" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==9.0.0" }, "packaging": { @@ -416,6 +435,7 @@ "sha256:4d5a0a5a8590906daa58ebd5f3cfc34091377354a1acced269dd10faf55da60e" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==3.1.18" }, "pycodestyle": { @@ -448,6 +468,7 @@ "sha256:86ea5559263c098e1aa4f866776aa2cf45362fd91a576b9fd8fbbbb55db12c4e" ], "index": "pypi", + "markers": "python_full_version >= '3.8.1'", "version": "==6.1.0" }, "python-dateutil": { @@ -458,6 +479,13 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.2" }, + "pytz": { + "hashes": [ + "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", + "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319" + ], + "version": "==2024.1" + }, "requests": { "hashes": [ "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", @@ -528,6 +556,7 @@ "sha256:b1f9db9bf67dc183484d760b99f4080185633136a273a03f6436034a41064146" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==6.6.0" }, "zope.event": { @@ -624,6 +653,7 @@ "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==23.12.1" }, "boto3": { @@ -740,6 +770,7 @@ "sha256:b1260ed381b10a11753c73444408e19869f3241fc45c985cd55a30177c789d13" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==4.2.10" }, "django-formtools": { @@ -748,6 +779,7 @@ "sha256:bce9b64eda52cc1eef6961cc649cf75aacd1a707c2fff08d6c3efcbc8e7e761a" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==2.5.1" }, "djhtml": { @@ -795,6 +827,7 @@ "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5" ], "index": "pypi", + "markers": "python_version >= '3.6'", "version": "==2.2.0" }, "isort": { @@ -899,6 +932,7 @@ "sha256:7a1585285aefc5165db81083c3e06363a27448f6b467b3b0f30dbd0ac1f73810" ], "index": "pypi", + "markers": "python_full_version >= '3.8.0'", "version": "==3.0.3" }, "pyproject-flake8": { @@ -907,6 +941,7 @@ "sha256:86ea5559263c098e1aa4f866776aa2cf45362fd91a576b9fd8fbbbb55db12c4e" ], "index": "pypi", + "markers": "python_full_version >= '3.8.1'", "version": "==6.1.0" }, "pytest": { @@ -923,6 +958,7 @@ "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==4.1.0" }, "python-dateutil": { diff --git a/config/settings/base.py b/config/settings/base.py index 90987c2b..18ff5444 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -51,6 +51,7 @@ "crispy_forms", "crispy_forms_gds", "django_chunk_upload_handlers", + "rest_framework", "simple_history", "storages", ] @@ -188,6 +189,9 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +# Companies House API +COMPANIES_HOUSE_API_KEY = env("COMPANIES_HOUSE_API_KEY", default=None) + # GOV NOTIFY GOV_NOTIFY_API_KEY = env.str("GOV_NOTIFY_API_KEY") EMAIL_VERIFY_CODE_TEMPLATE_ID = env.str("GOVUK_NOTIFY_TEMPLATE_EMAIL_VERIFICATION") diff --git a/report_a_breach/core/services/base.py b/report_a_breach/core/services/base.py new file mode 100644 index 00000000..704818a5 --- /dev/null +++ b/report_a_breach/core/services/base.py @@ -0,0 +1,152 @@ +from time import time + +from core.feature_flags import FeatureFlags +from django.conf import settings +from django.utils.decorators import method_decorator +from django_ratelimit.decorators import ratelimit +from organisations.models import get_organisation +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from security.constants import SECURITY_GROUP_SUPER_USER +from security.utils import validate_user_case, validate_user_organisation + +from config.ratelimit import get_rate +from config.version import __version__ + +from .exceptions import AccessDenied + + +@method_decorator(ratelimit(key="user_or_ip", rate=get_rate, method=ratelimit.ALL), name="dispatch") +class ReportABreachApiView(APIView): + """Base class for all Report A Breach API Views. + + Api responses should always return ResponseSuccess objects if successful, or + raise an API Exception otherwise. + + The base API class assigns some instance attributes to the + APIView instance and the response data, in order to conform + to a standard: + + `start` and `limit` are provided in the response data if + a queryset attribute is set. In addition _start & _limit + attributes are set in the APIView object itself. + + _search is set if a `q` query parameter is provided + + `process_time` is set in the response to provide a measure + of time it took to process this request + + `feature_flags` provides a `FeatureFlag` instance which can be used + to fetch flags from the SystemParameters. Flag values will be cached + on the request object, so it can be called multiple times without + performing multiple queries to the database or cache. + """ + + permission_classes = IsAuthenticated + allowed_groups = {} + + def __init__(self, *args, **kwargs): + self.case_id = None + self.user = None + self.organisation = None + self._start = 0 + self._limit = settings.DEFAULT_QUERYSET_PAGE_SIZE + self._search = None + self._order_by = "" + self._order_dir = "asc" + self.feature_flags = None + super().__init__(*args, **kwargs) + + def initial(self, request, *args, **kwargs): + """Initial override. + + Override initial to collect some standard + request parameters into the API View Object. + :param (HttpRequest) request: Request object. + """ + super().initial(request, *args, **kwargs) + organisation_id = kwargs.get("organisation_id") + self.case_id = kwargs.get("case_id") + self.user = request.user + self.organisation = get_organisation(organisation_id) + if self.organisation: + self.organisation.set_user_context(request.user) + if self.allowed_groups: + self.raise_on_invalid_access() + self._start = int(request.query_params.get("start", 0)) + self._limit = int(request.query_params.get("limit", settings.DEFAULT_QUERYSET_PAGE_SIZE)) + self._search = request.query_params.get("q") + self._order_by = request.query_params.get("order_by") + self._order_dir = request.query_params.get("order_dir", "asc") + + def raise_on_invalid_access(self): + """Check user organisation authorisation. + + Raise an AccessDenied API exception if the user is not allowed to + access the organisation. + """ + is_valid = False + org_id = self.organisation.id if self.organisation else None + if self.user.has_group(SECURITY_GROUP_SUPER_USER): + is_valid = True + elif self.allowed_groups.get(self.request.method) and self.user.has_groups( + self.allowed_groups[self.request.method] + ): + is_valid = True + elif self.case_id and org_id: + is_valid = validate_user_case(self.user, self.case_id, org_id) + elif org_id: + is_valid = validate_user_organisation(self.user, org_id) + if not is_valid: + raise AccessDenied("User does not have access to organisation") + + def dispatch(self, request, *args, **kwargs): + """Dispatch override. + + :param (HttpRequest) request: Request object. + """ + time_recv = time() + self.feature_flags = FeatureFlags() + response = super().dispatch(request, *args, **kwargs) + if hasattr(response, "data"): + if response.exception is True: + response["error"] = True + if settings.DEBUG: + # logger.error(f"Error: {response.data}") TODO: require messge? + pass + else: + response.data["version"] = __version__ + response.data["process_time"] = time() - time_recv + if hasattr(self, "queryset"): + response.data["start"] = self._start + response.data["limit"] = self._limit + return response + + def validate_required_fields(self, request): + if hasattr(self, "required_keys"): + missing_keys = [key for key in self.required_keys if not request.data.get(key)] + return missing_keys + return [] + + @property + def sort_spec(self): + if self._order_by and self._order_dir: + order_dir_indicator = "-" if self._order_dir == "desc" else "" + return [f"{order_dir_indicator}{self._order_by}"] + return None + + +class ResponseSuccess(Response): + """Common response object. + + Manages a standard response format for all API calls. + """ + + def __init__(self, data=None, http_status=None, content_type=None): + _status = http_status or status.HTTP_200_OK + data = data or {} + reply = {"response": {"success": True}} + reply["response"].update(data) + super().__init__(data=reply, status=_status, content_type=content_type) diff --git a/report_a_breach/core/services/ch_proxy.py b/report_a_breach/core/services/ch_proxy.py new file mode 100644 index 00000000..ae2ced69 --- /dev/null +++ b/report_a_breach/core/services/ch_proxy.py @@ -0,0 +1,36 @@ +import base64 + +import requests +from django.conf import settings + +from .base import ReportABreachApiView, ResponseSuccess +from .exceptions import InvalidRequestParams + +COMPANIES_HOUSE_BASIC_AUTH = base64.b64encode( # /PS-IGNORE + bytes(f"{settings.COMPANIES_HOUSE_API_KEY}:", "utf-8") +).decode("utf-8") +COMPANIES_HOUSE_BASE_DOMAIN = "https://api.companieshouse.gov.uk" + + +class CompaniesHouseApiSearch(ReportABreachApiView): + def get(self, request, *args, **kwargs): + query = request.query_params.get("q") + if not query: + raise InvalidRequestParams("Missing q param") + headers = {"Authorization": f"Basic {COMPANIES_HOUSE_BASIC_AUTH}"} + response = requests.get( + f"{COMPANIES_HOUSE_BASE_DOMAIN}/search/companies", + headers=headers, + params={ + "q": query, + "items_per_page": 10, + }, + ).json() + return ResponseSuccess( + { + "results": response.get("items"), + "total": response.get("total_results"), + "limit": response.get("items_per_page"), + "page_number": response.get("page_number"), + } + ) diff --git a/report_a_breach/core/services/exceptions.py b/report_a_breach/core/services/exceptions.py new file mode 100644 index 00000000..d491be64 --- /dev/null +++ b/report_a_breach/core/services/exceptions.py @@ -0,0 +1,58 @@ +""" +Custom API exceptions +""" + +from rest_framework import status +from rest_framework.exceptions import APIException, ValidationError + + +class NotFoundApiExceptions(APIException): + status_code = status.HTTP_404_NOT_FOUND + + +class InvalidRequestParams(APIException): + status_code = status.HTTP_400_BAD_REQUEST + + +class RequestValidationError(ValidationError): + status_code = status.HTTP_400_BAD_REQUEST + + +class IntegrityErrorRequest(APIException): + """ + Generic integritry error exception + """ + + status_code = status.HTTP_400_BAD_REQUEST + + +class InvalidFileUpload(APIException): + status_code = status.HTTP_400_BAD_REQUEST + + +class ServerError(APIException): + """ + Generic 500 error + """ + + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + +class AccessDenied(APIException): + """ + Login error / user inactive + """ + + status_code = status.HTTP_401_UNAUTHORIZED + + +class InvalidRequestLockout(APIException): + status_code = status.HTTP_401_UNAUTHORIZED + + def __init__(self, detail=None, failures=None): + super().__init__(detail=detail, code=self.status_code) + self.failures = failures or 0 + + +class NotifyError(APIException): + pass From 758a3be3b10eb34f6bdaa230169b07a37f30720e Mon Sep 17 00:00:00 2001 From: Nawaz Date: Mon, 12 Feb 2024 13:13:13 +0000 Subject: [PATCH 2/7] validate q parameter for CH search --- Pipfile | 1 + Pipfile.lock | 9 ++ config/ratelimit.py | 19 +++++ config/settings/base.py | 11 +++ config/version.py | 1 + local.env.example | 2 + report_a_breach/core/services/base.py | 113 +++++++++++++++++--------- report_a_breach/core/urls.py | 1 + 8 files changed, 118 insertions(+), 39 deletions(-) create mode 100644 config/ratelimit.py create mode 100644 config/version.py diff --git a/Pipfile b/Pipfile index 184ca2f5..25eb1303 100644 --- a/Pipfile +++ b/Pipfile @@ -23,6 +23,7 @@ whitenoise = "*" django-simple-history = "~=3.4.0" pyproject-flake8 = "*" djangorestframework = "*" +django-ratelimit = "*" [dev-packages] black = "~=23.12" diff --git a/Pipfile.lock b/Pipfile.lock index 49660d65..f677817a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -228,6 +228,15 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==3.0.7" }, + "django-ratelimit": { + "hashes": [ + "sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b", + "sha256:d047a31cf94d83ef1465d7543ca66c6fc16695559b5f8d814d1b51df15110b92" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==4.1.0" + }, "django-simple-history": { "hashes": [ "sha256:19bd1a87e1e2eba34dfd43eab1fcf2da5752221f343232f2372b2121c7e3b97d", diff --git a/config/ratelimit.py b/config/ratelimit.py new file mode 100644 index 00000000..12f08305 --- /dev/null +++ b/config/ratelimit.py @@ -0,0 +1,19 @@ +from django.conf import settings +from django.http import JsonResponse + + +def get_rate(group, request): + """Return the rate limit for the given user. The Health Check user does not get rate-limited.""" + if not settings.API_RATELIMIT_ENABLED: + return None + + return settings.API_RATELIMIT_RATE + + +def ratelimited_error(request, exception): + """Return a 429 response when the user is rate-limited.""" + from sentry_sdk import capture_exception + + # logging to sentry so we know + capture_exception(exception) + return JsonResponse({"error": "ratelimited"}, status=429) diff --git a/config/settings/base.py b/config/settings/base.py index 18ff5444..380547c8 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -192,6 +192,17 @@ # Companies House API COMPANIES_HOUSE_API_KEY = env("COMPANIES_HOUSE_API_KEY", default=None) +# ------------------- API RATE LIMITING ------------------- +API_RATELIMIT_ENABLED = env.bool("API_RATELIMIT_ENABLED", default=True) +if API_RATELIMIT_ENABLED: + MIDDLEWARE = MIDDLEWARE + [ + "django_ratelimit.middleware.RatelimitMiddleware", + ] + API_RATELIMIT_RATE = env.str("API_RATELIMIT_RATE", default="200/m") + RATELIMIT_VIEW = "config.ratelimit.ratelimited_error" + +DEFAULT_QUERYSET_PAGE_SIZE = 20 + # GOV NOTIFY GOV_NOTIFY_API_KEY = env.str("GOV_NOTIFY_API_KEY") EMAIL_VERIFY_CODE_TEMPLATE_ID = env.str("GOVUK_NOTIFY_TEMPLATE_EMAIL_VERIFICATION") diff --git a/config/version.py b/config/version.py new file mode 100644 index 00000000..ef6497d0 --- /dev/null +++ b/config/version.py @@ -0,0 +1 @@ +__version__ = "2.3.2" diff --git a/local.env.example b/local.env.example index fa502b3b..d7cd88de 100644 --- a/local.env.example +++ b/local.env.example @@ -16,3 +16,5 @@ AWS_REGION=Ask a Colleague CLAM_AV_USERNAME=Ask a Colleague CLAM_AV_PASSWORD=Ask a Colleague CLAM_AV_DOMAIN=Ask a Colleague + +COMPANIES_HOUSE_API_KEY=ask team members diff --git a/report_a_breach/core/services/base.py b/report_a_breach/core/services/base.py index 704818a5..47fbff43 100644 --- a/report_a_breach/core/services/base.py +++ b/report_a_breach/core/services/base.py @@ -1,21 +1,58 @@ from time import time -from core.feature_flags import FeatureFlags from django.conf import settings + +# TODO: Confirm that secturity check are not required, and then remove the related code. +# from django.contrib.auth.models import Group from django.utils.decorators import method_decorator from django_ratelimit.decorators import ratelimit -from organisations.models import get_organisation from rest_framework import status -from rest_framework.permissions import IsAuthenticated + +# TODO: Confirm that secturity check are not required, and then remove the related code. +# from rest_framework.permissions import BasePermission, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from security.constants import SECURITY_GROUP_SUPER_USER -from security.utils import validate_user_case, validate_user_organisation from config.ratelimit import get_rate from config.version import __version__ -from .exceptions import AccessDenied +# TODO: Confirm that secturity check are not required, and then remove the related code. +# from .exceptions import AccessDenied +# from security.utils import validate_user_organisation, validate_user_case +# from security.constants import SECURITY_GROUP_SUPER_USER + + +# TODO: Confirm that secturity check are not required, and then remove the related code. +# class GroupPermission(BasePermission): +# @staticmethod +# def _user_in_group(user, group): +# """Check is a user is in a group. + +# :param (User) user: User to check. +# :param (Group) group: Group to check membership of. +# :returns (bool): True if the user is in a given group, False otherwise. +# """ +# try: +# return Group.objects.get(name=group).user_set.filter(id=user.id).exists() +# except Group.DoesNotExist: +# return False + +# def has_permission(self, request, view): +# """Check user's permission override. + +# Check if the user in this request is in an allowed group for the request +# method invoked on the view (or is a superuser, or the view doesn't specify +# allowed groups). +# :param (HTTPRequest) request: API request. +# :param (ReportABreachApiView) view: View to check permission for. + +# :returns (bool): True +# """ +# allowed_groups_mapping = getattr(view, "allowed_groups", {}) +# allowed_groups = allowed_groups_mapping.get(request.method, []) +# if request.user.is_superuser or not allowed_groups: +# return True +# return any([self._user_in_group(request.user, group) for group in allowed_groups]) @method_decorator(ratelimit(key="user_or_ip", rate=get_rate, method=ratelimit.ALL), name="dispatch") @@ -44,8 +81,9 @@ class ReportABreachApiView(APIView): performing multiple queries to the database or cache. """ - permission_classes = IsAuthenticated - allowed_groups = {} + # TODO: Confirm that secturity check are not required, and then remove the related code. + # permission_classes = (IsAuthenticated, GroupPermission) + # allowed_groups = {} def __init__(self, *args, **kwargs): self.case_id = None @@ -67,40 +105,38 @@ def initial(self, request, *args, **kwargs): :param (HttpRequest) request: Request object. """ super().initial(request, *args, **kwargs) - organisation_id = kwargs.get("organisation_id") - self.case_id = kwargs.get("case_id") - self.user = request.user - self.organisation = get_organisation(organisation_id) - if self.organisation: - self.organisation.set_user_context(request.user) - if self.allowed_groups: - self.raise_on_invalid_access() + # TODO: Confirm that secturity check are not required, and then remove the related code. + # self.user = request.user + # if self.allowed_groups: + # self.raise_on_invalid_access() self._start = int(request.query_params.get("start", 0)) self._limit = int(request.query_params.get("limit", settings.DEFAULT_QUERYSET_PAGE_SIZE)) - self._search = request.query_params.get("q") + # self._search = request.query_params.get("q") + self._search = 1234561 self._order_by = request.query_params.get("order_by") self._order_dir = request.query_params.get("order_dir", "asc") - def raise_on_invalid_access(self): - """Check user organisation authorisation. - - Raise an AccessDenied API exception if the user is not allowed to - access the organisation. - """ - is_valid = False - org_id = self.organisation.id if self.organisation else None - if self.user.has_group(SECURITY_GROUP_SUPER_USER): - is_valid = True - elif self.allowed_groups.get(self.request.method) and self.user.has_groups( - self.allowed_groups[self.request.method] - ): - is_valid = True - elif self.case_id and org_id: - is_valid = validate_user_case(self.user, self.case_id, org_id) - elif org_id: - is_valid = validate_user_organisation(self.user, org_id) - if not is_valid: - raise AccessDenied("User does not have access to organisation") + # TODO: Confirm that secturity check are not required, and then remove the related code. + # def raise_on_invalid_access(self): + # """Check user organisation authorisation. + + # Raise an AccessDenied API exception if the user is not allowed to + # access the organisation. + # """ + # is_valid = False + # org_id = self.organisation.id if self.organisation else None + # if self.user.has_group(SECURITY_GROUP_SUPER_USER): + # is_valid = True + # elif self.allowed_groups.get(self.request.method) and self.user.has_groups( + # self.allowed_groups[self.request.method] + # ): + # is_valid = True + # elif self.case_id and org_id: + # is_valid = validate_user_case(self.user, self.case_id, org_id) + # elif org_id: + # is_valid = validate_user_organisation(self.user, org_id) + # if not is_valid: + # raise AccessDenied("User does not have access to organisation") def dispatch(self, request, *args, **kwargs): """Dispatch override. @@ -108,13 +144,12 @@ def dispatch(self, request, *args, **kwargs): :param (HttpRequest) request: Request object. """ time_recv = time() - self.feature_flags = FeatureFlags() response = super().dispatch(request, *args, **kwargs) if hasattr(response, "data"): if response.exception is True: response["error"] = True if settings.DEBUG: - # logger.error(f"Error: {response.data}") TODO: require messge? + # logger.error(f"Error: {response.data}") TODO: require a messge here? pass else: response.data["version"] = __version__ diff --git a/report_a_breach/core/urls.py b/report_a_breach/core/urls.py index cdac6e44..ce5dedd8 100644 --- a/report_a_breach/core/urls.py +++ b/report_a_breach/core/urls.py @@ -10,4 +10,5 @@ path("confirmation", ReportSubmissionCompleteView.as_view(), name="confirmation"), path("", report_a_breach_wizard, name="report_a_breach"), re_path(r"report_a_breach/(?P.+)/$", report_a_breach_wizard, name="report_a_breach_step"), + path("search/", CompaniesHouseApiSearch.as_view()), ] From a50110e5413b092f72a4bd11ac9eaf0ac04c4b53 Mon Sep 17 00:00:00 2001 From: Nawaz Date: Mon, 12 Feb 2024 16:06:05 +0000 Subject: [PATCH 3/7] replaced search with retrieval for single company --- Pipfile | 2 - Pipfile.lock | 25 --- config/ratelimit.py | 19 -- config/settings/base.py | 12 -- report_a_breach/core/services/base.py | 187 -------------------- report_a_breach/core/services/ch_proxy.py | 41 ++--- report_a_breach/core/services/exceptions.py | 58 ------ report_a_breach/core/urls.py | 1 - 8 files changed, 16 insertions(+), 329 deletions(-) delete mode 100644 config/ratelimit.py delete mode 100644 report_a_breach/core/services/base.py delete mode 100644 report_a_breach/core/services/exceptions.py diff --git a/Pipfile b/Pipfile index 25eb1303..a6b767f8 100644 --- a/Pipfile +++ b/Pipfile @@ -22,8 +22,6 @@ sentry-sdk = "*" whitenoise = "*" django-simple-history = "~=3.4.0" pyproject-flake8 = "*" -djangorestframework = "*" -django-ratelimit = "*" [dev-packages] black = "~=23.12" diff --git a/Pipfile.lock b/Pipfile.lock index f677817a..2eac1090 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -228,15 +228,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "version": "==3.0.7" }, - "django-ratelimit": { - "hashes": [ - "sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b", - "sha256:d047a31cf94d83ef1465d7543ca66c6fc16695559b5f8d814d1b51df15110b92" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==4.1.0" - }, "django-simple-history": { "hashes": [ "sha256:19bd1a87e1e2eba34dfd43eab1fcf2da5752221f343232f2372b2121c7e3b97d", @@ -255,15 +246,6 @@ "markers": "python_version >= '3.7'", "version": "==1.14.2" }, - "djangorestframework": { - "hashes": [ - "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", - "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08" - ], - "index": "pypi", - "markers": "python_version >= '3.6'", - "version": "==3.14.0" - }, "docopt": { "hashes": [ "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" @@ -488,13 +470,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.2" }, - "pytz": { - "hashes": [ - "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", - "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319" - ], - "version": "==2024.1" - }, "requests": { "hashes": [ "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", diff --git a/config/ratelimit.py b/config/ratelimit.py deleted file mode 100644 index 12f08305..00000000 --- a/config/ratelimit.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.conf import settings -from django.http import JsonResponse - - -def get_rate(group, request): - """Return the rate limit for the given user. The Health Check user does not get rate-limited.""" - if not settings.API_RATELIMIT_ENABLED: - return None - - return settings.API_RATELIMIT_RATE - - -def ratelimited_error(request, exception): - """Return a 429 response when the user is rate-limited.""" - from sentry_sdk import capture_exception - - # logging to sentry so we know - capture_exception(exception) - return JsonResponse({"error": "ratelimited"}, status=429) diff --git a/config/settings/base.py b/config/settings/base.py index 380547c8..6d127ce7 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -51,7 +51,6 @@ "crispy_forms", "crispy_forms_gds", "django_chunk_upload_handlers", - "rest_framework", "simple_history", "storages", ] @@ -192,17 +191,6 @@ # Companies House API COMPANIES_HOUSE_API_KEY = env("COMPANIES_HOUSE_API_KEY", default=None) -# ------------------- API RATE LIMITING ------------------- -API_RATELIMIT_ENABLED = env.bool("API_RATELIMIT_ENABLED", default=True) -if API_RATELIMIT_ENABLED: - MIDDLEWARE = MIDDLEWARE + [ - "django_ratelimit.middleware.RatelimitMiddleware", - ] - API_RATELIMIT_RATE = env.str("API_RATELIMIT_RATE", default="200/m") - RATELIMIT_VIEW = "config.ratelimit.ratelimited_error" - -DEFAULT_QUERYSET_PAGE_SIZE = 20 - # GOV NOTIFY GOV_NOTIFY_API_KEY = env.str("GOV_NOTIFY_API_KEY") EMAIL_VERIFY_CODE_TEMPLATE_ID = env.str("GOVUK_NOTIFY_TEMPLATE_EMAIL_VERIFICATION") diff --git a/report_a_breach/core/services/base.py b/report_a_breach/core/services/base.py deleted file mode 100644 index 47fbff43..00000000 --- a/report_a_breach/core/services/base.py +++ /dev/null @@ -1,187 +0,0 @@ -from time import time - -from django.conf import settings - -# TODO: Confirm that secturity check are not required, and then remove the related code. -# from django.contrib.auth.models import Group -from django.utils.decorators import method_decorator -from django_ratelimit.decorators import ratelimit -from rest_framework import status - -# TODO: Confirm that secturity check are not required, and then remove the related code. -# from rest_framework.permissions import BasePermission, IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from config.ratelimit import get_rate -from config.version import __version__ - -# TODO: Confirm that secturity check are not required, and then remove the related code. -# from .exceptions import AccessDenied -# from security.utils import validate_user_organisation, validate_user_case -# from security.constants import SECURITY_GROUP_SUPER_USER - - -# TODO: Confirm that secturity check are not required, and then remove the related code. -# class GroupPermission(BasePermission): -# @staticmethod -# def _user_in_group(user, group): -# """Check is a user is in a group. - -# :param (User) user: User to check. -# :param (Group) group: Group to check membership of. -# :returns (bool): True if the user is in a given group, False otherwise. -# """ -# try: -# return Group.objects.get(name=group).user_set.filter(id=user.id).exists() -# except Group.DoesNotExist: -# return False - -# def has_permission(self, request, view): -# """Check user's permission override. - -# Check if the user in this request is in an allowed group for the request -# method invoked on the view (or is a superuser, or the view doesn't specify -# allowed groups). -# :param (HTTPRequest) request: API request. -# :param (ReportABreachApiView) view: View to check permission for. - -# :returns (bool): True -# """ -# allowed_groups_mapping = getattr(view, "allowed_groups", {}) -# allowed_groups = allowed_groups_mapping.get(request.method, []) -# if request.user.is_superuser or not allowed_groups: -# return True -# return any([self._user_in_group(request.user, group) for group in allowed_groups]) - - -@method_decorator(ratelimit(key="user_or_ip", rate=get_rate, method=ratelimit.ALL), name="dispatch") -class ReportABreachApiView(APIView): - """Base class for all Report A Breach API Views. - - Api responses should always return ResponseSuccess objects if successful, or - raise an API Exception otherwise. - - The base API class assigns some instance attributes to the - APIView instance and the response data, in order to conform - to a standard: - - `start` and `limit` are provided in the response data if - a queryset attribute is set. In addition _start & _limit - attributes are set in the APIView object itself. - - _search is set if a `q` query parameter is provided - - `process_time` is set in the response to provide a measure - of time it took to process this request - - `feature_flags` provides a `FeatureFlag` instance which can be used - to fetch flags from the SystemParameters. Flag values will be cached - on the request object, so it can be called multiple times without - performing multiple queries to the database or cache. - """ - - # TODO: Confirm that secturity check are not required, and then remove the related code. - # permission_classes = (IsAuthenticated, GroupPermission) - # allowed_groups = {} - - def __init__(self, *args, **kwargs): - self.case_id = None - self.user = None - self.organisation = None - self._start = 0 - self._limit = settings.DEFAULT_QUERYSET_PAGE_SIZE - self._search = None - self._order_by = "" - self._order_dir = "asc" - self.feature_flags = None - super().__init__(*args, **kwargs) - - def initial(self, request, *args, **kwargs): - """Initial override. - - Override initial to collect some standard - request parameters into the API View Object. - :param (HttpRequest) request: Request object. - """ - super().initial(request, *args, **kwargs) - # TODO: Confirm that secturity check are not required, and then remove the related code. - # self.user = request.user - # if self.allowed_groups: - # self.raise_on_invalid_access() - self._start = int(request.query_params.get("start", 0)) - self._limit = int(request.query_params.get("limit", settings.DEFAULT_QUERYSET_PAGE_SIZE)) - # self._search = request.query_params.get("q") - self._search = 1234561 - self._order_by = request.query_params.get("order_by") - self._order_dir = request.query_params.get("order_dir", "asc") - - # TODO: Confirm that secturity check are not required, and then remove the related code. - # def raise_on_invalid_access(self): - # """Check user organisation authorisation. - - # Raise an AccessDenied API exception if the user is not allowed to - # access the organisation. - # """ - # is_valid = False - # org_id = self.organisation.id if self.organisation else None - # if self.user.has_group(SECURITY_GROUP_SUPER_USER): - # is_valid = True - # elif self.allowed_groups.get(self.request.method) and self.user.has_groups( - # self.allowed_groups[self.request.method] - # ): - # is_valid = True - # elif self.case_id and org_id: - # is_valid = validate_user_case(self.user, self.case_id, org_id) - # elif org_id: - # is_valid = validate_user_organisation(self.user, org_id) - # if not is_valid: - # raise AccessDenied("User does not have access to organisation") - - def dispatch(self, request, *args, **kwargs): - """Dispatch override. - - :param (HttpRequest) request: Request object. - """ - time_recv = time() - response = super().dispatch(request, *args, **kwargs) - if hasattr(response, "data"): - if response.exception is True: - response["error"] = True - if settings.DEBUG: - # logger.error(f"Error: {response.data}") TODO: require a messge here? - pass - else: - response.data["version"] = __version__ - response.data["process_time"] = time() - time_recv - if hasattr(self, "queryset"): - response.data["start"] = self._start - response.data["limit"] = self._limit - return response - - def validate_required_fields(self, request): - if hasattr(self, "required_keys"): - missing_keys = [key for key in self.required_keys if not request.data.get(key)] - return missing_keys - return [] - - @property - def sort_spec(self): - if self._order_by and self._order_dir: - order_dir_indicator = "-" if self._order_dir == "desc" else "" - return [f"{order_dir_indicator}{self._order_by}"] - return None - - -class ResponseSuccess(Response): - """Common response object. - - Manages a standard response format for all API calls. - """ - - def __init__(self, data=None, http_status=None, content_type=None): - _status = http_status or status.HTTP_200_OK - data = data or {} - reply = {"response": {"success": True}} - reply["response"].update(data) - super().__init__(data=reply, status=_status, content_type=content_type) diff --git a/report_a_breach/core/services/ch_proxy.py b/report_a_breach/core/services/ch_proxy.py index ae2ced69..47080e0a 100644 --- a/report_a_breach/core/services/ch_proxy.py +++ b/report_a_breach/core/services/ch_proxy.py @@ -3,34 +3,25 @@ import requests from django.conf import settings -from .base import ReportABreachApiView, ResponseSuccess -from .exceptions import InvalidRequestParams - COMPANIES_HOUSE_BASIC_AUTH = base64.b64encode( # /PS-IGNORE bytes(f"{settings.COMPANIES_HOUSE_API_KEY}:", "utf-8") ).decode("utf-8") COMPANIES_HOUSE_BASE_DOMAIN = "https://api.companieshouse.gov.uk" -class CompaniesHouseApiSearch(ReportABreachApiView): - def get(self, request, *args, **kwargs): - query = request.query_params.get("q") - if not query: - raise InvalidRequestParams("Missing q param") - headers = {"Authorization": f"Basic {COMPANIES_HOUSE_BASIC_AUTH}"} - response = requests.get( - f"{COMPANIES_HOUSE_BASE_DOMAIN}/search/companies", - headers=headers, - params={ - "q": query, - "items_per_page": 10, - }, - ).json() - return ResponseSuccess( - { - "results": response.get("items"), - "total": response.get("total_results"), - "limit": response.get("items_per_page"), - "page_number": response.get("page_number"), - } - ) +class CompaniesHouseApi: + def get_details_from_companies_house(self, registration_number): + """ + Retrieves and returns details of a company from Companies House + using registration number that is passed in. + """ + + if registration_number: + headers_get_company = {"Authorization": f"Basic {COMPANIES_HOUSE_BASIC_AUTH}"} + response = requests.get( + f"{COMPANIES_HOUSE_BASE_DOMAIN}/company/{registration_number}", + headers=headers_get_company, + ) + if response.status_code == 200: + return response.json() + return {} diff --git a/report_a_breach/core/services/exceptions.py b/report_a_breach/core/services/exceptions.py deleted file mode 100644 index d491be64..00000000 --- a/report_a_breach/core/services/exceptions.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Custom API exceptions -""" - -from rest_framework import status -from rest_framework.exceptions import APIException, ValidationError - - -class NotFoundApiExceptions(APIException): - status_code = status.HTTP_404_NOT_FOUND - - -class InvalidRequestParams(APIException): - status_code = status.HTTP_400_BAD_REQUEST - - -class RequestValidationError(ValidationError): - status_code = status.HTTP_400_BAD_REQUEST - - -class IntegrityErrorRequest(APIException): - """ - Generic integritry error exception - """ - - status_code = status.HTTP_400_BAD_REQUEST - - -class InvalidFileUpload(APIException): - status_code = status.HTTP_400_BAD_REQUEST - - -class ServerError(APIException): - """ - Generic 500 error - """ - - status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - -class AccessDenied(APIException): - """ - Login error / user inactive - """ - - status_code = status.HTTP_401_UNAUTHORIZED - - -class InvalidRequestLockout(APIException): - status_code = status.HTTP_401_UNAUTHORIZED - - def __init__(self, detail=None, failures=None): - super().__init__(detail=detail, code=self.status_code) - self.failures = failures or 0 - - -class NotifyError(APIException): - pass diff --git a/report_a_breach/core/urls.py b/report_a_breach/core/urls.py index ce5dedd8..cdac6e44 100644 --- a/report_a_breach/core/urls.py +++ b/report_a_breach/core/urls.py @@ -10,5 +10,4 @@ path("confirmation", ReportSubmissionCompleteView.as_view(), name="confirmation"), path("", report_a_breach_wizard, name="report_a_breach"), re_path(r"report_a_breach/(?P.+)/$", report_a_breach_wizard, name="report_a_breach_step"), - path("search/", CompaniesHouseApiSearch.as_view()), ] From 3ff71c61aca9c524a915e437978de9712b8f083f Mon Sep 17 00:00:00 2001 From: Nawaz Date: Mon, 12 Feb 2024 16:28:20 +0000 Subject: [PATCH 4/7] remove redundant file --- config/version.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 config/version.py diff --git a/config/version.py b/config/version.py deleted file mode 100644 index ef6497d0..00000000 --- a/config/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "2.3.2" From 927749f0e60402f6758b5108b71fa58d966a33b9 Mon Sep 17 00:00:00 2001 From: Nawaz Date: Tue, 20 Feb 2024 11:59:45 +0000 Subject: [PATCH 5/7] piplock installed --- Pipfile.lock | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 2eac1090..7cbd7c80 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -179,11 +179,11 @@ }, "django-chunk-upload-handlers": { "hashes": [ - "sha256:9959a4e8211ec7afee266f02034441898f31540e84b558d6ec6509c408af211c" + "sha256:4a8c7113f1fea9f307b4caa79995dc5824c7f5bc70bdc486cb93b5091d782854" ], "index": "pypi", - "markers": "python_version >= '3.6'", - "version": "==0.0.13" + "markers": "python_version >= '3.8'", + "version": "==0.0.14" }, "django-countries": { "hashes": [ @@ -791,11 +791,11 @@ }, "identify": { "hashes": [ - "sha256:a4316013779e433d08b96e5eabb7f641e6c7942e4ab5d4c509ebd2e7a8994aed", - "sha256:ee17bc9d499899bc9eaec1ac7bf2dc9eedd480db9d88b96d123d3b64a9d34f5d" + "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791", + "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e" ], "markers": "python_version >= '3.8'", - "version": "==2.5.34" + "version": "==2.5.35" }, "iniconfig": { "hashes": [ @@ -888,8 +888,8 @@ }, "pre-commit": { "hashes": [ - "sha256:9fe989afcf095d2c4796ce7c553cf28d4d4a9b9346de3cda079bcf40748454a4", - "sha256:c90961d8aa706f75d60935aba09469a6b0bcb8345f127c3fbee4bdc5f114cf4b" + "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c", + "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e" ], "index": "pypi", "version": "==3.6.1" @@ -930,11 +930,11 @@ }, "pytest": { "hashes": [ - "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c", - "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6" + "sha256:267f6563751877d772019b13aacbe4e860d73fe8f651f28112e9ac37de7513ae", + "sha256:3e4f16fe1c0a9dc9d9389161c127c3edc5d810c38d6793042fb81d9f48a59fca" ], "markers": "python_version >= '3.8'", - "version": "==8.0.0" + "version": "==8.0.1" }, "pytest-cov": { "hashes": [ From 1e598f37283d1183d79b5bab32e6e12364b603d9 Mon Sep 17 00:00:00 2001 From: Nawaz Date: Tue, 20 Feb 2024 13:38:03 +0000 Subject: [PATCH 6/7] replace CH class with function --- config/settings/base.py | 2 +- report_a_breach/core/services/ch_proxy.py | 34 +++++++++++++---------- report_a_breach/exceptions.py | 4 +++ 3 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 report_a_breach/exceptions.py diff --git a/config/settings/base.py b/config/settings/base.py index 6d127ce7..045ef197 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -189,7 +189,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Companies House API -COMPANIES_HOUSE_API_KEY = env("COMPANIES_HOUSE_API_KEY", default=None) +COMPANIES_HOUSE_API_KEY = env.str("COMPANIES_HOUSE_API_KEY", default="") # GOV NOTIFY GOV_NOTIFY_API_KEY = env.str("GOV_NOTIFY_API_KEY") diff --git a/report_a_breach/core/services/ch_proxy.py b/report_a_breach/core/services/ch_proxy.py index 47080e0a..ad386fba 100644 --- a/report_a_breach/core/services/ch_proxy.py +++ b/report_a_breach/core/services/ch_proxy.py @@ -2,6 +2,7 @@ import requests from django.conf import settings +from exceptions import CompaniesHouseException COMPANIES_HOUSE_BASIC_AUTH = base64.b64encode( # /PS-IGNORE bytes(f"{settings.COMPANIES_HOUSE_API_KEY}:", "utf-8") @@ -9,19 +10,24 @@ COMPANIES_HOUSE_BASE_DOMAIN = "https://api.companieshouse.gov.uk" -class CompaniesHouseApi: - def get_details_from_companies_house(self, registration_number): - """ - Retrieves and returns details of a company from Companies House - using registration number that is passed in. - """ +def get_details_from_companies_house(self, registration_number): + """ + Retrieves and returns details of a company from Companies House + using registration number that is passed in. + """ - if registration_number: - headers_get_company = {"Authorization": f"Basic {COMPANIES_HOUSE_BASIC_AUTH}"} - response = requests.get( - f"{COMPANIES_HOUSE_BASE_DOMAIN}/company/{registration_number}", - headers=headers_get_company, + if registration_number: + headers_get_company = {"Authorization": f"Basic {COMPANIES_HOUSE_BASIC_AUTH}"} + response = requests.get( + f"{COMPANIES_HOUSE_BASE_DOMAIN}/company/{registration_number}", + headers=headers_get_company, + ) + if response.status_code == 200: + return response.json() + else: + raise CompaniesHouseException( + f"Companies House API request failed: {response.status_code}" ) - if response.status_code == 200: - return response.json() - return {} + else: + raise CompaniesHouseException("No registration number provided for Companies House API") + return {} diff --git a/report_a_breach/exceptions.py b/report_a_breach/exceptions.py new file mode 100644 index 00000000..f200b330 --- /dev/null +++ b/report_a_breach/exceptions.py @@ -0,0 +1,4 @@ +class CompaniesHouseException(Exception): + """Exception raised when issue encountered with Companies House API""" + + pass From f827a5d0b56bb63abbbb216997c6bd6e93e864b2 Mon Sep 17 00:00:00 2001 From: Nawaz Date: Tue, 20 Feb 2024 13:51:58 +0000 Subject: [PATCH 7/7] remove redundant return --- report_a_breach/core/services/ch_proxy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/report_a_breach/core/services/ch_proxy.py b/report_a_breach/core/services/ch_proxy.py index ad386fba..e5a6918a 100644 --- a/report_a_breach/core/services/ch_proxy.py +++ b/report_a_breach/core/services/ch_proxy.py @@ -30,4 +30,3 @@ def get_details_from_companies_house(self, registration_number): ) else: raise CompaniesHouseException("No registration number provided for Companies House API") - return {}