BTHLABS-61: Service layer refactoring

A journey to fix `ValidationError` in Pocket imports turned service
layer refactoring :D
This commit is contained in:
Tomek Wójcik 2025-10-12 18:37:32 +00:00
parent ac7a8dd90e
commit 8b86145519
45 changed files with 1023 additions and 337 deletions

View File

@ -11,14 +11,64 @@ on:
- "public" - "public"
jobs: 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: run-checks:
name: "Checks" name: "Checks"
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"
needs:
- "setup"
steps: steps:
- name: "Checkout the code" - name: "Checkout the code"
uses: "actions/checkout@v2" uses: "actions/checkout@v2"
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
id: "setup-docker-buildx"
uses: "docker/setup-buildx-action@v3" 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" - name: "Build `postgres` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -27,6 +77,7 @@ jobs:
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-local"
platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}"
- name: "Build `keycloak` image" - name: "Build `keycloak` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -35,6 +86,7 @@ jobs:
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-local"
platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}"
- name: "Build `rabbitmq` image" - name: "Build `rabbitmq` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -43,6 +95,7 @@ jobs:
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-local"
platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}"
- name: "Build `backend-ci` image" - name: "Build `backend-ci` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -52,6 +105,7 @@ jobs:
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-local"
platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}"
- name: "Build `packages-ci` image" - name: "Build `packages-ci` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -61,6 +115,7 @@ jobs:
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-local"
platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}"
- name: "Build `extension-ci` image" - name: "Build `extension-ci` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -70,6 +125,7 @@ jobs:
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/extension:ci-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/extension:ci-local"
platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}"
- name: "Build `apple-ci` image" - name: "Build `apple-ci` image"
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@ -79,6 +135,7 @@ jobs:
push: false push: false
load: true load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-local" tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-local"
platforms: "${{ needs.setup.outputs.BUILD_PLATFORM }}"
- name: "Run `backend` checks" - name: "Run `backend` checks"
run: | run: |
set -x set -x

View File

@ -8,7 +8,7 @@ ARG APP_USER_UID
ARG APP_USER_GID ARG APP_USER_GID
ARG IMAGE_ID 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"] VOLUME ["/srv/node_modules", "/srv/venv"]

View File

@ -6,6 +6,7 @@ import hmac
import logging import logging
import uuid import uuid
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
import uuid6 import uuid6
@ -15,6 +16,10 @@ from hotpocket_soa.dto.accounts import (
AccessTokenMetaUpdateIn, AccessTokenMetaUpdateIn,
AccessTokensQuery, AccessTokensQuery,
) )
from hotpocket_soa.exceptions.backend import (
Invalid as InvalidError,
NotFound as NotFoundError,
)
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -23,7 +28,10 @@ class AccessTokensService:
class AccessTokensServiceError(Exception): class AccessTokensServiceError(Exception):
pass pass
class AccessTokenNotFound(AccessTokensServiceError): class Invalid(InvalidError, AccessTokensServiceError):
pass
class NotFound(NotFoundError, AccessTokensServiceError):
pass pass
def create(self, def create(self,
@ -32,6 +40,7 @@ class AccessTokensService:
origin: str, origin: str,
meta: dict, meta: dict,
) -> AccessToken: ) -> AccessToken:
try:
pk = uuid6.uuid7() pk = uuid6.uuid7()
key = hmac.new( key = hmac.new(
settings.SECRET_KEY.encode('ascii'), settings.SECRET_KEY.encode('ascii'),
@ -46,6 +55,8 @@ class AccessTokensService:
origin=origin, origin=origin,
meta=meta, meta=meta,
) )
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self, *, pk: uuid.UUID) -> AccessToken: def get(self, *, pk: uuid.UUID) -> AccessToken:
try: try:
@ -53,7 +64,7 @@ class AccessTokensService:
return query_set.get(pk=pk) return query_set.get(pk=pk)
except AccessToken.DoesNotExist as exception: except AccessToken.DoesNotExist as exception:
raise self.AccessTokenNotFound( raise self.NotFound(
f'Access Token not found: pk=`{pk}`', f'Access Token not found: pk=`{pk}`',
) from exception ) from exception
@ -63,7 +74,7 @@ class AccessTokensService:
return query_set.get(key=key) return query_set.get(key=key)
except AccessToken.DoesNotExist as exception: except AccessToken.DoesNotExist as exception:
raise self.AccessTokenNotFound( raise self.NotFound(
f'Access Token not found: key=`{key}`', f'Access Token not found: key=`{key}`',
) from exception ) from exception
@ -98,7 +109,7 @@ class AccessTokensService:
pk: uuid.UUID, pk: uuid.UUID,
update: AccessTokenMetaUpdateIn, update: AccessTokenMetaUpdateIn,
) -> AccessToken: ) -> AccessToken:
access_token = AccessToken.active_objects.get(pk=pk) access_token = self.get(pk=pk)
next_meta = { next_meta = {
**(access_token.meta or {}), **(access_token.meta or {}),

View File

@ -5,6 +5,7 @@ import logging
import uuid import uuid
from hotpocket_backend.apps.accounts.models import Account from hotpocket_backend.apps.accounts.models import Account
from hotpocket_soa.exceptions.backend import NotFound as NotFoundError
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -13,7 +14,7 @@ class AccountsService:
class AccountsServiceError(Exception): class AccountsServiceError(Exception):
pass pass
class AccountNotFound(AccountsServiceError): class NotFound(NotFoundError, AccountsServiceError):
pass pass
def get(self, *, pk: uuid.UUID) -> Account: def get(self, *, pk: uuid.UUID) -> Account:
@ -22,6 +23,6 @@ class AccountsService:
return query_set.get(pk=pk) return query_set.get(pk=pk)
except Account.DoesNotExist as exception: except Account.DoesNotExist as exception:
raise self.AccountNotFound( raise self.NotFound(
f'Account not found: pk=`{pk}`', f'Account not found: pk=`{pk}`',
) from exception ) from exception

View File

@ -5,11 +5,17 @@ import datetime
import logging import logging
import uuid import uuid
from django.core.exceptions import ValidationError
from django.utils.timezone import now from django.utils.timezone import now
import uuid6 import uuid6
from hotpocket_backend.apps.accounts.models import AuthKey from hotpocket_backend.apps.accounts.models import AuthKey
from hotpocket_backend.apps.core.conf import settings 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__) LOGGER = logging.getLogger(__name__)
@ -18,22 +24,25 @@ class AuthKeysService:
class AuthKeysServiceError(Exception): class AuthKeysServiceError(Exception):
pass pass
class AuthKeyNotFound(AuthKeysServiceError): class Invalid(InvalidError, AuthKeysServiceError):
pass pass
class AuthKeyExpired(AuthKeysServiceError): class NotFound(NotFoundError, AuthKeysServiceError):
pass pass
class AuthKeyAccessDenied(AuthKeysServiceError): class Expired(InternalError, AuthKeysServiceError):
pass pass
def create(self, *, account_uuid: uuid.UUID) -> AuthKey: def create(self, *, account_uuid: uuid.UUID) -> AuthKey:
try:
key = str(uuid6.uuid7()) key = str(uuid6.uuid7())
return AuthKey.objects.create( return AuthKey.objects.create(
account_uuid=account_uuid, account_uuid=account_uuid,
key=key, key=key,
) )
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self, *, pk: uuid.UUID) -> AuthKey: def get(self, *, pk: uuid.UUID) -> AuthKey:
try: try:
@ -41,7 +50,7 @@ class AuthKeysService:
return query_set.get(pk=pk) return query_set.get(pk=pk)
except AuthKey.DoesNotExist as exception: except AuthKey.DoesNotExist as exception:
raise self.AuthKeyNotFound( raise self.NotFound(
f'Auth Key not found: pk=`{pk}`', f'Auth Key not found: pk=`{pk}`',
) from exception ) from exception
@ -56,17 +65,17 @@ class AuthKeysService:
if ttl > 0: if ttl > 0:
if result.created_at < now() - datetime.timedelta(seconds=ttl): if result.created_at < now() - datetime.timedelta(seconds=ttl):
raise self.AuthKeyExpired( raise self.Expired(
f'Auth Key expired: pk=`{key}`', f'Auth Key expired: pk=`{key}`',
) )
if result.consumed_at is not None: if result.consumed_at is not None:
raise self.AuthKeyExpired( raise self.Expired(
f'Auth Key already consumed: pk=`{key}`', f'Auth Key already consumed: pk=`{key}`',
) )
return result return result
except AuthKey.DoesNotExist as exception: except AuthKey.DoesNotExist as exception:
raise self.AuthKeyNotFound( raise self.NotFound(
f'Auth Key not found: key=`{key}`', f'Auth Key not found: key=`{key}`',
) from exception ) from exception

View File

@ -1,16 +1,39 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import functools
import typing import typing
from bthlabs_jsonrpc_core.exceptions import BaseJSONRPCError
from bthlabs_jsonrpc_django import ( from bthlabs_jsonrpc_django import (
DjangoExecutor, DjangoExecutor,
DjangoJSONRPCSerializer, DjangoJSONRPCSerializer,
JSONRPCView as BaseJSONRPCView, JSONRPCView as BaseJSONRPCView,
) )
from django.core.exceptions import ValidationError
import uuid6 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): class JSONRPCSerializer(DjangoJSONRPCSerializer):
STRING_COERCIBLE_TYPES: typing.Any = ( STRING_COERCIBLE_TYPES: typing.Any = (
@ -18,30 +41,6 @@ class JSONRPCSerializer(DjangoJSONRPCSerializer):
uuid6.UUID, 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): class Executor(DjangoExecutor):
serializer = JSONRPCSerializer serializer = JSONRPCSerializer
@ -49,3 +48,14 @@ class Executor(DjangoExecutor):
class JSONRPCView(BaseJSONRPCView): class JSONRPCView(BaseJSONRPCView):
executor = Executor 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

View File

@ -5,6 +5,7 @@ import datetime
import logging import logging
import uuid import uuid
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.timezone import now from django.utils.timezone import now
@ -15,6 +16,10 @@ from hotpocket_soa.dto.associations import (
AssociationsQuery, AssociationsQuery,
AssociationUpdateIn, AssociationUpdateIn,
) )
from hotpocket_soa.exceptions.backend import (
Invalid as InvalidError,
NotFound as NotFoundError,
)
from .saves import SavesService from .saves import SavesService
@ -25,7 +30,10 @@ class AssociationsService:
class AssociationsServiceError(Exception): class AssociationsServiceError(Exception):
pass pass
class AssociationNotFound(AssociationsServiceError): class Invalid(InvalidError, AssociationsServiceError):
pass
class NotFound(NotFoundError, AssociationsServiceError):
pass pass
@property @property
@ -46,6 +54,7 @@ class AssociationsService:
pk: uuid.UUID | None = None, pk: uuid.UUID | None = None,
created_at: datetime.datetime | None = None, created_at: datetime.datetime | None = None,
) -> Association: ) -> Association:
try:
save = SavesService().get(pk=save_uuid) save = SavesService().get(pk=save_uuid)
defaults = dict( defaults = dict(
@ -70,6 +79,8 @@ class AssociationsService:
result.save() result.save()
return result return result
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self, def get(self,
*, *,
@ -87,7 +98,7 @@ class AssociationsService:
return query_set.get(pk=pk) return query_set.get(pk=pk)
except Association.DoesNotExist as exception: except Association.DoesNotExist as exception:
raise self.AssociationNotFound( raise self.NotFound(
f'Association not found: pk=`{pk}`', f'Association not found: pk=`{pk}`',
) from exception ) from exception
@ -112,6 +123,7 @@ class AssociationsService:
pk: uuid.UUID, pk: uuid.UUID,
update: AssociationUpdateIn, update: AssociationUpdateIn,
) -> Association: ) -> Association:
try:
association = self.get(pk=pk) association = self.get(pk=pk)
association.target_title = update.target_title association.target_title = update.target_title
association.target_description = update.target_description association.target_description = update.target_description
@ -127,6 +139,8 @@ class AssociationsService:
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: def archive(self, *, pk: uuid.UUID) -> bool:
association = self.get(pk=pk) association = self.get(pk=pk)

View File

@ -5,19 +5,27 @@ import hashlib
import typing import typing
import uuid import uuid
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from hotpocket_backend.apps.core.services import get_adapter from hotpocket_backend.apps.core.services import get_adapter
from hotpocket_backend.apps.saves.models import Save from hotpocket_backend.apps.saves.models import Save
from hotpocket_backend.apps.saves.types import PSaveAdapter from hotpocket_backend.apps.saves.types import PSaveAdapter
from hotpocket_soa.dto.saves import ImportedSaveIn, SaveIn, SavesQuery from hotpocket_soa.dto.saves import ImportedSaveIn, SaveIn, SavesQuery
from hotpocket_soa.exceptions.backend import (
Invalid as InvalidError,
NotFound as NotFoundError,
)
class SavesService: class SavesService:
class SavesServiceError(Exception): class SavesServiceError(Exception):
pass pass
class SaveNotFound(SavesServiceError): class Invalid(InvalidError, SavesServiceError):
pass
class NotFound(NotFoundError, SavesServiceError):
pass pass
@property @property
@ -36,6 +44,7 @@ class SavesService:
account_uuid: uuid.UUID, account_uuid: uuid.UUID,
save: SaveIn | ImportedSaveIn, save: SaveIn | ImportedSaveIn,
) -> Save: ) -> Save:
try:
key = hashlib.sha256(save.url.encode('utf-8')).hexdigest() key = hashlib.sha256(save.url.encode('utf-8')).hexdigest()
defaults = dict( defaults = dict(
@ -59,12 +68,14 @@ class SavesService:
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: def get(self, *, pk: uuid.UUID) -> Save:
try: try:
return Save.active_objects.get(pk=pk) return Save.active_objects.get(pk=pk)
except Save.DoesNotExist as exception: except Save.DoesNotExist as exception:
raise self.SaveNotFound( raise self.NotFound(
f'Save not found: pk=`{pk}`', f'Save not found: pk=`{pk}`',
) from exception ) from exception

View File

@ -7,6 +7,7 @@ from bthlabs_jsonrpc_core import register_method
from django import db from django import db
from django.http import HttpRequest from django.http import HttpRequest
from hotpocket_backend.apps.core.rpc import wrap_soa_errors
from hotpocket_soa.services import ( from hotpocket_soa.services import (
AccessTokensService, AccessTokensService,
AccountsService, AccountsService,
@ -17,6 +18,7 @@ LOGGER = logging.getLogger(__name__)
@register_method('accounts.access_tokens.create', namespace='accounts') @register_method('accounts.access_tokens.create', namespace='accounts')
@wrap_soa_errors
def create(request: HttpRequest, def create(request: HttpRequest,
auth_key: str, auth_key: str,
meta: dict, meta: dict,
@ -27,7 +29,7 @@ def create(request: HttpRequest,
account_uuid=None, account_uuid=None,
key=auth_key, key=auth_key,
) )
except AuthKeysService.AuthKeyNotFound as exception: except AuthKeysService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Unable to issue access token: %s', 'Unable to issue access token: %s',
exception, exception,
@ -37,7 +39,7 @@ def create(request: HttpRequest,
try: try:
account = AccountsService().get(pk=auth_key_object.account_uuid) account = AccountsService().get(pk=auth_key_object.account_uuid)
except AccountsService.AccountNotFound as exception: except AccountsService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Unable to issue access token: %s', 'Unable to issue access token: %s',
exception, exception,

View File

@ -44,7 +44,7 @@ def check_access_token(request: HttpRequest,
access_token=access_token_object, access_token=access_token_object,
update=meta_update, update=meta_update,
) )
except AccessTokensService.AccessTokenNotFound as exception: except AccessTokensService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Access Token not found: account_uuid=`%s` key=`%s`', 'Access Token not found: account_uuid=`%s` key=`%s`',
request.user.pk, request.user.pk,
@ -52,7 +52,7 @@ def check_access_token(request: HttpRequest,
exc_info=exception, exc_info=exception,
) )
result = False result = False
except AccessTokensService.AccessTokenAccessDenied as exception: except AccessTokensService.AccessDenied as exception:
LOGGER.error( LOGGER.error(
'Access Token access denied: account_uuid=`%s` key=`%s`', 'Access Token access denied: account_uuid=`%s` key=`%s`',
request.user.pk, request.user.pk,

View File

@ -4,11 +4,13 @@ from __future__ import annotations
from bthlabs_jsonrpc_core import register_method from bthlabs_jsonrpc_core import register_method
from django.http import HttpRequest 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_backend.apps.ui.services.workflows import CreateSaveWorkflow
from hotpocket_soa.dto.associations import AssociationOut from hotpocket_soa.dto.associations import AssociationOut
@register_method(method='saves.create') @register_method(method='saves.create')
@wrap_soa_errors
def create(request: HttpRequest, url: str) -> AssociationOut: def create(request: HttpRequest, url: str) -> AssociationOut:
association = CreateSaveWorkflow().run_rpc( association = CreateSaveWorkflow().run_rpc(
request=request, request=request,

View File

@ -27,7 +27,7 @@ class UIAccessTokensService:
account_uuid=account_uuid, account_uuid=account_uuid,
pk=pk, pk=pk,
) )
except AccessTokensService.AccessTokenNotFound as exception: except AccessTokensService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Access Token not found: account_uuid=`%s` pk=`%s`', 'Access Token not found: account_uuid=`%s` pk=`%s`',
account_uuid, account_uuid,
@ -35,7 +35,7 @@ class UIAccessTokensService:
exc_info=exception, exc_info=exception,
) )
raise Http404() raise Http404()
except AccessTokensService.AccessTokenAccessDenied as exception: except AccessTokensService.AccessDenied as exception:
LOGGER.error( LOGGER.error(
'Access Token access denied: account_uuid=`%s` pk=`%s`', 'Access Token access denied: account_uuid=`%s` pk=`%s`',
account_uuid, account_uuid,

View File

@ -34,7 +34,7 @@ class UIAssociationsService:
with_target=True, with_target=True,
allow_archived=allow_archived, allow_archived=allow_archived,
) )
except AssociationsService.AssociationNotFound as exception: except AssociationsService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Association not found: account_uuid=`%s` pk=`%s`', 'Association not found: account_uuid=`%s` pk=`%s`',
account_uuid, account_uuid,
@ -42,7 +42,7 @@ class UIAssociationsService:
exc_info=exception, exc_info=exception,
) )
raise Http404() raise Http404()
except AssociationsService.AssociationAccessDenied as exception: except AssociationsService.AccessDenied as exception:
LOGGER.error( LOGGER.error(
'Association access denied: account_uuid=`%s` pk=`%s`', 'Association access denied: account_uuid=`%s` pk=`%s`',
account_uuid, account_uuid,

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import csv import csv
import datetime import datetime
import logging
import os import os
import uuid 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.services.workflows import ImportSaveWorkflow
from hotpocket_backend.apps.ui.tasks import import_from_pocket from hotpocket_backend.apps.ui.tasks import import_from_pocket
from hotpocket_common.uuid import uuid7_from_timestamp from hotpocket_common.uuid import uuid7_from_timestamp
from hotpocket_soa.services import SavesService
LOGGER = logging.getLogger(__name__)
class UIImportsService: class UIImportsService:
def import_from_pocket(self, def import_from_pocket(self,
*, *,
job: str,
account_uuid: uuid.UUID, account_uuid: uuid.UUID,
csv_path: str, csv_path: str,
) -> list[tuple[uuid.UUID, uuid.UUID]]: ) -> 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(): with db.transaction.atomic():
try: try:
with open(csv_path, 'r', encoding='utf-8') as csv_file: with open(csv_path, 'r', encoding='utf-8') as csv_file:
@ -34,13 +47,14 @@ class UIImportsService:
current_timezone = get_current_timezone() current_timezone = get_current_timezone()
is_header = False is_header = False
for row in csv_reader: for row_number, row in enumerate(csv_reader, start=1):
if is_header is False: if is_header is False:
is_header = True is_header = True
continue continue
timestamp = int(row['time_added']) timestamp = int(row['time_added'])
try:
save, association = ImportSaveWorkflow().run( save, association = ImportSaveWorkflow().run(
account_uuid=account_uuid, account_uuid=account_uuid,
url=row['url'], url=row['url'],
@ -50,6 +64,18 @@ class UIImportsService:
timestamp, tz=current_timezone, 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)) result.append((save.pk, association.pk))
finally: finally:
@ -64,6 +90,7 @@ class UIImportsService:
) -> AsyncResult: ) -> AsyncResult:
return import_from_pocket.apply_async( return import_from_pocket.apply_async(
kwargs={ kwargs={
'job': str(uuid.uuid4()),
'account_uuid': account_uuid, 'account_uuid': account_uuid,
'csv_path': csv_path, 'csv_path': csv_path,
}, },

View File

@ -19,7 +19,7 @@ class UISavesService:
def get_or_404(self, *, pk: uuid.UUID) -> SaveOut: def get_or_404(self, *, pk: uuid.UUID) -> SaveOut:
try: try:
return SavesService().get(pk=pk) return SavesService().get(pk=pk)
except SavesService.SaveNotFound as exception: except SavesService.NotFound as exception:
LOGGER.error( LOGGER.error(
'Save not found: pk=`%s`', pk, exc_info=exception, 'Save not found: pk=`%s`', pk, exc_info=exception,
) )

View File

@ -1,9 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
from bthlabs_jsonrpc_core import JSONRPCInternalError
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError
import django.db import django.db
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect 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.associations import AssociationOut
from hotpocket_soa.dto.celery import AsyncResultOut from hotpocket_soa.dto.celery import AsyncResultOut
from hotpocket_soa.dto.saves import SaveIn, SaveOut from hotpocket_soa.dto.saves import SaveIn, SaveOut
from hotpocket_soa.services import SavesService
from .base import SaveWorkflow from .base import SaveWorkflow
@ -73,14 +70,8 @@ class CreateSaveWorkflow(SaveWorkflow):
account: PAccount, account: PAccount,
url: str, url: str,
) -> AssociationOut: ) -> AssociationOut:
try:
save, association, processing_result = self.create_associate_and_process( save, association, processing_result = self.create_associate_and_process(
account, url, account, url,
) )
return association return association
except SavesService.SavesServiceError as exception:
if isinstance(exception.__cause__, ValidationError) is True:
raise JSONRPCInternalError(data=exception.__cause__)
raise

View File

@ -11,6 +11,7 @@ LOGGER = logging.getLogger(__name__)
@shared_task @shared_task
def import_from_pocket(*, def import_from_pocket(*,
job: str,
account_uuid: uuid.UUID, account_uuid: uuid.UUID,
csv_path: str, csv_path: str,
) -> list[tuple[uuid.UUID, uuid.UUID]]: ) -> list[tuple[uuid.UUID, uuid.UUID]]:
@ -18,6 +19,7 @@ def import_from_pocket(*,
try: try:
return UIImportsService().import_from_pocket( return UIImportsService().import_from_pocket(
job=job,
account_uuid=account_uuid, account_uuid=account_uuid,
csv_path=csv_path, csv_path=csv_path,
) )

View File

@ -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 @pytest.fixture
def other_account_association(association_factory, other_account): def other_account_association(association_factory, other_account):
return association_factory(account=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 @pytest.fixture
def browsable_associations(association, def browsable_associations(association,
deleted_association, deleted_association,

View File

@ -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 @pytest.fixture
def pocket_csv_content(pocket_import_created_save_spec, def pocket_csv_content(pocket_import_created_save_spec,
pocket_import_reused_save_spec, pocket_import_reused_save_spec,
pocket_import_other_account_save_spec, pocket_import_other_account_save_spec,
pocket_import_banned_netloc_save_spec, pocket_import_banned_netloc_save_spec,
pocket_import_invalid_url_spec,
): ):
with io.StringIO() as csv_f: with io.StringIO() as csv_f:
field_names = [ field_names = [
@ -82,6 +96,7 @@ def pocket_csv_content(pocket_import_created_save_spec,
pocket_import_reused_save_spec.dict(), pocket_import_reused_save_spec.dict(),
pocket_import_other_account_save_spec.dict(), pocket_import_other_account_save_spec.dict(),
pocket_import_banned_netloc_save_spec.dict(), pocket_import_banned_netloc_save_spec.dict(),
pocket_import_invalid_url_spec.dict(),
]) ])
csv_f.seek(0) csv_f.seek(0)

View File

@ -29,6 +29,20 @@ class AssociationsTestingService:
if reference is not None: if reference is not None:
assert association.updated_at > reference.updated_at 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): def assert_starred(self, *, pk: uuid.UUID, reference: typing.Any = None):
association = Association.objects.get(pk=pk) association = Association.objects.get(pk=pk)
assert association.starred_at is not None assert association.starred_at is not None

View File

@ -45,10 +45,12 @@ def test_ok(account,
other_account_save_out, other_account_save_out,
pocket_import_other_account_save_spec: PocketImportSaveSpec, pocket_import_other_account_save_spec: PocketImportSaveSpec,
pocket_import_banned_netloc_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, mock_saves_process_save_task_apply_async: mock.Mock,
): ):
# When # When
result = tasks_module.import_from_pocket( result = tasks_module.import_from_pocket(
job='test',
account_uuid=account.pk, account_uuid=account.pk,
csv_path=str(pocket_csv_file_path), csv_path=str(pocket_csv_file_path),
) )

View File

@ -100,6 +100,54 @@ def test_invalid_all_empty(authenticated_client: Client,
assert 'canhazconfirm' in result.context['form'].errors 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 @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,
other_account_association_out, other_account_association_out,

View File

@ -9,7 +9,7 @@ from django.urls import reverse
import pytest import pytest
from pytest_django import asserts 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 from hotpocket_common.constants import AssociationsSearchMode
@ -35,9 +35,10 @@ def test_ok(authenticated_client: Client,
fetch_redirect_response=False, fetch_redirect_response=False,
) )
association_object = Association.objects.get(pk=association_out.pk) AssociationsTestingService().assert_deleted(
assert association_object.updated_at > association_out.updated_at pk=association_out.pk,
assert association_object.deleted_at is not None reference=association_out,
)
@pytest.mark.django_db @pytest.mark.django_db
@ -65,6 +66,34 @@ def test_ok_htmx(authenticated_client: Client,
assert result.json() == expected_payload 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 @pytest.mark.django_db
def test_invalid_all_missing(authenticated_client: Client, def test_invalid_all_missing(authenticated_client: Client,
association_out, association_out,
@ -78,13 +107,13 @@ def test_invalid_all_missing(authenticated_client: Client,
# Then # Then
assert result.status_code == http.HTTPStatus.OK 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 assert 'canhazconfirm' in result.context['form'].errors
AssociationsTestingService().assert_not_deleted(
pk=association_out.pk,
reference=association_out,
)
@pytest.mark.django_db @pytest.mark.django_db
def test_invalid_all_empty(authenticated_client: Client, def test_invalid_all_empty(authenticated_client: Client,
@ -100,13 +129,45 @@ def test_invalid_all_empty(authenticated_client: Client,
# Then # Then
assert result.status_code == http.HTTPStatus.OK 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 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 @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,

View File

@ -47,7 +47,7 @@ def test_ok(authenticated_client: Client,
@pytest.mark.django_db @pytest.mark.django_db
def test_invalid_all_empty(authenticated_client: Client, def test_all_empty(authenticated_client: Client,
association_out, association_out,
payload, payload,
): ):
@ -79,7 +79,7 @@ def test_invalid_all_empty(authenticated_client: Client,
@pytest.mark.django_db @pytest.mark.django_db
def test_invalid_all_missing(authenticated_client: Client, def test_all_missing(authenticated_client: Client,
association_out, association_out,
): ):
# Given # Given
@ -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 @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,
other_account_association_out, other_account_association_out,

View File

@ -128,6 +128,54 @@ def test_invalid_all_empty(authenticated_client: Client,
assert 'canhazconfirm' in result.context['form'].errors 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 @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,
other_account_association_out, other_account_association_out,

View File

@ -54,6 +54,45 @@ def test_ok_htmx(authenticated_client: Client,
assert result.context['association'].target.pk == association_out.target.pk 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 @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,
other_account_association_out, other_account_association_out,

View File

@ -54,6 +54,45 @@ def test_ok_htmx(authenticated_client: Client,
assert result.context['association'].target.pk == starred_association_out.target.pk 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 @pytest.mark.django_db
def test_other_account_association(authenticated_client: Client, def test_other_account_association(authenticated_client: Client,
other_account_starred_association_out, other_account_starred_association_out,

View File

@ -66,6 +66,19 @@ def test_authenticated_deleted(authenticated_client: Client,
assert result.status_code == http.HTTPStatus.NOT_FOUND 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 @pytest.mark.django_db
def test_authenticated_not_found(authenticated_client: Client, def test_authenticated_not_found(authenticated_client: Client,
null_uuid, null_uuid,
@ -169,6 +182,23 @@ def test_authenticated_share_deleted(authenticated_client: Client,
assert result.status_code == http.HTTPStatus.NOT_FOUND 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 @pytest.mark.django_db
def test_authenticated_share_not_found(authenticated_client: Client, def test_authenticated_share_not_found(authenticated_client: Client,
null_uuid, null_uuid,
@ -240,6 +270,23 @@ def test_anonymous_share_deleted(client: Client,
assert result.status_code == http.HTTPStatus.NOT_FOUND 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 @pytest.mark.django_db
def test_anonymous_share_not_found(client: Client, def test_anonymous_share_not_found(client: Client,
null_uuid, null_uuid,

View File

@ -76,6 +76,7 @@ def test_ok(override_settings_upload_path,
mock_ui_import_from_pocket_task_apply_async.assert_called_once_with( mock_ui_import_from_pocket_task_apply_async.assert_called_once_with(
kwargs={ kwargs={
'job': mock.ANY,
'account_uuid': account.pk, 'account_uuid': account.pk,
'csv_path': str(uploaded_file_path), 'csv_path': str(uploaded_file_path),
}, },

View File

@ -77,10 +77,11 @@ def test_auth_key_not_found(null_uuid,
call_result = result.json() call_result = result.json()
assert 'error' in call_result 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', '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 @pytest.mark.django_db
@ -108,10 +109,11 @@ def test_deleted_auth_key(deleted_auth_key_out,
call_result = result.json() call_result = result.json()
assert 'error' in call_result 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', '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 @pytest.mark.django_db
@ -139,10 +141,11 @@ def test_expired_auth_key(expired_auth_key_out,
call_result = result.json() call_result = result.json()
assert 'error' in call_result 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', '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 @pytest.mark.django_db
@ -170,10 +173,11 @@ def test_consumed_auth_key(consumed_auth_key,
call_result = result.json() call_result = result.json()
assert 'error' in call_result 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', '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 @pytest.mark.django_db
@ -201,4 +205,5 @@ def test_inactive_account(inactive_account_auth_key,
call_result = result.json() call_result = result.json()
assert 'error' in call_result 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']

View File

@ -8,7 +8,7 @@ ARG APP_USER_UID
ARG APP_USER_GID ARG APP_USER_GID
ARG IMAGE_ID 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"] VOLUME ["/srv/node_modules", "/srv/venv"]

View File

@ -8,7 +8,7 @@ ARG APP_USER_UID
ARG APP_USER_GID ARG APP_USER_GID
ARG IMAGE_ID 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"] VOLUME ["/srv/node_modules", "/srv/venv"]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import http
import uuid import uuid
from hotpocket_backend.apps.accounts.services import ( from hotpocket_backend.apps.accounts.services import (
@ -11,6 +12,7 @@ from hotpocket_soa.dto.accounts import (
AccessTokenOut, AccessTokenOut,
AccessTokensQuery, AccessTokensQuery,
) )
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -19,22 +21,18 @@ class AccessTokensService(ProxyService):
class AccessTokensServiceError(SOAError): class AccessTokensServiceError(SOAError):
pass pass
class AccessTokenNotFound(AccessTokensServiceError): class NotFound(AccessTokensServiceError):
pass pass
class AccessTokenAccessDenied(AccessTokensServiceError): class AccessDenied(AccessTokensServiceError):
pass pass
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_access_tokens_service = BackendAccessTokensService() self.backend_access_tokens_service = BackendAccessTokensService()
def wrap_exception(self, exception: Exception) -> Exception: def get_error_class(self) -> type[SOAError]:
new_exception_args = [] return self.AccessTokensServiceError
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AccessTokensServiceError(*new_exception_args)
def create(self, def create(self,
*, *,
@ -69,16 +67,14 @@ class AccessTokensService(ProxyService):
) )
if result.account_uuid != account_uuid: if result.account_uuid != account_uuid:
raise self.AccessTokenAccessDenied( raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` pk=`{pk}`', f'account_uuid=`{account_uuid}` pk=`{pk}`',
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AccessTokenNotFound(*exception.args) from exception
else:
raise
def get_by_key(self, def get_by_key(self,
*, *,
@ -96,16 +92,14 @@ class AccessTokensService(ProxyService):
) )
if result.account_uuid != account_uuid: if result.account_uuid != account_uuid:
raise self.AccessTokenAccessDenied( raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` key=`{key}`', f'account_uuid=`{account_uuid}` key=`{key}`',
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AccessTokenNotFound(f'account_uuid=`{account_uuid}` pk=`{key}`') from exception
else:
raise
def search(self, def search(self,
*, *,
@ -124,17 +118,21 @@ class AccessTokensService(ProxyService):
] ]
def delete(self, *, access_token: AccessTokenOut) -> bool: def delete(self, *, access_token: AccessTokenOut) -> bool:
try:
return self.call( return self.call(
self.backend_access_tokens_service, self.backend_access_tokens_service,
'delete', 'delete',
pk=access_token.pk, pk=access_token.pk,
) )
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def update_meta(self, def update_meta(self,
*, *,
access_token: AccessTokenOut, access_token: AccessTokenOut,
update: AccessTokenMetaUpdateIn, update: AccessTokenMetaUpdateIn,
) -> AccessTokenOut: ) -> AccessTokenOut:
try:
return AccessTokenOut.model_validate( return AccessTokenOut.model_validate(
self.call( self.call(
self.backend_access_tokens_service, self.backend_access_tokens_service,
@ -144,3 +142,5 @@ class AccessTokensService(ProxyService):
), ),
from_attributes=True, from_attributes=True,
) )
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@ -7,6 +7,7 @@ from hotpocket_backend.apps.accounts.services import (
AccountsService as BackendAccountsService, AccountsService as BackendAccountsService,
) )
from hotpocket_soa.dto.accounts import AccountOut from hotpocket_soa.dto.accounts import AccountOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -15,19 +16,15 @@ class AccountsService(ProxyService):
class AccountsServiceError(SOAError): class AccountsServiceError(SOAError):
pass pass
class AccountNotFound(AccountsServiceError): class NotFound(AccountsServiceError):
pass pass
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_accounts_service = BackendAccountsService() self.backend_accounts_service = BackendAccountsService()
def wrap_exception(self, exception: Exception) -> Exception: def get_error_class(self) -> type[SOAError]:
new_exception_args = [] return self.AccountsServiceError
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AccountsServiceError(*new_exception_args)
def get(self, *, pk: uuid.UUID) -> AccountOut: def get(self, *, pk: uuid.UUID) -> AccountOut:
try: try:
@ -41,8 +38,5 @@ class AccountsService(ProxyService):
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAccountsService.AccountNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AccountNotFound(*exception.args) from exception
else:
raise

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import http
import uuid import uuid
from hotpocket_backend.apps.saves.services import ( from hotpocket_backend.apps.saves.services import (
@ -14,6 +15,7 @@ from hotpocket_soa.dto.associations import (
AssociationWithTargetOut, AssociationWithTargetOut,
) )
from hotpocket_soa.dto.saves import SaveOut from hotpocket_soa.dto.saves import SaveOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -22,22 +24,18 @@ class AssociationsService(ProxyService):
class AssociationsServiceError(SOAError): class AssociationsServiceError(SOAError):
pass pass
class AssociationNotFound(AssociationsServiceError): class NotFound(AssociationsServiceError):
pass pass
class AssociationAccessDenied(AssociationsServiceError): class AccessDenied(AssociationsServiceError):
pass pass
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_associations_service = BackendAssociationsService() self.backend_associations_service = BackendAssociationsService()
def wrap_exception(self, exception: Exception) -> Exception: def get_error_class(self) -> type[SOAError]:
new_exception_args = [] return self.AssociationsServiceError
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AssociationsServiceError(*new_exception_args)
def create(self, def create(self,
*, *,
@ -81,19 +79,19 @@ class AssociationsService(ProxyService):
) )
if allow_archived is False and result.archived_at is not None: 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: 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}`', f'account_uuid=`{account_uuid}` pk=`{pk}`',
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAssociationsService.AssociationNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AssociationNotFound(*exception.args) from exception
else:
raise
def search(self, def search(self,
*, *,
@ -112,13 +110,17 @@ class AssociationsService(ProxyService):
] ]
def archive(self, *, association: AssociationOut) -> bool: def archive(self, *, association: AssociationOut) -> bool:
try:
return self.call( return self.call(
self.backend_associations_service, self.backend_associations_service,
'archive', 'archive',
pk=association.pk, pk=association.pk,
) )
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def star(self, *, association: AssociationOut) -> AssociationOut: def star(self, *, association: AssociationOut) -> AssociationOut:
try:
return AssociationOut.model_validate( return AssociationOut.model_validate(
self.call( self.call(
self.backend_associations_service, self.backend_associations_service,
@ -127,8 +129,11 @@ class AssociationsService(ProxyService):
), ),
from_attributes=True, from_attributes=True,
) )
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def unstar(self, *, association: AssociationOut) -> AssociationOut: def unstar(self, *, association: AssociationOut) -> AssociationOut:
try:
return AssociationOut.model_validate( return AssociationOut.model_validate(
self.call( self.call(
self.backend_associations_service, self.backend_associations_service,
@ -137,12 +142,15 @@ class AssociationsService(ProxyService):
), ),
from_attributes=True, from_attributes=True,
) )
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def update(self, def update(self,
*, *,
association: AssociationOut, association: AssociationOut,
update: AssociationUpdateIn, update: AssociationUpdateIn,
) -> AssociationOut: ) -> AssociationOut:
try:
return AssociationOut.model_validate( return AssociationOut.model_validate(
self.call( self.call(
self.backend_associations_service, self.backend_associations_service,
@ -152,10 +160,15 @@ class AssociationsService(ProxyService):
), ),
from_attributes=True, from_attributes=True,
) )
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def delete(self, *, association: AssociationOut) -> bool: def delete(self, *, association: AssociationOut) -> bool:
try:
return self.call( return self.call(
self.backend_associations_service, self.backend_associations_service,
'delete', 'delete',
pk=association.pk, pk=association.pk,
) )
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@ -1,12 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import http
import uuid import uuid
from hotpocket_backend.apps.accounts.services import ( from hotpocket_backend.apps.accounts.services import (
AuthKeysService as BackendAuthKeysService, AuthKeysService as BackendAuthKeysService,
) )
from hotpocket_soa.dto.accounts import AuthKeyOut from hotpocket_soa.dto.accounts import AuthKeyOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -15,23 +17,16 @@ class AuthKeysService(ProxyService):
class AuthKeysServiceError(SOAError): class AuthKeysServiceError(SOAError):
pass pass
class AuthKeyNotFound(AuthKeysServiceError): class NotFound(AuthKeysServiceError):
pass pass
class AuthKeyAccessDenied(AuthKeysServiceError): class AccessDenied(AuthKeysServiceError):
pass pass
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_auth_keys_service = BackendAuthKeysService() 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, def _check_auth_key_access(self,
auth_key: AuthKeyOut, auth_key: AuthKeyOut,
account_uuid: uuid.UUID | None, account_uuid: uuid.UUID | None,
@ -70,16 +65,14 @@ class AuthKeysService(ProxyService):
) )
if self._check_auth_key_access(result, account_uuid) is False: 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}`', f'account_uuid=`{account_uuid}` pk=`{pk}`',
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AuthKeyNotFound(*exception.args) from exception
else:
raise
def get_by_key(self, def get_by_key(self,
*, *,
@ -97,13 +90,11 @@ class AuthKeysService(ProxyService):
) )
if self._check_auth_key_access(result, account_uuid) is False: 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}`', f'account_uuid=`{account_uuid}` key=`{key}`',
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.AuthKeyNotFound(*exception.args) from exception
else:
raise

View File

@ -1,16 +1,66 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import functools
import http
import types
import typing import typing
from hotpocket_soa.exceptions.backend import BackendServiceError
class SOAError(Exception): from hotpocket_soa.exceptions.frontend import SOAError
pass
class Service: class Service:
def wrap_exception(self, exception: Exception) -> Exception: def __getattribute__(self, name: str) -> typing.Any:
return SOAError(exception.args[0]) 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): class ProxyService(Service):
@ -18,7 +68,4 @@ class ProxyService(Service):
handler = getattr(service, method, None) handler = getattr(service, method, None)
assert handler is not None, f'Unknown method: method=`{method}`' assert handler is not None, f'Unknown method: method=`{method}`'
try:
return handler(*args, **kwargs) return handler(*args, **kwargs)
except Exception as exception:
raise self.wrap_exception(exception) from exception

View File

@ -15,12 +15,8 @@ class BotService(ProxyService):
super().__init__() super().__init__()
self.backend_associations_service = BackendBotService() self.backend_associations_service = BackendBotService()
def wrap_exception(self, exception: Exception) -> Exception: def get_error_class(self) -> type[SOAError]:
new_exception_args = [] return self.BotServiceError
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.BotServiceError(*new_exception_args)
def is_netloc_banned(self, *, url: str) -> bool: def is_netloc_banned(self, *, url: str) -> bool:
return self.call( return self.call(

View File

@ -18,17 +18,13 @@ class SaveProcessorService(ProxyService):
class SaveProcessorServiceError(SOAError): class SaveProcessorServiceError(SOAError):
pass 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): def __init__(self):
super().__init__() super().__init__()
self.backend_save_processor_service = BackendSaveProcessorService() self.backend_save_processor_service = BackendSaveProcessorService()
def get_error_class(self) -> type[SOAError]:
return self.SaveProcessorServiceError
def schedule_process_save(self, *, save: SaveOut) -> AsyncResultOut: def schedule_process_save(self, *, save: SaveOut) -> AsyncResultOut:
result = AsyncResultOut.model_validate( result = AsyncResultOut.model_validate(
self.call( self.call(

View File

@ -7,6 +7,7 @@ from hotpocket_backend.apps.saves.services import (
SavesService as BackendSavesService, SavesService as BackendSavesService,
) )
from hotpocket_soa.dto.saves import SaveIn, SaveOut from hotpocket_soa.dto.saves import SaveIn, SaveOut
from hotpocket_soa.exceptions.backend import Invalid, NotFound
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -15,21 +16,21 @@ class SavesService(ProxyService):
class SavesServiceError(SOAError): class SavesServiceError(SOAError):
pass pass
class SaveNotFound(SavesServiceError): class NotFound(SavesServiceError):
pass pass
def wrap_exception(self, exception: Exception) -> Exception: class Invalid(SavesServiceError):
new_exception_args = [] pass
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.SavesServiceError(*new_exception_args)
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.backend_saves_service = BackendSavesService() 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: def create(self, *, account_uuid: uuid.UUID, save: SaveIn) -> SaveOut:
try:
return SaveOut.model_validate( return SaveOut.model_validate(
self.call( self.call(
self.backend_saves_service, self.backend_saves_service,
@ -39,6 +40,10 @@ class SavesService(ProxyService):
), ),
from_attributes=True, from_attributes=True,
) )
except Invalid as exception:
raise self.Invalid(
exception.CODE.value, exception.message, exception.data,
)
def get(self, *, pk: uuid.UUID) -> SaveOut: def get(self, *, pk: uuid.UUID) -> SaveOut:
try: try:
@ -52,8 +57,5 @@ class SavesService(ProxyService):
) )
return result return result
except SOAError as exception: except NotFound as exception:
if isinstance(exception.__cause__, BackendSavesService.SaveNotFound) is True: raise self.NotFound.from_backend_error(exception)
raise self.SaveNotFound(*exception.args) from exception
else:
raise

View File

@ -24,8 +24,8 @@ server {
listen *:443 ssl; listen *:443 ssl;
server_name app.hotpocket.work.bthlabs.net; server_name app.hotpocket.work.bthlabs.net;
ssl_certificate /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt; ssl_certificate /Users/bilbo/Projects/HOTPOCKET/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_key /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key;
location / { location / {
# proxy_cache_bypass $http_upgrade; # proxy_cache_bypass $http_upgrade;
@ -71,8 +71,8 @@ server {
listen *:443 ssl; listen *:443 ssl;
server_name admin.hotpocket.work.bthlabs.net; server_name admin.hotpocket.work.bthlabs.net;
ssl_certificate /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt; ssl_certificate /Users/bilbo/Projects/HOTPOCKET/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_key /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key;
location / { location / {
# proxy_cache_bypass $http_upgrade; # proxy_cache_bypass $http_upgrade;