From 8b86145519ad5dcd66068d4c9bbf985af7a8d02a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomek=20W=C3=B3jcik?= Date: Sun, 12 Oct 2025 18:37:32 +0000 Subject: [PATCH] BTHLABS-61: Service layer refactoring A journey to fix `ValidationError` in Pocket imports turned service layer refactoring :D --- .gitea/workflows/ci.yaml | 57 +++++++++ services/apple/Dockerfile | 2 +- .../apps/accounts/services/access_tokens.py | 45 ++++--- .../apps/accounts/services/accounts.py | 5 +- .../apps/accounts/services/auth_keys.py | 33 +++-- .../hotpocket_backend/apps/core/rpc.py | 60 ++++++---- .../apps/saves/services/associations.py | 78 +++++++----- .../apps/saves/services/saves.py | 49 +++++--- .../ui/rpc_methods/accounts/access_tokens.py | 6 +- .../apps/ui/rpc_methods/accounts/auth.py | 4 +- .../apps/ui/rpc_methods/saves.py | 2 + .../apps/ui/services/access_tokens.py | 4 +- .../apps/ui/services/associations.py | 4 +- .../apps/ui/services/imports.py | 49 ++++++-- .../apps/ui/services/saves.py | 2 +- .../ui/services/workflows/saves/create.py | 17 +-- .../hotpocket_backend/apps/ui/tasks.py | 2 + .../fixtures/saves/association.py | 24 ++++ .../hotpocket_backend_testing/fixtures/ui.py | 15 +++ .../services/saves/associations.py | 14 +++ .../tests/ui/tasks/test_import_from_pocket.py | 2 + .../ui/views/associations/test_archive.py | 48 ++++++++ .../ui/views/associations/test_delete.py | 89 +++++++++++--- .../tests/ui/views/associations/test_edit.py | 53 ++++++-- .../ui/views/associations/test_refresh.py | 48 ++++++++ .../tests/ui/views/associations/test_star.py | 39 ++++++ .../ui/views/associations/test_unstar.py | 39 ++++++ .../tests/ui/views/associations/test_view.py | 47 ++++++++ .../tests/ui/views/imports/test_pocket.py | 1 + .../rpc/accounts/access_tokens/test_create.py | 23 ++-- services/extension/Dockerfile | 2 +- services/packages/Dockerfile | 2 +- .../packages/soa/hotpocket_soa/constants.py | 11 ++ .../soa/hotpocket_soa/exceptions/__init__.py | 0 .../soa/hotpocket_soa/exceptions/backend.py | 89 ++++++++++++++ .../soa/hotpocket_soa/exceptions/frontend.py | 28 +++++ .../hotpocket_soa/services/access_tokens.py | 68 +++++------ .../soa/hotpocket_soa/services/accounts.py | 18 +-- .../hotpocket_soa/services/associations.py | 113 ++++++++++-------- .../soa/hotpocket_soa/services/auth_keys.py | 33 ++--- .../soa/hotpocket_soa/services/base.py | 65 ++++++++-- .../soa/hotpocket_soa/services/bot.py | 8 +- .../hotpocket_soa/services/save_processor.py | 10 +- .../soa/hotpocket_soa/services/saves.py | 44 +++---- skel/hotpocket.work.bthlabs.net | 8 +- 45 files changed, 1023 insertions(+), 337 deletions(-) create mode 100644 services/packages/soa/hotpocket_soa/constants.py create mode 100644 services/packages/soa/hotpocket_soa/exceptions/__init__.py create mode 100644 services/packages/soa/hotpocket_soa/exceptions/backend.py create mode 100644 services/packages/soa/hotpocket_soa/exceptions/frontend.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 71be5de..789fe0b 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -11,14 +11,64 @@ on: - "public" jobs: + setup: + name: "Setup" + runs-on: "ubuntu-latest" + outputs: + SHORT_SHA: ${{ steps.get-build-options.outputs.SHORT_SHA }} + BUILD_ARCH: ${{ steps.get-build-options.outputs.BUILD_ARCH }} + BUILD_PLATFORM: ${{ steps.get-build-options.outputs.BUILD_PLATFORM }} + HOTPOCKET_BACKEND_VERSION: ${{ steps.get-backend-version.outputs.HOTPOCKET_BACKEND_VERSION }} + HOTPOCKET_BACKEND_BUILD: ${{ steps.get-backend-version.outputs.HOTPOCKET_BACKEND_BUILD }} + steps: + - name: "Checkout the code" + uses: "actions/checkout@v2" + - name: "Get build options" + id: "get-build-options" + run: | + set -x + SHORT_SHA="${GITHUB_SHA::8}" + BUILD_ARCH="amd64" + BUILD_PLATFORM="linux/amd64" + if [ "${RUNNER_ARCH}" = "ARM64" ];then + BUILD_ARCH="arm64" + BUILD_PLATFORM="linux/arm64" + fi + echo "SHORT_SHA=$SHORT_SHA" >> $GITHUB_OUTPUT + echo "BUILD_ARCH=$BUILD_ARCH" >> $GITHUB_OUTPUT + echo "BUILD_PLATFORM=$BUILD_PLATFORM" >> $GITHUB_OUTPUT + - name: "Get `backend` version" + id: "get-backend-version" + run: | + set -x + if [ "${GITHUB_REF_NAME}" = "development" ]; then + VERSION="${GITHUB_SHA::8}" + BUILD="${GITHUB_RUN_NUMBER}" + else + VERSION="v$(grep -Po '(?<=^version\s=\s")[^"]+' services/backend/pyproject.toml)" + BUILD="01" + fi + echo "HOTPOCKET_BACKEND_VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "HOTPOCKET_BACKEND_BUILD=$BUILD" >> $GITHUB_OUTPUT + run-checks: name: "Checks" runs-on: "ubuntu-latest" + needs: + - "setup" steps: - name: "Checkout the code" uses: "actions/checkout@v2" - name: "Set up Docker Buildx" + id: "setup-docker-buildx" uses: "docker/setup-buildx-action@v3" + with: + driver: "remote" + endpoint: "tcp://builder-01.bthlab:2375" + platforms: "linux/amd64" + append: | + - endpoint: "tcp://builder-mac-01.bthlab:2375" + platforms: "linux/arm64" - name: "Build `postgres` image" uses: docker/build-push-action@v6 with: @@ -27,6 +77,7 @@ jobs: push: false load: true tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-local" + platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}" - name: "Build `keycloak` image" uses: docker/build-push-action@v6 with: @@ -35,6 +86,7 @@ jobs: push: false load: true tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-local" + platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}" - name: "Build `rabbitmq` image" uses: docker/build-push-action@v6 with: @@ -43,6 +95,7 @@ jobs: push: false load: true tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-local" + platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}" - name: "Build `backend-ci` image" uses: docker/build-push-action@v6 with: @@ -52,6 +105,7 @@ jobs: push: false load: true tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-local" + platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}" - name: "Build `packages-ci` image" uses: docker/build-push-action@v6 with: @@ -61,6 +115,7 @@ jobs: push: false load: true tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-local" + platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}" - name: "Build `extension-ci` image" uses: docker/build-push-action@v6 with: @@ -70,6 +125,7 @@ jobs: push: false load: true tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/extension:ci-local" + platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}" - name: "Build `apple-ci` image" uses: docker/build-push-action@v6 with: @@ -79,6 +135,7 @@ jobs: push: false load: true tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-local" + platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}" - name: "Run `backend` checks" run: | set -x diff --git a/services/apple/Dockerfile b/services/apple/Dockerfile index 46c14a6..1202ef6 100644 --- a/services/apple/Dockerfile +++ b/services/apple/Dockerfile @@ -8,7 +8,7 @@ ARG APP_USER_UID ARG APP_USER_GID ARG IMAGE_ID -COPY --chown=$APP_USER_UID:$APP_USER_GID apple/ops/bin/*.sh /srv/bin/ +# COPY --chown=$APP_USER_UID:$APP_USER_GID apple/ops/bin/*.sh /srv/bin/ VOLUME ["/srv/node_modules", "/srv/venv"] diff --git a/services/backend/hotpocket_backend/apps/accounts/services/access_tokens.py b/services/backend/hotpocket_backend/apps/accounts/services/access_tokens.py index 59955d8..7cba805 100644 --- a/services/backend/hotpocket_backend/apps/accounts/services/access_tokens.py +++ b/services/backend/hotpocket_backend/apps/accounts/services/access_tokens.py @@ -6,6 +6,7 @@ import hmac import logging import uuid +from django.core.exceptions import ValidationError from django.db import models import uuid6 @@ -15,6 +16,10 @@ from hotpocket_soa.dto.accounts import ( AccessTokenMetaUpdateIn, AccessTokensQuery, ) +from hotpocket_soa.exceptions.backend import ( + Invalid as InvalidError, + NotFound as NotFoundError, +) LOGGER = logging.getLogger(__name__) @@ -23,7 +28,10 @@ class AccessTokensService: class AccessTokensServiceError(Exception): pass - class AccessTokenNotFound(AccessTokensServiceError): + class Invalid(InvalidError, AccessTokensServiceError): + pass + + class NotFound(NotFoundError, AccessTokensServiceError): pass def create(self, @@ -32,20 +40,23 @@ class AccessTokensService: origin: str, meta: dict, ) -> AccessToken: - pk = uuid6.uuid7() - key = hmac.new( - settings.SECRET_KEY.encode('ascii'), - msg=pk.bytes, - digestmod=hashlib.sha256, - ) + try: + pk = uuid6.uuid7() + key = hmac.new( + settings.SECRET_KEY.encode('ascii'), + msg=pk.bytes, + digestmod=hashlib.sha256, + ) - return AccessToken.objects.create( - pk=pk, - account_uuid=account_uuid, - key=key.hexdigest(), - origin=origin, - meta=meta, - ) + return AccessToken.objects.create( + pk=pk, + account_uuid=account_uuid, + key=key.hexdigest(), + origin=origin, + meta=meta, + ) + except ValidationError as exception: + raise self.Invalid.from_django_validation_error(exception) def get(self, *, pk: uuid.UUID) -> AccessToken: try: @@ -53,7 +64,7 @@ class AccessTokensService: return query_set.get(pk=pk) except AccessToken.DoesNotExist as exception: - raise self.AccessTokenNotFound( + raise self.NotFound( f'Access Token not found: pk=`{pk}`', ) from exception @@ -63,7 +74,7 @@ class AccessTokensService: return query_set.get(key=key) except AccessToken.DoesNotExist as exception: - raise self.AccessTokenNotFound( + raise self.NotFound( f'Access Token not found: key=`{key}`', ) from exception @@ -98,7 +109,7 @@ class AccessTokensService: pk: uuid.UUID, update: AccessTokenMetaUpdateIn, ) -> AccessToken: - access_token = AccessToken.active_objects.get(pk=pk) + access_token = self.get(pk=pk) next_meta = { **(access_token.meta or {}), diff --git a/services/backend/hotpocket_backend/apps/accounts/services/accounts.py b/services/backend/hotpocket_backend/apps/accounts/services/accounts.py index b21ddf1..6bf6ba5 100644 --- a/services/backend/hotpocket_backend/apps/accounts/services/accounts.py +++ b/services/backend/hotpocket_backend/apps/accounts/services/accounts.py @@ -5,6 +5,7 @@ import logging import uuid from hotpocket_backend.apps.accounts.models import Account +from hotpocket_soa.exceptions.backend import NotFound as NotFoundError LOGGER = logging.getLogger(__name__) @@ -13,7 +14,7 @@ class AccountsService: class AccountsServiceError(Exception): pass - class AccountNotFound(AccountsServiceError): + class NotFound(NotFoundError, AccountsServiceError): pass def get(self, *, pk: uuid.UUID) -> Account: @@ -22,6 +23,6 @@ class AccountsService: return query_set.get(pk=pk) except Account.DoesNotExist as exception: - raise self.AccountNotFound( + raise self.NotFound( f'Account not found: pk=`{pk}`', ) from exception diff --git a/services/backend/hotpocket_backend/apps/accounts/services/auth_keys.py b/services/backend/hotpocket_backend/apps/accounts/services/auth_keys.py index 17d79d8..6daa2e7 100644 --- a/services/backend/hotpocket_backend/apps/accounts/services/auth_keys.py +++ b/services/backend/hotpocket_backend/apps/accounts/services/auth_keys.py @@ -5,11 +5,17 @@ import datetime import logging import uuid +from django.core.exceptions import ValidationError from django.utils.timezone import now import uuid6 from hotpocket_backend.apps.accounts.models import AuthKey from hotpocket_backend.apps.core.conf import settings +from hotpocket_soa.exceptions.backend import ( + InternalError, + Invalid as InvalidError, + NotFound as NotFoundError, +) LOGGER = logging.getLogger(__name__) @@ -18,22 +24,25 @@ class AuthKeysService: class AuthKeysServiceError(Exception): pass - class AuthKeyNotFound(AuthKeysServiceError): + class Invalid(InvalidError, AuthKeysServiceError): pass - class AuthKeyExpired(AuthKeysServiceError): + class NotFound(NotFoundError, AuthKeysServiceError): pass - class AuthKeyAccessDenied(AuthKeysServiceError): + class Expired(InternalError, AuthKeysServiceError): pass def create(self, *, account_uuid: uuid.UUID) -> AuthKey: - key = str(uuid6.uuid7()) + try: + key = str(uuid6.uuid7()) - return AuthKey.objects.create( - account_uuid=account_uuid, - key=key, - ) + return AuthKey.objects.create( + account_uuid=account_uuid, + key=key, + ) + except ValidationError as exception: + raise self.Invalid.from_django_validation_error(exception) def get(self, *, pk: uuid.UUID) -> AuthKey: try: @@ -41,7 +50,7 @@ class AuthKeysService: return query_set.get(pk=pk) except AuthKey.DoesNotExist as exception: - raise self.AuthKeyNotFound( + raise self.NotFound( f'Auth Key not found: pk=`{pk}`', ) from exception @@ -56,17 +65,17 @@ class AuthKeysService: if ttl > 0: if result.created_at < now() - datetime.timedelta(seconds=ttl): - raise self.AuthKeyExpired( + raise self.Expired( f'Auth Key expired: pk=`{key}`', ) if result.consumed_at is not None: - raise self.AuthKeyExpired( + raise self.Expired( f'Auth Key already consumed: pk=`{key}`', ) return result except AuthKey.DoesNotExist as exception: - raise self.AuthKeyNotFound( + raise self.NotFound( f'Auth Key not found: key=`{key}`', ) from exception diff --git a/services/backend/hotpocket_backend/apps/core/rpc.py b/services/backend/hotpocket_backend/apps/core/rpc.py index f4a0ffb..1f52da1 100644 --- a/services/backend/hotpocket_backend/apps/core/rpc.py +++ b/services/backend/hotpocket_backend/apps/core/rpc.py @@ -1,16 +1,39 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import functools import typing +from bthlabs_jsonrpc_core.exceptions import BaseJSONRPCError from bthlabs_jsonrpc_django import ( DjangoExecutor, DjangoJSONRPCSerializer, JSONRPCView as BaseJSONRPCView, ) -from django.core.exceptions import ValidationError import uuid6 +from hotpocket_soa.exceptions.frontend import SOAError + + +class SOAJSONRPCError(BaseJSONRPCError): + ERROR_CODE = -32000 + ERROR_MESSAGE = 'SOA Error' + + def to_rpc(self) -> dict: + exception = typing.cast(SOAError, self.data) + + code = ( + exception.code + if exception.code is not None + else self.ERROR_CODE + ) + + return { + 'code': code, + 'message': exception.message or self.ERROR_MESSAGE, + 'data': exception.data, + } + class JSONRPCSerializer(DjangoJSONRPCSerializer): STRING_COERCIBLE_TYPES: typing.Any = ( @@ -18,30 +41,6 @@ class JSONRPCSerializer(DjangoJSONRPCSerializer): uuid6.UUID, ) - def serialize_value(self, value: typing.Any) -> typing.Any: - if isinstance(value, ValidationError): - result: typing.Any = None - - if hasattr(value, 'error_dict') is True: - result = {} - for field, errors in value.error_dict.items(): - result[field] = [ - error.code - for error - in errors - ] - elif hasattr(value, 'error_list') is True: - result = [ - error.code - for error in value.error_list - ] - else: - result = value.code - - return self.serialize_value(result) - - return super().serialize_value(value) - class Executor(DjangoExecutor): serializer = JSONRPCSerializer @@ -49,3 +48,14 @@ class Executor(DjangoExecutor): class JSONRPCView(BaseJSONRPCView): executor = Executor + + +def wrap_soa_errors(func: typing.Callable) -> typing.Callable: + @functools.wraps(func) + def decorator(*args, **kwargs): + try: + return func(*args, **kwargs) + except SOAError as exception: + raise SOAJSONRPCError(exception) + + return decorator diff --git a/services/backend/hotpocket_backend/apps/saves/services/associations.py b/services/backend/hotpocket_backend/apps/saves/services/associations.py index 25e1eac..3c5aeae 100644 --- a/services/backend/hotpocket_backend/apps/saves/services/associations.py +++ b/services/backend/hotpocket_backend/apps/saves/services/associations.py @@ -5,6 +5,7 @@ import datetime import logging import uuid +from django.core.exceptions import ValidationError from django.db import models from django.utils.timezone import now @@ -15,6 +16,10 @@ from hotpocket_soa.dto.associations import ( AssociationsQuery, AssociationUpdateIn, ) +from hotpocket_soa.exceptions.backend import ( + Invalid as InvalidError, + NotFound as NotFoundError, +) from .saves import SavesService @@ -25,7 +30,10 @@ class AssociationsService: class AssociationsServiceError(Exception): pass - class AssociationNotFound(AssociationsServiceError): + class Invalid(InvalidError, AssociationsServiceError): + pass + + class NotFound(NotFoundError, AssociationsServiceError): pass @property @@ -46,30 +54,33 @@ class AssociationsService: pk: uuid.UUID | None = None, created_at: datetime.datetime | None = None, ) -> Association: - save = SavesService().get(pk=save_uuid) + try: + save = SavesService().get(pk=save_uuid) - defaults = dict( - account_uuid=account_uuid, - target=save, - ) + defaults = dict( + account_uuid=account_uuid, + target=save, + ) - if pk is not None: - defaults['id'] = pk + if pk is not None: + defaults['id'] = pk - result, created = Association.objects.get_or_create( - account_uuid=account_uuid, - deleted_at__isnull=True, - target=save, - archived_at__isnull=True, - defaults=defaults, - ) + result, created = Association.objects.get_or_create( + account_uuid=account_uuid, + deleted_at__isnull=True, + target=save, + archived_at__isnull=True, + defaults=defaults, + ) - if created is True: - if created_at is not None: - result.created_at = created_at - result.save() + if created is True: + if created_at is not None: + result.created_at = created_at + result.save() - return result + return result + except ValidationError as exception: + raise self.Invalid.from_django_validation_error(exception) def get(self, *, @@ -87,7 +98,7 @@ class AssociationsService: return query_set.get(pk=pk) except Association.DoesNotExist as exception: - raise self.AssociationNotFound( + raise self.NotFound( f'Association not found: pk=`{pk}`', ) from exception @@ -112,21 +123,24 @@ class AssociationsService: pk: uuid.UUID, update: AssociationUpdateIn, ) -> Association: - association = self.get(pk=pk) - association.target_title = update.target_title - association.target_description = update.target_description + try: + association = self.get(pk=pk) + association.target_title = update.target_title + association.target_description = update.target_description - next_target_meta = { - **(association.target_meta or {}), - } + next_target_meta = { + **(association.target_meta or {}), + } - next_target_meta.pop('title', None) - next_target_meta.pop('description', None) - association.target_meta = next_target_meta + next_target_meta.pop('title', None) + next_target_meta.pop('description', None) + association.target_meta = next_target_meta - association.save() + association.save() - return association + return association + except ValidationError as exception: + raise self.Invalid.from_django_validation_error(exception) def archive(self, *, pk: uuid.UUID) -> bool: association = self.get(pk=pk) diff --git a/services/backend/hotpocket_backend/apps/saves/services/saves.py b/services/backend/hotpocket_backend/apps/saves/services/saves.py index 4ac1b43..0774c9a 100644 --- a/services/backend/hotpocket_backend/apps/saves/services/saves.py +++ b/services/backend/hotpocket_backend/apps/saves/services/saves.py @@ -5,19 +5,27 @@ import hashlib import typing import uuid +from django.core.exceptions import ValidationError from django.db import models from hotpocket_backend.apps.core.services import get_adapter from hotpocket_backend.apps.saves.models import Save from hotpocket_backend.apps.saves.types import PSaveAdapter from hotpocket_soa.dto.saves import ImportedSaveIn, SaveIn, SavesQuery +from hotpocket_soa.exceptions.backend import ( + Invalid as InvalidError, + NotFound as NotFoundError, +) class SavesService: class SavesServiceError(Exception): pass - class SaveNotFound(SavesServiceError): + class Invalid(InvalidError, SavesServiceError): + pass + + class NotFound(NotFoundError, SavesServiceError): pass @property @@ -36,35 +44,38 @@ class SavesService: account_uuid: uuid.UUID, save: SaveIn | ImportedSaveIn, ) -> Save: - key = hashlib.sha256(save.url.encode('utf-8')).hexdigest() + try: + key = hashlib.sha256(save.url.encode('utf-8')).hexdigest() - defaults = dict( - account_uuid=account_uuid, - key=key, - url=save.url, - ) + defaults = dict( + account_uuid=account_uuid, + key=key, + url=save.url, + ) - save_object, created = Save.objects.get_or_create( - key=key, - deleted_at__isnull=True, - defaults=defaults, - ) + save_object, created = Save.objects.get_or_create( + key=key, + deleted_at__isnull=True, + defaults=defaults, + ) - if created is True: - save_object.is_netloc_banned = save.is_netloc_banned + if created is True: + save_object.is_netloc_banned = save.is_netloc_banned - if isinstance(save, ImportedSaveIn) is True: - save_object.title = save.title # type: ignore[union-attr] + if isinstance(save, ImportedSaveIn) is True: + save_object.title = save.title # type: ignore[union-attr] - save_object.save() + save_object.save() - return save_object + return save_object + except ValidationError as exception: + raise self.Invalid.from_django_validation_error(exception) def get(self, *, pk: uuid.UUID) -> Save: try: return Save.active_objects.get(pk=pk) except Save.DoesNotExist as exception: - raise self.SaveNotFound( + raise self.NotFound( f'Save not found: pk=`{pk}`', ) from exception diff --git a/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/access_tokens.py b/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/access_tokens.py index ca57a8e..a8426cf 100644 --- a/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/access_tokens.py +++ b/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/access_tokens.py @@ -7,6 +7,7 @@ from bthlabs_jsonrpc_core import register_method from django import db from django.http import HttpRequest +from hotpocket_backend.apps.core.rpc import wrap_soa_errors from hotpocket_soa.services import ( AccessTokensService, AccountsService, @@ -17,6 +18,7 @@ LOGGER = logging.getLogger(__name__) @register_method('accounts.access_tokens.create', namespace='accounts') +@wrap_soa_errors def create(request: HttpRequest, auth_key: str, meta: dict, @@ -27,7 +29,7 @@ def create(request: HttpRequest, account_uuid=None, key=auth_key, ) - except AuthKeysService.AuthKeyNotFound as exception: + except AuthKeysService.NotFound as exception: LOGGER.error( 'Unable to issue access token: %s', exception, @@ -37,7 +39,7 @@ def create(request: HttpRequest, try: account = AccountsService().get(pk=auth_key_object.account_uuid) - except AccountsService.AccountNotFound as exception: + except AccountsService.NotFound as exception: LOGGER.error( 'Unable to issue access token: %s', exception, diff --git a/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/auth.py b/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/auth.py index d9ba926..9dae72a 100644 --- a/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/auth.py +++ b/services/backend/hotpocket_backend/apps/ui/rpc_methods/accounts/auth.py @@ -44,7 +44,7 @@ def check_access_token(request: HttpRequest, access_token=access_token_object, update=meta_update, ) - except AccessTokensService.AccessTokenNotFound as exception: + except AccessTokensService.NotFound as exception: LOGGER.error( 'Access Token not found: account_uuid=`%s` key=`%s`', request.user.pk, @@ -52,7 +52,7 @@ def check_access_token(request: HttpRequest, exc_info=exception, ) result = False - except AccessTokensService.AccessTokenAccessDenied as exception: + except AccessTokensService.AccessDenied as exception: LOGGER.error( 'Access Token access denied: account_uuid=`%s` key=`%s`', request.user.pk, diff --git a/services/backend/hotpocket_backend/apps/ui/rpc_methods/saves.py b/services/backend/hotpocket_backend/apps/ui/rpc_methods/saves.py index 8a37ffc..e62dc6e 100644 --- a/services/backend/hotpocket_backend/apps/ui/rpc_methods/saves.py +++ b/services/backend/hotpocket_backend/apps/ui/rpc_methods/saves.py @@ -4,11 +4,13 @@ from __future__ import annotations from bthlabs_jsonrpc_core import register_method from django.http import HttpRequest +from hotpocket_backend.apps.core.rpc import wrap_soa_errors from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow from hotpocket_soa.dto.associations import AssociationOut @register_method(method='saves.create') +@wrap_soa_errors def create(request: HttpRequest, url: str) -> AssociationOut: association = CreateSaveWorkflow().run_rpc( request=request, diff --git a/services/backend/hotpocket_backend/apps/ui/services/access_tokens.py b/services/backend/hotpocket_backend/apps/ui/services/access_tokens.py index 2e0a1bb..b45349c 100644 --- a/services/backend/hotpocket_backend/apps/ui/services/access_tokens.py +++ b/services/backend/hotpocket_backend/apps/ui/services/access_tokens.py @@ -27,7 +27,7 @@ class UIAccessTokensService: account_uuid=account_uuid, pk=pk, ) - except AccessTokensService.AccessTokenNotFound as exception: + except AccessTokensService.NotFound as exception: LOGGER.error( 'Access Token not found: account_uuid=`%s` pk=`%s`', account_uuid, @@ -35,7 +35,7 @@ class UIAccessTokensService: exc_info=exception, ) raise Http404() - except AccessTokensService.AccessTokenAccessDenied as exception: + except AccessTokensService.AccessDenied as exception: LOGGER.error( 'Access Token access denied: account_uuid=`%s` pk=`%s`', account_uuid, diff --git a/services/backend/hotpocket_backend/apps/ui/services/associations.py b/services/backend/hotpocket_backend/apps/ui/services/associations.py index 4286a8e..9e03b70 100644 --- a/services/backend/hotpocket_backend/apps/ui/services/associations.py +++ b/services/backend/hotpocket_backend/apps/ui/services/associations.py @@ -34,7 +34,7 @@ class UIAssociationsService: with_target=True, allow_archived=allow_archived, ) - except AssociationsService.AssociationNotFound as exception: + except AssociationsService.NotFound as exception: LOGGER.error( 'Association not found: account_uuid=`%s` pk=`%s`', account_uuid, @@ -42,7 +42,7 @@ class UIAssociationsService: exc_info=exception, ) raise Http404() - except AssociationsService.AssociationAccessDenied as exception: + except AssociationsService.AccessDenied as exception: LOGGER.error( 'Association access denied: account_uuid=`%s` pk=`%s`', account_uuid, diff --git a/services/backend/hotpocket_backend/apps/ui/services/imports.py b/services/backend/hotpocket_backend/apps/ui/services/imports.py index 7903477..39004c8 100644 --- a/services/backend/hotpocket_backend/apps/ui/services/imports.py +++ b/services/backend/hotpocket_backend/apps/ui/services/imports.py @@ -3,6 +3,7 @@ from __future__ import annotations import csv import datetime +import logging import os import uuid @@ -13,16 +14,28 @@ from django.utils.timezone import get_current_timezone from hotpocket_backend.apps.ui.services.workflows import ImportSaveWorkflow from hotpocket_backend.apps.ui.tasks import import_from_pocket from hotpocket_common.uuid import uuid7_from_timestamp +from hotpocket_soa.services import SavesService + +LOGGER = logging.getLogger(__name__) class UIImportsService: def import_from_pocket(self, *, + job: str, account_uuid: uuid.UUID, csv_path: str, ) -> list[tuple[uuid.UUID, uuid.UUID]]: - result = [] + LOGGER.info( + 'Starting import job: job=`%s` account_uuid=`%s`', + job, + account_uuid, + extra={ + 'job': job, + }, + ) + result = [] with db.transaction.atomic(): try: with open(csv_path, 'r', encoding='utf-8') as csv_file: @@ -34,22 +47,35 @@ class UIImportsService: current_timezone = get_current_timezone() is_header = False - for row in csv_reader: + for row_number, row in enumerate(csv_reader, start=1): if is_header is False: is_header = True continue timestamp = int(row['time_added']) - save, association = ImportSaveWorkflow().run( - account_uuid=account_uuid, - url=row['url'], - title=row['title'], - pk=uuid7_from_timestamp(timestamp), - created_at=datetime.datetime.fromtimestamp( - timestamp, tz=current_timezone, - ), - ) + try: + save, association = ImportSaveWorkflow().run( + account_uuid=account_uuid, + url=row['url'], + title=row['title'], + pk=uuid7_from_timestamp(timestamp), + created_at=datetime.datetime.fromtimestamp( + timestamp, tz=current_timezone, + ), + ) + except SavesService.Invalid as exception: + LOGGER.error( + 'Import error: row_number=`%d` url=`%s` exception=`%s`', + row_number, + row['url'], + exception, + exc_info=exception, + extra={ + 'job': job, + }, + ) + continue result.append((save.pk, association.pk)) finally: @@ -64,6 +90,7 @@ class UIImportsService: ) -> AsyncResult: return import_from_pocket.apply_async( kwargs={ + 'job': str(uuid.uuid4()), 'account_uuid': account_uuid, 'csv_path': csv_path, }, diff --git a/services/backend/hotpocket_backend/apps/ui/services/saves.py b/services/backend/hotpocket_backend/apps/ui/services/saves.py index 85fd638..51635bb 100644 --- a/services/backend/hotpocket_backend/apps/ui/services/saves.py +++ b/services/backend/hotpocket_backend/apps/ui/services/saves.py @@ -19,7 +19,7 @@ class UISavesService: def get_or_404(self, *, pk: uuid.UUID) -> SaveOut: try: return SavesService().get(pk=pk) - except SavesService.SaveNotFound as exception: + except SavesService.NotFound as exception: LOGGER.error( 'Save not found: pk=`%s`', pk, exc_info=exception, ) diff --git a/services/backend/hotpocket_backend/apps/ui/services/workflows/saves/create.py b/services/backend/hotpocket_backend/apps/ui/services/workflows/saves/create.py index 4b1a1df..b19fe3b 100644 --- a/services/backend/hotpocket_backend/apps/ui/services/workflows/saves/create.py +++ b/services/backend/hotpocket_backend/apps/ui/services/workflows/saves/create.py @@ -1,9 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations -from bthlabs_jsonrpc_core import JSONRPCInternalError from django.contrib import messages -from django.core.exceptions import ValidationError import django.db from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect @@ -14,7 +12,6 @@ from hotpocket_backend.apps.accounts.types import PAccount from hotpocket_soa.dto.associations import AssociationOut from hotpocket_soa.dto.celery import AsyncResultOut from hotpocket_soa.dto.saves import SaveIn, SaveOut -from hotpocket_soa.services import SavesService from .base import SaveWorkflow @@ -73,14 +70,8 @@ class CreateSaveWorkflow(SaveWorkflow): account: PAccount, url: str, ) -> AssociationOut: - try: - save, association, processing_result = self.create_associate_and_process( - account, url, - ) + save, association, processing_result = self.create_associate_and_process( + account, url, + ) - return association - except SavesService.SavesServiceError as exception: - if isinstance(exception.__cause__, ValidationError) is True: - raise JSONRPCInternalError(data=exception.__cause__) - - raise + return association diff --git a/services/backend/hotpocket_backend/apps/ui/tasks.py b/services/backend/hotpocket_backend/apps/ui/tasks.py index a65fd09..5341f48 100644 --- a/services/backend/hotpocket_backend/apps/ui/tasks.py +++ b/services/backend/hotpocket_backend/apps/ui/tasks.py @@ -11,6 +11,7 @@ LOGGER = logging.getLogger(__name__) @shared_task def import_from_pocket(*, + job: str, account_uuid: uuid.UUID, csv_path: str, ) -> list[tuple[uuid.UUID, uuid.UUID]]: @@ -18,6 +19,7 @@ def import_from_pocket(*, try: return UIImportsService().import_from_pocket( + job=job, account_uuid=account_uuid, csv_path=csv_path, ) diff --git a/services/backend/testing/hotpocket_backend_testing/fixtures/saves/association.py b/services/backend/testing/hotpocket_backend_testing/fixtures/saves/association.py index 4e42061..fe1488d 100644 --- a/services/backend/testing/hotpocket_backend_testing/fixtures/saves/association.py +++ b/services/backend/testing/hotpocket_backend_testing/fixtures/saves/association.py @@ -76,6 +76,18 @@ def starred_association_out(starred_association): ) +@pytest.fixture +def deleted_save_association(association_factory, deleted_save): + return association_factory(target=deleted_save) + + +@pytest.fixture +def deleted_save_association_out(deleted_save_association): + return AssociationWithTargetOut.model_validate( + deleted_save_association, from_attributes=True, + ) + + @pytest.fixture def other_account_association(association_factory, other_account): return association_factory(account=other_account) @@ -124,6 +136,18 @@ def other_account_starred_association_out(other_account_starred_association): ) +@pytest.fixture +def other_account_deleted_save_association(association_factory, other_account, deleted_save): + return association_factory(account=other_account, target=deleted_save) + + +@pytest.fixture +def other_account_deleted_save_association_out(other_account_deleted_save_association): + return AssociationWithTargetOut.model_validate( + other_account_deleted_save_association, from_attributes=True, + ) + + @pytest.fixture def browsable_associations(association, deleted_association, diff --git a/services/backend/testing/hotpocket_backend_testing/fixtures/ui.py b/services/backend/testing/hotpocket_backend_testing/fixtures/ui.py index efacf1d..a866434 100644 --- a/services/backend/testing/hotpocket_backend_testing/fixtures/ui.py +++ b/services/backend/testing/hotpocket_backend_testing/fixtures/ui.py @@ -64,11 +64,25 @@ def pocket_import_banned_netloc_save_spec(): }) +@pytest.fixture +def pocket_import_invalid_url_spec(): + return PocketImportSaveSpec.model_validate({ + 'title': "This isn't right", + 'url': 'thisisntright', + 'time_added': datetime.datetime( + 2021, 1, 18, 8, 0, 0, 0, tzinfo=datetime.UTC, + ), + 'tags': '', + 'status': 'unread', + }) + + @pytest.fixture def pocket_csv_content(pocket_import_created_save_spec, pocket_import_reused_save_spec, pocket_import_other_account_save_spec, pocket_import_banned_netloc_save_spec, + pocket_import_invalid_url_spec, ): with io.StringIO() as csv_f: field_names = [ @@ -82,6 +96,7 @@ def pocket_csv_content(pocket_import_created_save_spec, pocket_import_reused_save_spec.dict(), pocket_import_other_account_save_spec.dict(), pocket_import_banned_netloc_save_spec.dict(), + pocket_import_invalid_url_spec.dict(), ]) csv_f.seek(0) diff --git a/services/backend/testing/hotpocket_backend_testing/services/saves/associations.py b/services/backend/testing/hotpocket_backend_testing/services/saves/associations.py index 95c504a..aaa351d 100644 --- a/services/backend/testing/hotpocket_backend_testing/services/saves/associations.py +++ b/services/backend/testing/hotpocket_backend_testing/services/saves/associations.py @@ -29,6 +29,20 @@ class AssociationsTestingService: if reference is not None: assert association.updated_at > reference.updated_at + def assert_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None): + association = Association.objects.get(pk=pk) + assert association.deleted_at is not None + + if reference is not None: + assert association.updated_at > reference.updated_at + + def assert_not_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None): + association = Association.objects.get(pk=pk) + assert association.deleted_at is None + + if reference is not None: + assert association.updated_at == reference.updated_at + def assert_starred(self, *, pk: uuid.UUID, reference: typing.Any = None): association = Association.objects.get(pk=pk) assert association.starred_at is not None diff --git a/services/backend/tests/ui/tasks/test_import_from_pocket.py b/services/backend/tests/ui/tasks/test_import_from_pocket.py index 3a443eb..b8f07fd 100644 --- a/services/backend/tests/ui/tasks/test_import_from_pocket.py +++ b/services/backend/tests/ui/tasks/test_import_from_pocket.py @@ -45,10 +45,12 @@ def test_ok(account, other_account_save_out, pocket_import_other_account_save_spec: PocketImportSaveSpec, pocket_import_banned_netloc_save_spec: PocketImportSaveSpec, + pocket_import_invalid_url_spec: PocketImportSaveSpec, mock_saves_process_save_task_apply_async: mock.Mock, ): # When result = tasks_module.import_from_pocket( + job='test', account_uuid=account.pk, csv_path=str(pocket_csv_file_path), ) diff --git a/services/backend/tests/ui/views/associations/test_archive.py b/services/backend/tests/ui/views/associations/test_archive.py index 5c8a50f..6f160f9 100644 --- a/services/backend/tests/ui/views/associations/test_archive.py +++ b/services/backend/tests/ui/views/associations/test_archive.py @@ -100,6 +100,54 @@ def test_invalid_all_empty(authenticated_client: Client, assert 'canhazconfirm' in result.context['form'].errors +@pytest.mark.django_db +def test_archived(authenticated_client: Client, + archived_association_out, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.archive', args=(archived_association_out.pk,)), + data={ + 'canhazconfirm': 'hai', + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_deleted(authenticated_client: Client, + deleted_association_out, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.archive', args=(deleted_association_out.pk,)), + data={ + 'canhazconfirm': 'hai', + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_not_found(authenticated_client: Client, + null_uuid, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.archive', args=(null_uuid,)), + data={ + 'canhazconfirm': 'hai', + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db def test_other_account_association(authenticated_client: Client, other_account_association_out, diff --git a/services/backend/tests/ui/views/associations/test_delete.py b/services/backend/tests/ui/views/associations/test_delete.py index c10e586..a086790 100644 --- a/services/backend/tests/ui/views/associations/test_delete.py +++ b/services/backend/tests/ui/views/associations/test_delete.py @@ -9,7 +9,7 @@ from django.urls import reverse import pytest from pytest_django import asserts -from hotpocket_backend.apps.saves.models import Association +from hotpocket_backend_testing.services.saves import AssociationsTestingService from hotpocket_common.constants import AssociationsSearchMode @@ -35,9 +35,10 @@ def test_ok(authenticated_client: Client, fetch_redirect_response=False, ) - association_object = Association.objects.get(pk=association_out.pk) - assert association_object.updated_at > association_out.updated_at - assert association_object.deleted_at is not None + AssociationsTestingService().assert_deleted( + pk=association_out.pk, + reference=association_out, + ) @pytest.mark.django_db @@ -65,6 +66,34 @@ def test_ok_htmx(authenticated_client: Client, assert result.json() == expected_payload +@pytest.mark.django_db +def test_ok_archived(authenticated_client: Client, + archived_association_out, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.delete', args=(archived_association_out.pk,)), + data={ + 'canhazconfirm': 'hai', + }, + ) + + # Then + asserts.assertRedirects( + result, + reverse( + 'ui.associations.browse', + query=[('mode', AssociationsSearchMode.ARCHIVED.value)], + ), + fetch_redirect_response=False, + ) + + AssociationsTestingService().assert_deleted( + pk=archived_association_out.pk, + reference=archived_association_out, + ) + + @pytest.mark.django_db def test_invalid_all_missing(authenticated_client: Client, association_out, @@ -78,13 +107,13 @@ def test_invalid_all_missing(authenticated_client: Client, # Then assert result.status_code == http.HTTPStatus.OK - - association_object = Association.objects.get(pk=association_out.pk) - assert association_object.updated_at == association_out.updated_at - assert association_object.deleted_at is None - assert 'canhazconfirm' in result.context['form'].errors + AssociationsTestingService().assert_not_deleted( + pk=association_out.pk, + reference=association_out, + ) + @pytest.mark.django_db def test_invalid_all_empty(authenticated_client: Client, @@ -100,13 +129,45 @@ def test_invalid_all_empty(authenticated_client: Client, # Then assert result.status_code == http.HTTPStatus.OK - - association_object = Association.objects.get(pk=association_out.pk) - assert association_object.updated_at == association_out.updated_at - assert association_object.deleted_at is None - assert 'canhazconfirm' in result.context['form'].errors + AssociationsTestingService().assert_not_deleted( + pk=association_out.pk, + reference=association_out, + ) + + +@pytest.mark.django_db +def test_deleted(authenticated_client: Client, + deleted_association_out, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.delete', args=(deleted_association_out.pk,)), + data={ + 'canhazconfirm': 'hai', + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_not_found(authenticated_client: Client, + null_uuid, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.delete', args=(null_uuid,)), + data={ + 'canhazconfirm': 'hai', + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + @pytest.mark.django_db def test_other_account_association(authenticated_client: Client, diff --git a/services/backend/tests/ui/views/associations/test_edit.py b/services/backend/tests/ui/views/associations/test_edit.py index 25e5b40..b49571c 100644 --- a/services/backend/tests/ui/views/associations/test_edit.py +++ b/services/backend/tests/ui/views/associations/test_edit.py @@ -47,10 +47,10 @@ def test_ok(authenticated_client: Client, @pytest.mark.django_db -def test_invalid_all_empty(authenticated_client: Client, - association_out, - payload, - ): +def test_all_empty(authenticated_client: Client, + association_out, + payload, + ): # Given effective_payload = { key: '' @@ -79,9 +79,9 @@ def test_invalid_all_empty(authenticated_client: Client, @pytest.mark.django_db -def test_invalid_all_missing(authenticated_client: Client, - association_out, - ): +def test_all_missing(authenticated_client: Client, + association_out, + ): # Given effective_payload = {} @@ -105,6 +105,45 @@ def test_invalid_all_missing(authenticated_client: Client, ) +@pytest.mark.django_db +def test_archived(authenticated_client: Client, + archived_association_out, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.edit', args=(archived_association_out.pk,)), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_deleted(authenticated_client: Client, + deleted_association_out, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.edit', args=(deleted_association_out.pk,)), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_not_found(authenticated_client: Client, + null_uuid, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.edit', args=(null_uuid,)), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db def test_other_account_association(authenticated_client: Client, other_account_association_out, diff --git a/services/backend/tests/ui/views/associations/test_refresh.py b/services/backend/tests/ui/views/associations/test_refresh.py index d4cfbd3..a4adbd5 100644 --- a/services/backend/tests/ui/views/associations/test_refresh.py +++ b/services/backend/tests/ui/views/associations/test_refresh.py @@ -128,6 +128,54 @@ def test_invalid_all_empty(authenticated_client: Client, assert 'canhazconfirm' in result.context['form'].errors +@pytest.mark.django_db +def test_archived(authenticated_client: Client, + archived_association_out, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.refresh', args=(archived_association_out.pk,)), + data={ + 'canhazconfirm': 'hai', + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_deleted(authenticated_client: Client, + deleted_association_out, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.refresh', args=(deleted_association_out.pk,)), + data={ + 'canhazconfirm': 'hai', + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_not_found(authenticated_client: Client, + null_uuid, + ): + # When + result = authenticated_client.post( + reverse('ui.associations.refresh', args=(null_uuid,)), + data={ + 'canhazconfirm': 'hai', + }, + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db def test_other_account_association(authenticated_client: Client, other_account_association_out, diff --git a/services/backend/tests/ui/views/associations/test_star.py b/services/backend/tests/ui/views/associations/test_star.py index d89b3bf..575b64b 100644 --- a/services/backend/tests/ui/views/associations/test_star.py +++ b/services/backend/tests/ui/views/associations/test_star.py @@ -54,6 +54,45 @@ def test_ok_htmx(authenticated_client: Client, assert result.context['association'].target.pk == association_out.target.pk +@pytest.mark.django_db +def test_archived(authenticated_client: Client, + archived_association_out, + ): + # When + result = authenticated_client.get( + reverse('ui.associations.star', args=(archived_association_out.pk,)), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_deleted(authenticated_client: Client, + deleted_association_out, + ): + # When + result = authenticated_client.get( + reverse('ui.associations.star', args=(deleted_association_out.pk,)), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_not_found(authenticated_client: Client, + null_uuid, + ): + # When + result = authenticated_client.get( + reverse('ui.associations.star', args=(null_uuid,)), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db def test_other_account_association(authenticated_client: Client, other_account_association_out, diff --git a/services/backend/tests/ui/views/associations/test_unstar.py b/services/backend/tests/ui/views/associations/test_unstar.py index 538b18e..ddc1270 100644 --- a/services/backend/tests/ui/views/associations/test_unstar.py +++ b/services/backend/tests/ui/views/associations/test_unstar.py @@ -54,6 +54,45 @@ def test_ok_htmx(authenticated_client: Client, assert result.context['association'].target.pk == starred_association_out.target.pk +@pytest.mark.django_db +def test_archived(authenticated_client: Client, + archived_association_out, + ): + # When + result = authenticated_client.get( + reverse('ui.associations.unstar', args=(archived_association_out.pk,)), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_deleted(authenticated_client: Client, + deleted_association_out, + ): + # When + result = authenticated_client.get( + reverse('ui.associations.unstar', args=(deleted_association_out.pk,)), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + +@pytest.mark.django_db +def test_not_found(authenticated_client: Client, + null_uuid, + ): + # When + result = authenticated_client.get( + reverse('ui.associations.unstar', args=(null_uuid,)), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db def test_other_account_association(authenticated_client: Client, other_account_starred_association_out, diff --git a/services/backend/tests/ui/views/associations/test_view.py b/services/backend/tests/ui/views/associations/test_view.py index 819dfcc..824a889 100644 --- a/services/backend/tests/ui/views/associations/test_view.py +++ b/services/backend/tests/ui/views/associations/test_view.py @@ -66,6 +66,19 @@ def test_authenticated_deleted(authenticated_client: Client, assert result.status_code == http.HTTPStatus.NOT_FOUND +@pytest.mark.django_db +def test_authenticated_deleted_save(authenticated_client: Client, + deleted_save_association_out, + ): + # When + result = authenticated_client.get( + reverse('ui.associations.view', args=(deleted_save_association_out.pk,)), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db def test_authenticated_not_found(authenticated_client: Client, null_uuid, @@ -169,6 +182,23 @@ def test_authenticated_share_deleted(authenticated_client: Client, assert result.status_code == http.HTTPStatus.NOT_FOUND +@pytest.mark.django_db +def test_authenticated_share_deleted_save(authenticated_client: Client, + other_account_deleted_save_association_out, + ): + # When + result = authenticated_client.get( + reverse( + 'ui.associations.view', + args=(other_account_deleted_save_association_out.pk,), + query=[('share', 'true')], + ), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db def test_authenticated_share_not_found(authenticated_client: Client, null_uuid, @@ -240,6 +270,23 @@ def test_anonymous_share_deleted(client: Client, assert result.status_code == http.HTTPStatus.NOT_FOUND +@pytest.mark.django_db +def test_anonymous_share_deleted_save(client: Client, + deleted_save_association_out, + ): + # When + result = client.get( + reverse( + 'ui.associations.view', + args=(deleted_save_association_out.pk,), + query=[('share', 'true')], + ), + ) + + # Then + assert result.status_code == http.HTTPStatus.NOT_FOUND + + @pytest.mark.django_db def test_anonymous_share_not_found(client: Client, null_uuid, diff --git a/services/backend/tests/ui/views/imports/test_pocket.py b/services/backend/tests/ui/views/imports/test_pocket.py index f704ede..38a5a0d 100644 --- a/services/backend/tests/ui/views/imports/test_pocket.py +++ b/services/backend/tests/ui/views/imports/test_pocket.py @@ -76,6 +76,7 @@ def test_ok(override_settings_upload_path, mock_ui_import_from_pocket_task_apply_async.assert_called_once_with( kwargs={ + 'job': mock.ANY, 'account_uuid': account.pk, 'csv_path': str(uploaded_file_path), }, diff --git a/services/backend/tests/ui/views/rpc/accounts/access_tokens/test_create.py b/services/backend/tests/ui/views/rpc/accounts/access_tokens/test_create.py index 793c9f3..e2a3371 100644 --- a/services/backend/tests/ui/views/rpc/accounts/access_tokens/test_create.py +++ b/services/backend/tests/ui/views/rpc/accounts/access_tokens/test_create.py @@ -77,10 +77,11 @@ def test_auth_key_not_found(null_uuid, call_result = result.json() assert 'error' in call_result - assert call_result['error']['data'].startswith( + assert call_result['error']['code'] == -32001 + assert call_result['error']['message'].startswith( 'Auth Key not found', ) - assert call_auth_key in call_result['error']['data'] + assert call_auth_key in call_result['error']['message'] @pytest.mark.django_db @@ -108,10 +109,11 @@ def test_deleted_auth_key(deleted_auth_key_out, call_result = result.json() assert 'error' in call_result - assert call_result['error']['data'].startswith( + assert call_result['error']['code'] == -32001 + assert call_result['error']['message'].startswith( 'Auth Key not found', ) - assert call_auth_key in call_result['error']['data'] + assert call_auth_key in call_result['error']['message'] @pytest.mark.django_db @@ -139,10 +141,11 @@ def test_expired_auth_key(expired_auth_key_out, call_result = result.json() assert 'error' in call_result - assert call_result['error']['data'].startswith( + assert call_result['error']['code'] == -32000 + assert call_result['error']['message'].startswith( 'Auth Key expired', ) - assert call_auth_key in call_result['error']['data'] + assert call_auth_key in call_result['error']['message'] @pytest.mark.django_db @@ -170,10 +173,11 @@ def test_consumed_auth_key(consumed_auth_key, call_result = result.json() assert 'error' in call_result - assert call_result['error']['data'].startswith( + assert call_result['error']['code'] == -32000 + assert call_result['error']['message'].startswith( 'Auth Key already consumed', ) - assert call_auth_key in call_result['error']['data'] + assert call_auth_key in call_result['error']['message'] @pytest.mark.django_db @@ -201,4 +205,5 @@ def test_inactive_account(inactive_account_auth_key, call_result = result.json() assert 'error' in call_result - assert str(inactive_account.pk) in call_result['error']['data'] + assert call_result['error']['code'] == -32001 + assert str(inactive_account.pk) in call_result['error']['message'] diff --git a/services/extension/Dockerfile b/services/extension/Dockerfile index 26f349f..b2037c6 100644 --- a/services/extension/Dockerfile +++ b/services/extension/Dockerfile @@ -8,7 +8,7 @@ ARG APP_USER_UID ARG APP_USER_GID ARG IMAGE_ID -COPY --chown=$APP_USER_UID:$APP_USER_GID extension/ops/bin/*.sh /srv/bin/ +# COPY --chown=$APP_USER_UID:$APP_USER_GID extension/ops/bin/*.sh /srv/bin/ VOLUME ["/srv/node_modules", "/srv/venv"] diff --git a/services/packages/Dockerfile b/services/packages/Dockerfile index 4e477c9..2557f8e 100644 --- a/services/packages/Dockerfile +++ b/services/packages/Dockerfile @@ -8,7 +8,7 @@ ARG APP_USER_UID ARG APP_USER_GID ARG IMAGE_ID -COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ops/bin/*.sh /srv/bin/ +# COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ops/bin/*.sh /srv/bin/ VOLUME ["/srv/node_modules", "/srv/venv"] diff --git a/services/packages/soa/hotpocket_soa/constants.py b/services/packages/soa/hotpocket_soa/constants.py new file mode 100644 index 0000000..c661a91 --- /dev/null +++ b/services/packages/soa/hotpocket_soa/constants.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import enum + + +class BackendServiceErrorCode(enum.Enum): + INTERNAL = -32000 + NOT_FOUND = -32001 + ACCESS_DENIED = -32002 + INVALID = -32003 diff --git a/services/packages/soa/hotpocket_soa/exceptions/__init__.py b/services/packages/soa/hotpocket_soa/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/packages/soa/hotpocket_soa/exceptions/backend.py b/services/packages/soa/hotpocket_soa/exceptions/backend.py new file mode 100644 index 0000000..c956e74 --- /dev/null +++ b/services/packages/soa/hotpocket_soa/exceptions/backend.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import typing + +from hotpocket_soa.constants import BackendServiceErrorCode + +if typing.TYPE_CHECKING: + from django.core.exceptions import ValidationError + + +VALIDATION_CODE_INVALID = 'invalid' + + +def get_validation_error_data(validation_error: typing.Any) -> typing.Any: # Heh + if hasattr(validation_error, 'error_dict') is True: + return { + field: [ + get_validation_error_data(inner_error) + for inner_error + in inner_errors + ] + for field, inner_errors in validation_error.error_dict.items() + } + elif hasattr(validation_error, 'error_list') is True and len(validation_error.error_list) > 1: + return [ + get_validation_error_data(inner_error) + for inner_error + in validation_error.error_list + ] + elif hasattr(validation_error, 'code') is True: + return validation_error.code + elif isinstance(validation_error, (tuple, list)) is True: + return [ + get_validation_error_data(inner_error) + for inner_error + in validation_error + ] + elif isinstance(validation_error, dict) is True: + return { + field: [ + get_validation_error_data(inner_error) + for inner_error + in inner_errors + ] + for field, inner_errors in validation_error.items() + } + else: + return VALIDATION_CODE_INVALID + + +class BackendServiceError(Exception): + CODE = BackendServiceErrorCode.INTERNAL + + def __init__(self, message: str, *args): + super().__init__(message, *args) + self.message = message + + self.data: typing.Any = None + if len(args) > 0: + self.data = args[0] + + +class InternalError(BackendServiceError): + pass + + +class NotFound(BackendServiceError): + CODE = BackendServiceErrorCode.NOT_FOUND + + +class AccessDenied(BackendServiceError): + CODE = BackendServiceErrorCode.ACCESS_DENIED + + +class Invalid(BackendServiceError): + CODE = BackendServiceErrorCode.INVALID + + @classmethod + def from_django_validation_error(cls: type[typing.Self], + exception: ValidationError, + message: str | None = None, + ) -> typing.Self: + data = get_validation_error_data(exception) + + result = cls(message or 'Invalid', data) + result.__cause__ = exception + + return result diff --git a/services/packages/soa/hotpocket_soa/exceptions/frontend.py b/services/packages/soa/hotpocket_soa/exceptions/frontend.py new file mode 100644 index 0000000..84d67aa --- /dev/null +++ b/services/packages/soa/hotpocket_soa/exceptions/frontend.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import typing + +from .backend import BackendServiceError + + +class SOAError(Exception): + def __init__(self, code: int, message: str, *args): + super().__init__(code, message, *args) + self.code = code + self.message = message + + self.data: typing.Any = None + if len(args) > 0: + self.data = args[0] + + @classmethod + def from_backend_error(cls: type[typing.Self], exception: BackendServiceError) -> typing.Self: + result = cls( + exception.CODE.value, + exception.message, + exception.data, + ) + result.__cause__ = exception + + return result diff --git a/services/packages/soa/hotpocket_soa/services/access_tokens.py b/services/packages/soa/hotpocket_soa/services/access_tokens.py index caa9bfc..d5f1405 100644 --- a/services/packages/soa/hotpocket_soa/services/access_tokens.py +++ b/services/packages/soa/hotpocket_soa/services/access_tokens.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import http import uuid from hotpocket_backend.apps.accounts.services import ( @@ -11,6 +12,7 @@ from hotpocket_soa.dto.accounts import ( AccessTokenOut, AccessTokensQuery, ) +from hotpocket_soa.exceptions.backend import NotFound from .base import ProxyService, SOAError @@ -19,22 +21,18 @@ class AccessTokensService(ProxyService): class AccessTokensServiceError(SOAError): pass - class AccessTokenNotFound(AccessTokensServiceError): + class NotFound(AccessTokensServiceError): pass - class AccessTokenAccessDenied(AccessTokensServiceError): + class AccessDenied(AccessTokensServiceError): pass def __init__(self): super().__init__() self.backend_access_tokens_service = BackendAccessTokensService() - def wrap_exception(self, exception: Exception) -> Exception: - new_exception_args = [] - if len(exception.args) > 0: - new_exception_args = [exception.args[0]] - - return self.AccessTokensServiceError(*new_exception_args) + def get_error_class(self) -> type[SOAError]: + return self.AccessTokensServiceError def create(self, *, @@ -69,16 +67,14 @@ class AccessTokensService(ProxyService): ) if result.account_uuid != account_uuid: - raise self.AccessTokenAccessDenied( + raise self.AccessDenied( + http.HTTPStatus.FORBIDDEN.value, f'account_uuid=`{account_uuid}` pk=`{pk}`', ) return result - except SOAError as exception: - if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True: - raise self.AccessTokenNotFound(*exception.args) from exception - else: - raise + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) def get_by_key(self, *, @@ -96,16 +92,14 @@ class AccessTokensService(ProxyService): ) if result.account_uuid != account_uuid: - raise self.AccessTokenAccessDenied( + raise self.AccessDenied( + http.HTTPStatus.FORBIDDEN.value, f'account_uuid=`{account_uuid}` key=`{key}`', ) return result - except SOAError as exception: - if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True: - raise self.AccessTokenNotFound(f'account_uuid=`{account_uuid}` pk=`{key}`') from exception - else: - raise + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) def search(self, *, @@ -124,23 +118,29 @@ class AccessTokensService(ProxyService): ] def delete(self, *, access_token: AccessTokenOut) -> bool: - return self.call( - self.backend_access_tokens_service, - 'delete', - pk=access_token.pk, - ) + try: + return self.call( + self.backend_access_tokens_service, + 'delete', + pk=access_token.pk, + ) + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) def update_meta(self, *, access_token: AccessTokenOut, update: AccessTokenMetaUpdateIn, ) -> AccessTokenOut: - return AccessTokenOut.model_validate( - self.call( - self.backend_access_tokens_service, - 'update_meta', - pk=access_token.pk, - update=update, - ), - from_attributes=True, - ) + try: + return AccessTokenOut.model_validate( + self.call( + self.backend_access_tokens_service, + 'update_meta', + pk=access_token.pk, + update=update, + ), + from_attributes=True, + ) + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) diff --git a/services/packages/soa/hotpocket_soa/services/accounts.py b/services/packages/soa/hotpocket_soa/services/accounts.py index af2fdc4..1a10da4 100644 --- a/services/packages/soa/hotpocket_soa/services/accounts.py +++ b/services/packages/soa/hotpocket_soa/services/accounts.py @@ -7,6 +7,7 @@ from hotpocket_backend.apps.accounts.services import ( AccountsService as BackendAccountsService, ) from hotpocket_soa.dto.accounts import AccountOut +from hotpocket_soa.exceptions.backend import NotFound from .base import ProxyService, SOAError @@ -15,19 +16,15 @@ class AccountsService(ProxyService): class AccountsServiceError(SOAError): pass - class AccountNotFound(AccountsServiceError): + class NotFound(AccountsServiceError): pass def __init__(self): super().__init__() self.backend_accounts_service = BackendAccountsService() - def wrap_exception(self, exception: Exception) -> Exception: - new_exception_args = [] - if len(exception.args) > 0: - new_exception_args = [exception.args[0]] - - return self.AccountsServiceError(*new_exception_args) + def get_error_class(self) -> type[SOAError]: + return self.AccountsServiceError def get(self, *, pk: uuid.UUID) -> AccountOut: try: @@ -41,8 +38,5 @@ class AccountsService(ProxyService): ) return result - except SOAError as exception: - if isinstance(exception.__cause__, BackendAccountsService.AccountNotFound) is True: - raise self.AccountNotFound(*exception.args) from exception - else: - raise + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) diff --git a/services/packages/soa/hotpocket_soa/services/associations.py b/services/packages/soa/hotpocket_soa/services/associations.py index f6fc651..17a19c4 100644 --- a/services/packages/soa/hotpocket_soa/services/associations.py +++ b/services/packages/soa/hotpocket_soa/services/associations.py @@ -2,6 +2,7 @@ from __future__ import annotations import datetime +import http import uuid from hotpocket_backend.apps.saves.services import ( @@ -14,6 +15,7 @@ from hotpocket_soa.dto.associations import ( AssociationWithTargetOut, ) from hotpocket_soa.dto.saves import SaveOut +from hotpocket_soa.exceptions.backend import NotFound from .base import ProxyService, SOAError @@ -22,22 +24,18 @@ class AssociationsService(ProxyService): class AssociationsServiceError(SOAError): pass - class AssociationNotFound(AssociationsServiceError): + class NotFound(AssociationsServiceError): pass - class AssociationAccessDenied(AssociationsServiceError): + class AccessDenied(AssociationsServiceError): pass def __init__(self): super().__init__() self.backend_associations_service = BackendAssociationsService() - def wrap_exception(self, exception: Exception) -> Exception: - new_exception_args = [] - if len(exception.args) > 0: - new_exception_args = [exception.args[0]] - - return self.AssociationsServiceError(*new_exception_args) + def get_error_class(self) -> type[SOAError]: + return self.AssociationsServiceError def create(self, *, @@ -81,19 +79,19 @@ class AssociationsService(ProxyService): ) if allow_archived is False and result.archived_at is not None: - raise self.AssociationNotFound(f'pk=`{pk}`') + raise self.NotFound( + http.HTTPStatus.NOT_FOUND.value, f'pk=`{pk}`', + ) if account_uuid is not None and result.account_uuid != account_uuid: - raise self.AssociationAccessDenied( + raise self.AccessDenied( + http.HTTPStatus.FORBIDDEN.value, f'account_uuid=`{account_uuid}` pk=`{pk}`', ) return result - except SOAError as exception: - if isinstance(exception.__cause__, BackendAssociationsService.AssociationNotFound) is True: - raise self.AssociationNotFound(*exception.args) from exception - else: - raise + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) def search(self, *, @@ -112,50 +110,65 @@ class AssociationsService(ProxyService): ] def archive(self, *, association: AssociationOut) -> bool: - return self.call( - self.backend_associations_service, - 'archive', - pk=association.pk, - ) + try: + return self.call( + self.backend_associations_service, + 'archive', + pk=association.pk, + ) + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) def star(self, *, association: AssociationOut) -> AssociationOut: - return AssociationOut.model_validate( - self.call( - self.backend_associations_service, - 'star', - pk=association.pk, - ), - from_attributes=True, - ) + try: + return AssociationOut.model_validate( + self.call( + self.backend_associations_service, + 'star', + pk=association.pk, + ), + from_attributes=True, + ) + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) def unstar(self, *, association: AssociationOut) -> AssociationOut: - return AssociationOut.model_validate( - self.call( - self.backend_associations_service, - 'unstar', - pk=association.pk, - ), - from_attributes=True, - ) + try: + return AssociationOut.model_validate( + self.call( + self.backend_associations_service, + 'unstar', + pk=association.pk, + ), + from_attributes=True, + ) + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) def update(self, *, association: AssociationOut, update: AssociationUpdateIn, ) -> AssociationOut: - return AssociationOut.model_validate( - self.call( - self.backend_associations_service, - 'update', - pk=association.pk, - update=update, - ), - from_attributes=True, - ) + try: + return AssociationOut.model_validate( + self.call( + self.backend_associations_service, + 'update', + pk=association.pk, + update=update, + ), + from_attributes=True, + ) + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) def delete(self, *, association: AssociationOut) -> bool: - return self.call( - self.backend_associations_service, - 'delete', - pk=association.pk, - ) + try: + return self.call( + self.backend_associations_service, + 'delete', + pk=association.pk, + ) + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) diff --git a/services/packages/soa/hotpocket_soa/services/auth_keys.py b/services/packages/soa/hotpocket_soa/services/auth_keys.py index d214416..093c299 100644 --- a/services/packages/soa/hotpocket_soa/services/auth_keys.py +++ b/services/packages/soa/hotpocket_soa/services/auth_keys.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import http import uuid from hotpocket_backend.apps.accounts.services import ( AuthKeysService as BackendAuthKeysService, ) from hotpocket_soa.dto.accounts import AuthKeyOut +from hotpocket_soa.exceptions.backend import NotFound from .base import ProxyService, SOAError @@ -15,23 +17,16 @@ class AuthKeysService(ProxyService): class AuthKeysServiceError(SOAError): pass - class AuthKeyNotFound(AuthKeysServiceError): + class NotFound(AuthKeysServiceError): pass - class AuthKeyAccessDenied(AuthKeysServiceError): + class AccessDenied(AuthKeysServiceError): pass def __init__(self): super().__init__() self.backend_auth_keys_service = BackendAuthKeysService() - def wrap_exception(self, exception: Exception) -> Exception: - new_exception_args = [] - if len(exception.args) > 0: - new_exception_args = [exception.args[0]] - - return self.AuthKeysServiceError(*new_exception_args) - def _check_auth_key_access(self, auth_key: AuthKeyOut, account_uuid: uuid.UUID | None, @@ -70,16 +65,14 @@ class AuthKeysService(ProxyService): ) if self._check_auth_key_access(result, account_uuid) is False: - raise self.AuthKeyAccessDenied( + raise self.AccessDenied( + http.HTTPStatus.FORBIDDEN.value, f'account_uuid=`{account_uuid}` pk=`{pk}`', ) return result - except SOAError as exception: - if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True: - raise self.AuthKeyNotFound(*exception.args) from exception - else: - raise + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) def get_by_key(self, *, @@ -97,13 +90,11 @@ class AuthKeysService(ProxyService): ) if self._check_auth_key_access(result, account_uuid) is False: - raise self.AuthKeyAccessDenied( + raise self.AccessDenied( + http.HTTPStatus.FORBIDDEN.value, f'account_uuid=`{account_uuid}` key=`{key}`', ) return result - except SOAError as exception: - if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True: - raise self.AuthKeyNotFound(*exception.args) from exception - else: - raise + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) diff --git a/services/packages/soa/hotpocket_soa/services/base.py b/services/packages/soa/hotpocket_soa/services/base.py index 789ba16..efd6104 100644 --- a/services/packages/soa/hotpocket_soa/services/base.py +++ b/services/packages/soa/hotpocket_soa/services/base.py @@ -1,16 +1,66 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import functools +import http +import types import typing - -class SOAError(Exception): - pass +from hotpocket_soa.exceptions.backend import BackendServiceError +from hotpocket_soa.exceptions.frontend import SOAError class Service: - def wrap_exception(self, exception: Exception) -> Exception: - return SOAError(exception.args[0]) + def __getattribute__(self, name: str) -> typing.Any: + result = super().__getattribute__(name) + + is_service_method = all(( + name.startswith('_') is False, + hasattr(Service, name) is False, + isinstance(result, types.MethodType), + getattr(result, '__self__', None) is self, + )) + if is_service_method is True: + @functools.wraps(result) + def wrapped_result(*args, **kwargs): + try: + return result(*args, **kwargs) + except Exception as exception: + raise self.wrap_exception(exception) from exception + + return wrapped_result + + return result + + def get_error_class(self) -> type[SOAError]: + return SOAError + + def wrap_exception(self, exception: Exception) -> SOAError: + error_class = self.get_error_class() + + if isinstance(exception, error_class) is True: + return typing.cast(SOAError, exception) + + result = error_class( + http.HTTPStatus.IM_A_TEAPOT.value, + 'SOA Error', + *exception.args, + ) + + if isinstance(exception, BackendServiceError) is True: + baskend_error = typing.cast(BackendServiceError, exception) + result = error_class( + baskend_error.CODE.value, + baskend_error.message, + baskend_error.data, + *baskend_error.args, + ) + + result.__cause__ = exception + return result + + def call(self, *args, **kwargs) -> typing.Any: + raise NotImplementedError('TODO') class ProxyService(Service): @@ -18,7 +68,4 @@ class ProxyService(Service): handler = getattr(service, method, None) assert handler is not None, f'Unknown method: method=`{method}`' - try: - return handler(*args, **kwargs) - except Exception as exception: - raise self.wrap_exception(exception) from exception + return handler(*args, **kwargs) diff --git a/services/packages/soa/hotpocket_soa/services/bot.py b/services/packages/soa/hotpocket_soa/services/bot.py index 0451930..d286080 100644 --- a/services/packages/soa/hotpocket_soa/services/bot.py +++ b/services/packages/soa/hotpocket_soa/services/bot.py @@ -15,12 +15,8 @@ class BotService(ProxyService): super().__init__() self.backend_associations_service = BackendBotService() - def wrap_exception(self, exception: Exception) -> Exception: - new_exception_args = [] - if len(exception.args) > 0: - new_exception_args = [exception.args[0]] - - return self.BotServiceError(*new_exception_args) + def get_error_class(self) -> type[SOAError]: + return self.BotServiceError def is_netloc_banned(self, *, url: str) -> bool: return self.call( diff --git a/services/packages/soa/hotpocket_soa/services/save_processor.py b/services/packages/soa/hotpocket_soa/services/save_processor.py index 5a7f1dd..63b8917 100644 --- a/services/packages/soa/hotpocket_soa/services/save_processor.py +++ b/services/packages/soa/hotpocket_soa/services/save_processor.py @@ -18,17 +18,13 @@ class SaveProcessorService(ProxyService): class SaveProcessorServiceError(SOAError): pass - def wrap_exception(self, exception: Exception) -> Exception: - new_exception_args = [] - if len(exception.args) > 0: - new_exception_args = [exception.args[0]] - - return self.SaveProcessorServiceError(*new_exception_args) - def __init__(self): super().__init__() self.backend_save_processor_service = BackendSaveProcessorService() + def get_error_class(self) -> type[SOAError]: + return self.SaveProcessorServiceError + def schedule_process_save(self, *, save: SaveOut) -> AsyncResultOut: result = AsyncResultOut.model_validate( self.call( diff --git a/services/packages/soa/hotpocket_soa/services/saves.py b/services/packages/soa/hotpocket_soa/services/saves.py index dde698d..0ca3aad 100644 --- a/services/packages/soa/hotpocket_soa/services/saves.py +++ b/services/packages/soa/hotpocket_soa/services/saves.py @@ -7,6 +7,7 @@ from hotpocket_backend.apps.saves.services import ( SavesService as BackendSavesService, ) from hotpocket_soa.dto.saves import SaveIn, SaveOut +from hotpocket_soa.exceptions.backend import Invalid, NotFound from .base import ProxyService, SOAError @@ -15,30 +16,34 @@ class SavesService(ProxyService): class SavesServiceError(SOAError): pass - class SaveNotFound(SavesServiceError): + class NotFound(SavesServiceError): pass - def wrap_exception(self, exception: Exception) -> Exception: - new_exception_args = [] - if len(exception.args) > 0: - new_exception_args = [exception.args[0]] - - return self.SavesServiceError(*new_exception_args) + class Invalid(SavesServiceError): + pass def __init__(self): super().__init__() self.backend_saves_service = BackendSavesService() + def get_error_class(self) -> type[SOAError]: + return self.SavesServiceError + def create(self, *, account_uuid: uuid.UUID, save: SaveIn) -> SaveOut: - return SaveOut.model_validate( - self.call( - self.backend_saves_service, - 'create', - account_uuid=account_uuid, - save=save, - ), - from_attributes=True, - ) + try: + return SaveOut.model_validate( + self.call( + self.backend_saves_service, + 'create', + account_uuid=account_uuid, + save=save, + ), + from_attributes=True, + ) + except Invalid as exception: + raise self.Invalid( + exception.CODE.value, exception.message, exception.data, + ) def get(self, *, pk: uuid.UUID) -> SaveOut: try: @@ -52,8 +57,5 @@ class SavesService(ProxyService): ) return result - except SOAError as exception: - if isinstance(exception.__cause__, BackendSavesService.SaveNotFound) is True: - raise self.SaveNotFound(*exception.args) from exception - else: - raise + except NotFound as exception: + raise self.NotFound.from_backend_error(exception) diff --git a/skel/hotpocket.work.bthlabs.net b/skel/hotpocket.work.bthlabs.net index fc99404..e5c5657 100644 --- a/skel/hotpocket.work.bthlabs.net +++ b/skel/hotpocket.work.bthlabs.net @@ -24,8 +24,8 @@ server { listen *:443 ssl; server_name app.hotpocket.work.bthlabs.net; - ssl_certificate /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt; - ssl_certificate_key /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key; + ssl_certificate /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt; + ssl_certificate_key /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key; location / { # proxy_cache_bypass $http_upgrade; @@ -71,8 +71,8 @@ server { listen *:443 ssl; server_name admin.hotpocket.work.bthlabs.net; - ssl_certificate /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt; - ssl_certificate_key /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key; + ssl_certificate /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt; + ssl_certificate_key /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key; location / { # proxy_cache_bypass $http_upgrade;