Release v1.0.0
Some checks failed
CI / Checks (push) Failing after 13m2s

This commit is contained in:
2025-08-20 21:00:50 +02:00
commit b4338e2769
401 changed files with 23576 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# HotPocket by BTHLabs
This repository contains the _HotPocket SOA_ project.

View File

@@ -0,0 +1,2 @@
from .bot import BotResultOut # noqa: F401
from .saves import SaveIn, SaveOut # noqa: F401

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import datetime
import uuid
import pydantic
from hotpocket_common.constants import AssociationsSearchMode
from .base import ModelOut, Query
from .saves import SaveOut
class AssociationOut(ModelOut):
archived_at: datetime.datetime | None
starred_at: datetime.datetime | None
target_uuid: uuid.UUID = pydantic.Field(alias='target_id')
target_meta: dict
target_title: str | None
target_description: str | None
@property
def is_starred(self) -> bool:
return self.starred_at is not None
def get_title(self) -> str | None:
return (
self.target_title
if self.target_title is not None
else self.target_meta.get('title', None)
)
def get_description(self) -> str | None:
return (
self.target_description
if self.target_description is not None
else self.target_meta.get('description', None)
)
@property
def title(self) -> str | None:
return self.get_title()
@property
def description(self) -> str | None:
return self.get_description()
class AssociationWithTargetOut(AssociationOut):
target: SaveOut
@property
def title(self) -> str | None:
return self.get_title() or self.target.title
@property
def description(self) -> str | None:
return self.get_description() or self.target.description
class AssociationIn(pydantic.BaseModel):
archived_at: datetime.datetime | None
class AssociationsQuery(Query):
account_uuid: uuid.UUID
before: uuid.UUID | None = pydantic.Field(default=None)
after: uuid.UUID | None = pydantic.Field(default=None)
search: str | None = pydantic.Field(default=None)
mode: AssociationsSearchMode = pydantic.Field(default=AssociationsSearchMode.DEFAULT)
class AssociationUpdateIn(pydantic.BaseModel):
target_title: str | None = pydantic.Field(default=None)
target_description: str | None = pydantic.Field(default=None)

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import datetime
import uuid
import pydantic
class ModelOut(pydantic.BaseModel):
id: uuid.UUID
account_uuid: uuid.UUID
created_at: datetime.datetime
updated_at: datetime.datetime
deleted_at: datetime.datetime | None
is_active: bool
@property
def pk(self) -> uuid.UUID:
return self.id
class Query(pydantic.BaseModel):
pass

View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import pydantic
class BotResultOut(pydantic.BaseModel):
title: str | None
description: str | None
is_netloc_banned: bool

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import pydantic
class AsyncResultOut(pydantic.BaseModel):
id: str

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import datetime
import urllib.parse
import pydantic
from .base import ModelOut, Query
class SaveOut(ModelOut):
key: str
url: str
title: str | None
description: str | None
last_processed_at: datetime.datetime | None
is_netloc_banned: bool
@property
def parsed_url(self) -> urllib.parse.SplitResult:
if hasattr(self, '_parsed_url') is False:
self._parsed_url = urllib.parse.urlsplit(self.url)
return self._parsed_url
@property
def parsed_url_query(self) -> dict:
if hasattr(self, '_parsed_url_query') is False:
self._parsed_url_query = urllib.parse.parse_qs(
self.parsed_url.query,
)
return self._parsed_url_query
@property
def netloc(self) -> str:
return self.parsed_url.netloc
@property
def is_youtube_video(self) -> bool:
return any((
all((
self.netloc.endswith('youtube.com'),
'v' in self.parsed_url_query,
)),
all((
self.netloc.endswith('youtube.com'),
self.parsed_url.path.startswith('/live'),
)),
self.netloc.endswith('youtu.be'),
))
class SaveIn(pydantic.BaseModel):
url: str
is_netloc_banned: bool | None = pydantic.Field(default=None)
class ImportedSaveIn(SaveIn):
title: str
class SavesQuery(Query):
pass

View File

@@ -0,0 +1,4 @@
from .associations import AssociationsService # noqa: F401
from .bot import BotService # noqa: F401
from .save_processor import SaveProcessorService # noqa: F401
from .saves import SavesService # noqa: F401

View File

@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import datetime
import uuid
from hotpocket_backend.apps.saves.services import (
AssociationsService as BackendAssociationsService,
)
from hotpocket_soa.dto.associations import (
AssociationOut,
AssociationsQuery,
AssociationUpdateIn,
AssociationWithTargetOut,
)
from hotpocket_soa.dto.saves import SaveOut
from .base import ProxyService, SOAError
class AssociationsService(ProxyService):
class AssociationsServiceError(SOAError):
pass
class AssociationNotFound(AssociationsServiceError):
pass
class AssociationAccessDenied(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 create(self,
*,
account_uuid: uuid.UUID,
target: SaveOut,
pk: uuid.UUID | None = None,
created_at: datetime.datetime | None = None,
) -> AssociationOut:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
'create',
account_uuid=account_uuid,
save_uuid=target.pk,
pk=pk,
created_at=created_at,
),
from_attributes=True,
)
def get(self,
*,
account_uuid: uuid.UUID | None,
pk: uuid.UUID,
with_target: bool = False,
allow_archived: bool = False,
) -> AssociationOut | AssociationWithTargetOut:
try:
model: type[AssociationOut] = AssociationOut
if with_target is True:
model = AssociationWithTargetOut
result = model.model_validate(
self.call(
self.backend_associations_service,
'get',
pk=pk,
with_target=with_target,
),
from_attributes=True,
)
if allow_archived is False and result.archived_at is not None:
raise self.AssociationNotFound(f'pk=`{pk}`')
if account_uuid is not None and result.account_uuid != account_uuid:
raise self.AssociationAccessDenied(
f'account_uuid=`{account_uuid}` pk=`{pk}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAssociationsService.AssociationNotFound) is True:
raise self.AssociationNotFound(f'pk=`{pk}`') from exception
else:
raise
def search(self,
*,
query: AssociationsQuery,
limit: int,
) -> list[AssociationWithTargetOut]:
return [
AssociationWithTargetOut.model_validate(row, from_attributes=True)
for row
in self.call(
self.backend_associations_service,
'search',
query=query,
limit=limit,
)
]
def archive(self, *, association: AssociationOut) -> bool:
return self.call(
self.backend_associations_service,
'archive',
pk=association.pk,
)
def star(self, *, association: AssociationOut) -> AssociationOut:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
'star',
pk=association.pk,
),
from_attributes=True,
)
def unstar(self, *, association: AssociationOut) -> AssociationOut:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
'unstar',
pk=association.pk,
),
from_attributes=True,
)
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,
)
def delete(self, *, association: AssociationOut) -> bool:
return self.call(
self.backend_associations_service,
'delete',
pk=association.pk,
)

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
class SOAError(Exception):
pass
class Service:
def wrap_exception(self, exception: Exception) -> Exception:
return SOAError(exception.args[0])
class ProxyService(Service):
def call(self, service: typing.Any, method: str, *args, **kwargs) -> typing.Any:
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

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from hotpocket_backend.apps.bot.services import BotService as BackendBotService
from hotpocket_soa.dto import BotResultOut
from .base import ProxyService, SOAError
class BotService(ProxyService):
class BotServiceError(SOAError):
pass
def __init__(self):
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 is_netloc_banned(self, *, url: str) -> bool:
return self.call(
self.backend_associations_service,
'is_netloc_banned',
url=url,
)
def handle(self, *, url: str) -> BotResultOut:
return BotResultOut.model_validate(
self.call(
self.backend_associations_service,
'handle',
url=url,
),
from_attributes=True,
)

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import logging
from hotpocket_backend.apps.saves.services import (
SaveProcessorService as BackendSaveProcessorService,
)
from hotpocket_soa.dto.celery import AsyncResultOut
from hotpocket_soa.dto.saves import SaveOut
from .base import ProxyService, SOAError
LOGGER = logging.getLogger(__name__)
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 schedule_process_save(self, *, save: SaveOut) -> AsyncResultOut:
result = AsyncResultOut.model_validate(
self.call(
self.backend_save_processor_service,
'schedule_process_save',
pk=save.pk,
),
from_attributes=True,
)
LOGGER.debug('Scheduled processing task: id=`%s`', result.id)
return result

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import uuid
from hotpocket_backend.apps.saves.services import (
SavesService as BackendSavesService,
)
from hotpocket_soa.dto.saves import SaveIn, SaveOut
from .base import ProxyService, SOAError
class SavesService(ProxyService):
class SavesServiceError(SOAError):
pass
class SaveNotFound(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)
def __init__(self):
super().__init__()
self.backend_saves_service = BackendSavesService()
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,
)
def get(self, *, pk: uuid.UUID) -> SaveOut:
try:
result = SaveOut.model_validate(
self.call(
self.backend_saves_service,
'get',
pk=pk,
),
from_attributes=True,
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendSavesService.SaveNotFound) is True:
raise self.SaveNotFound(f'pk=`{pk}`') from exception
else:
raise

View File

@@ -0,0 +1,15 @@
[tool.poetry]
name = "hotpocket-soa"
version = "1.0.0.dev0"
description = "HotPocket SOA"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0"
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12"
pydantic = "2.11.7"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"