diff --git a/server/config/__init__.py b/server/config/__init__.py index c78f109..69377f8 100644 --- a/server/config/__init__.py +++ b/server/config/__init__.py @@ -26,7 +26,7 @@ class AppConfig: db_pass_salt: str = field("DB_PASS_SALT", "") db_search_path: str = field("DB_SEARCH_PATH", "beerds") - fs_local_mount_dir: str = field("FS_LOCAL_MOUNT_DIR", default="./tmp/files") + fs_local_mount_dir: str = field("FS_LOCAL_MOUNT_DIR", default="./files") fs_s3_bucket: str = field("FS_S3_BUCKET", "") fs_s3_access_key_id: str = field("FS_ACCESS_KEY_ID", "") fs_s3_access_key: str = field("FS_SECRET_ACCESS_KEY", "") diff --git a/server/infra/db/db_mapper.py b/server/infra/db/db_mapper.py index d2cdef0..bcdf3a4 100644 --- a/server/infra/db/db_mapper.py +++ b/server/infra/db/db_mapper.py @@ -1,9 +1,7 @@ - from sqlalchemy.orm import registry mapper_registry = registry() - def dict_to_dataclass[T](data: dict, class_type: type[T]) -> T: return class_type(**data) diff --git a/server/infra/db/pg.py b/server/infra/db/pg.py index 3128a36..24c93cc 100644 --- a/server/infra/db/pg.py +++ b/server/infra/db/pg.py @@ -39,3 +39,9 @@ class AsyncDB(AbstractDB): def new_session(self): return asyncio.async_sessionmaker(self.engine, expire_on_commit=False)() + + def session_master(self): + return self.new_session() + + def session_slave(self): + return self.new_session() diff --git a/server/infra/web/__init__.py b/server/infra/web/__init__.py index cd8c64a..469f7ab 100644 --- a/server/infra/web/__init__.py +++ b/server/infra/web/__init__.py @@ -1,5 +1,6 @@ from server.infra.web.description import DescriptionController from server.infra.web.recognizer import BreedsController from server.infra.web.seo import SeoController +from server.infra.web.vote import VoteController -__all__ = ("DescriptionController", "SeoController", "BreedsController") +__all__ = ("DescriptionController", "SeoController", "BreedsController", "VoteController") diff --git a/server/infra/web/recognizer.py b/server/infra/web/recognizer.py index d9812a1..8afd43b 100644 --- a/server/infra/web/recognizer.py +++ b/server/infra/web/recognizer.py @@ -19,10 +19,12 @@ class BreedsController(Controller): async def beerds_dogs(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict: recognizer_service: RecognizerService = inject.instance(RecognizerService) body = await data.read() - return await recognizer_service.predict_dog_image(body) + result = await recognizer_service.predict_dog_image(body) + return result.to_serializable() @post("/cats") async def beerds_cats(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict: recognizer_service: RecognizerService = inject.instance(RecognizerService) body = await data.read() - return await recognizer_service.predict_cat_image(body) + result = await recognizer_service.predict_cat_image(body) + return result.to_serializable() diff --git a/server/infra/web/vote.py b/server/infra/web/vote.py new file mode 100644 index 0000000..c1910d9 --- /dev/null +++ b/server/infra/web/vote.py @@ -0,0 +1,39 @@ +from datetime import UTC, datetime +from uuid import uuid4 + +import inject +from litestar import ( + Controller, + post, +) +from pydantic import BaseModel + +from server.modules.rate import Vote, VotesService +from server.modules.descriptions import CharactersService + + +class VoteReq(BaseModel): + attachment_id: str + beerd_name: str + rate: int + + def to_domain(self, name_id_convert: dict) -> Vote: + return Vote( + id=str(uuid4()), + attachment_id=self.attachment_id, + beerd_id=name_id_convert[self.beerd_name], + rate=self.rate, + created_at=datetime.now(UTC), + ) + + +class VoteController(Controller): + path = "/votes" + + @post("/do") + async def beerds_dogs(self, data: VoteReq) -> dict: + rate_service: VotesService = inject.instance(VotesService) + characters_service: CharactersService = inject.instance(CharactersService) + breeds = await characters_service.get_characters() + await rate_service.add_vote(data.to_domain({b.name: b.id for b in breeds})) + return {"success": True} diff --git a/server/main.py b/server/main.py index 8bf326a..a459c78 100644 --- a/server/main.py +++ b/server/main.py @@ -12,9 +12,11 @@ from litestar.template.config import TemplateConfig from server.config import get_app_config from server.infra.db import AsyncDB -from server.infra.web import BreedsController, DescriptionController, SeoController +from server.infra.web import BreedsController, DescriptionController, SeoController, VoteController +from server.modules.attachments import AtachmentService, DBAttachmentRepository, LocalStorageDriver from server.modules.descriptions import CharactersService, PGCharactersRepository from server.modules.recognizer import RecognizerRepository, RecognizerService +from server.modules.rate import PGVoteRepository, VotesService os.environ["CUDA_VISIBLE_DEVICES"] = "-1" @@ -28,8 +30,11 @@ def inject_config(binder: inject.Binder): cnf = get_app_config() db = AsyncDB(cnf) loop.run_until_complete(db.connect()) - binder.bind(RecognizerService, RecognizerService(RecognizerRepository())) + attach_service = AtachmentService(LocalStorageDriver(), DBAttachmentRepository(db)) + binder.bind(RecognizerService, RecognizerService(RecognizerRepository(), attach_service)) binder.bind(CharactersService, CharactersService(PGCharactersRepository(db))) + binder.bind(VotesService, VotesService(PGVoteRepository(db))) + binder.bind(AtachmentService, attach_service) inject.configure(inject_config) @@ -39,6 +44,7 @@ app = Litestar( BreedsController, DescriptionController, SeoController, + VoteController, create_static_files_router(path="/static", directories=["server/static"]), ], template_config=TemplateConfig( diff --git a/server/migration/versions/2026-01-12-1828-474b572b7fe2-parent-commit-a284498.py b/server/migration/versions/2026-01-12-1828-474b572b7fe2-parent-commit-a284498.py index 3ec63bd..6bfaa1c 100644 --- a/server/migration/versions/2026-01-12-1828-474b572b7fe2-parent-commit-a284498.py +++ b/server/migration/versions/2026-01-12-1828-474b572b7fe2-parent-commit-a284498.py @@ -46,11 +46,11 @@ def upgrade() -> None: op.create_table( "votes", sa.Column("id", sa.String(), nullable=False), - sa.Column("attachemnt_id", sa.String(), nullable=False), + sa.Column("attachment_id", sa.String(), nullable=False), sa.Column("beerd_id", sa.String(), nullable=False), sa.Column("rate", sa.BigInteger(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(["attachemnt_id"], ["attachments.id"], name="votes_attachemnt_id_fk"), + sa.ForeignKeyConstraint(["attachment_id"], ["attachments.id"], name="votes_attachment_id_fk"), sa.ForeignKeyConstraint(["beerd_id"], ["beerds.id"], name="votes_beerd_id_fk"), sa.PrimaryKeyConstraint("id"), ) diff --git a/server/modules/attachments/domains/attachments.py b/server/modules/attachments/domains/attachments.py index 0d79639..d591072 100644 --- a/server/modules/attachments/domains/attachments.py +++ b/server/modules/attachments/domains/attachments.py @@ -13,6 +13,5 @@ class Attachment(UJsonMixin): storage_driver_name: str path: str media_type: str - created_by: str content_type: str is_deleted: bool = False diff --git a/server/modules/attachments/repository/attachments.py b/server/modules/attachments/repository/attachments.py index 8821cfd..9545ce2 100644 --- a/server/modules/attachments/repository/attachments.py +++ b/server/modules/attachments/repository/attachments.py @@ -5,7 +5,7 @@ from sqlalchemy import CursorResult, delete, insert, select, update from server.config import get_app_config from server.infra.db import AbstractDB, AbstractSession, AsyncDB, MockDB -from server.modules.attachments.domains.attachments import Attachment +from server.modules.attachments.repository.models import Attachment class AttachmentRepository(metaclass=ABCMeta): diff --git a/server/modules/attachments/repository/models.py b/server/modules/attachments/repository/models.py index 864a323..70fd0a5 100644 --- a/server/modules/attachments/repository/models.py +++ b/server/modules/attachments/repository/models.py @@ -6,6 +6,7 @@ from sqlalchemy import BigInteger, Boolean, Column, DateTime, String from server.config import get_app_config from server.infra.db.db_mapper import mapper_registry +from server.modules.attachments.domains.attachments import Attachment as AttachmentModel @mapper_registry.mapped @@ -33,3 +34,15 @@ class Attachment(UJsonMixin): def __str__(self): return f"{get_app_config().app_public_url}/api/v0/attachment/{self.id}.original.ext" + + def to_domain(self) -> AttachmentModel: + return AttachmentModel( + id=self.id, + size=self.size, + media_type=self.media_type, + content_type=self.content_type, + created_at=self.created_at, + updated_at=self.updated_at, + storage_driver_name=self.storage_driver_name, + path=self.path, + ) diff --git a/server/modules/attachments/services/attachment.py b/server/modules/attachments/services/attachment.py index 9dbef06..08cd474 100644 --- a/server/modules/attachments/services/attachment.py +++ b/server/modules/attachments/services/attachment.py @@ -21,6 +21,7 @@ from server.infra.db import AbstractSession from server.infra.logger import get_logger from server.modules.attachments.domains.attachments import Attachment from server.modules.attachments.repository.attachments import AttachmentRepository +from server.modules.attachments.repository.models import Attachment as AttachmentModel class StorageDriversType(str, Enum): @@ -240,22 +241,21 @@ class AtachmentService: def url(self, attachment_id: str, content_type: str | None = None) -> str: return f"{self._cnf.app_public_url}/api/v0/attachment/{attachment_id}.original.{self.extension(content_type)}" - async def create(self, file: bytes, user_id: str) -> Attachment: + async def create(self, file: bytes) -> Attachment: path = await self._driver.put(file) content_type = self.content_type(file) - attach = Attachment( + attach = AttachmentModel( size=len(file), storage_driver_name=str(self._driver.get_name()), path=path, media_type=self.media_type(content_type), content_type=content_type, - created_by=user_id, id=str(uuid.uuid4()), created_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) await self._repository.create(attach) - return attach + return attach.to_domain() async def get_info(self, session: AbstractSession | None, attach_id: list[str]) -> list[Attachment]: if not attach_id: diff --git a/server/modules/descriptions/repository/repository.py b/server/modules/descriptions/repository/repository.py index f944fc5..76dfe91 100644 --- a/server/modules/descriptions/repository/repository.py +++ b/server/modules/descriptions/repository/repository.py @@ -69,7 +69,7 @@ class PGCharactersRepository(ACharactersRepository): async with self._db.async_session() as session: # Писем SELECT‑запрос (получаем все строки) - stmt = select( + stmt = select( # type: ignore models.Beerds.id, models.Beerds.name, models.Beerds.alias, @@ -104,7 +104,7 @@ class PGCharactersRepository(ACharactersRepository): async with self._db.async_session() as session: stmt = ( - select( + select( # type: ignore models.Beerds.id, models.Beerds.name, models.Beerds.alias, diff --git a/server/modules/rate/__init__.py b/server/modules/rate/__init__.py new file mode 100644 index 0000000..b2db803 --- /dev/null +++ b/server/modules/rate/__init__.py @@ -0,0 +1,10 @@ +from server.modules.rate.domain import Vote +from server.modules.rate.repository import AVoteRepository, PGVoteRepository +from server.modules.rate.service import VotesService + +__all__ = ( + "VotesService", + "AVoteRepository", + "PGVoteRepository", + "Vote", +) diff --git a/server/modules/rate/domain.py b/server/modules/rate/domain.py new file mode 100644 index 0000000..f80819a --- /dev/null +++ b/server/modules/rate/domain.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class Vote: + id: str + attachment_id: str + beerd_id: str + rate: int + created_at: datetime diff --git a/server/modules/rate/domain/__init__.py b/server/modules/rate/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/server/modules/rate/repository/__init__.py b/server/modules/rate/repository/__init__.py index e69de29..ff282f7 100644 --- a/server/modules/rate/repository/__init__.py +++ b/server/modules/rate/repository/__init__.py @@ -0,0 +1,13 @@ +from server.modules.rate.domain import Vote +from server.modules.rate.repository.models import Vote as VoteModel +from server.modules.rate.repository.repository import AVoteRepository, PGVoteRepository +from server.modules.rate.service import VotesService + +__all__ = ( + "Vote", + "PGVoteRepository", + "ACharactersRepository", + "AVoteRepository", + "VotesService", + "VoteModel", +) diff --git a/server/modules/rate/repository/models.py b/server/modules/rate/repository/models.py index 426e63f..b669c00 100644 --- a/server/modules/rate/repository/models.py +++ b/server/modules/rate/repository/models.py @@ -2,8 +2,6 @@ from dataclasses import dataclass, field from datetime import UTC, datetime from dataclasses_ujson.dataclasses_ujson import UJsonMixin # type: ignore -from server.config import get_app_config -from server.infra.db.db_mapper import mapper_registry from sqlalchemy import ( BigInteger, Column, @@ -12,6 +10,10 @@ from sqlalchemy import ( String, ) +from server.config import get_app_config +from server.infra.db.db_mapper import mapper_registry +from server.modules.rate import domain + @mapper_registry.mapped @dataclass @@ -19,12 +21,12 @@ class Vote(UJsonMixin): __sa_dataclass_metadata_key__ = "sa" __tablename__ = "votes" __table_args__ = ( - ForeignKeyConstraint(["attachemnt_id"], ["attachments.id"], "votes_attachemnt_id_fk"), + ForeignKeyConstraint(["attachment_id"], ["attachments.id"], "votes_attachment_id_fk"), ForeignKeyConstraint(["beerd_id"], ["beerds.id"], "votes_beerd_id_fk"), ) id: str = field(metadata={"sa": Column(String(), primary_key=True, nullable=False)}) - attachemnt_id: str = field(metadata={"sa": Column(String(), nullable=False)}) + attachment_id: str = field(metadata={"sa": Column(String(), nullable=False)}) beerd_id: str = field(metadata={"sa": Column(String(), nullable=False)}) rate: int = field(metadata={"sa": Column(BigInteger(), nullable=False)}) created_at: datetime = field( @@ -34,3 +36,13 @@ class Vote(UJsonMixin): def __str__(self): return f"{get_app_config().app_public_url}/api/v0/attachment/{self.id}.original.ext" + + @staticmethod + def from_domain(d: domain.Vote) -> "Vote": + return Vote( + id=d.id, + attachment_id=d.attachment_id, + beerd_id=d.beerd_id, + rate=d.rate, + created_at=d.created_at, + ) diff --git a/server/modules/rate/repository/repository.py b/server/modules/rate/repository/repository.py new file mode 100644 index 0000000..165431c --- /dev/null +++ b/server/modules/rate/repository/repository.py @@ -0,0 +1,81 @@ +""" +Repository for working with the :class:`~server.modules.rate.repository.models.Vote` model. +Only the “write‑only” operation – adding a vote – is required in the current +application logic. The repository keeps the database interaction +encapsulated and provides an asynchronous API that can be used by the +service layer (or any other consumer). + +The implementation uses the same pattern that is already present in the +``CharactersRepository`` – an async session manager from +``server.infra.db.AsyncDB`` and an interface that makes unit‑testing +straightforward. +""" + +from abc import ABCMeta, abstractmethod + +from server.infra.db import AsyncDB +from server.modules.rate.repository import models + + +# --------------------------------------------------------------------------- # +# 1️⃣ Base interface +# --------------------------------------------------------------------------- # +class AVoteRepository(metaclass=ABCMeta): + """ + Abstract repository that declares the contract for working with votes. + At the moment only one operation is required – inserting a new vote. + """ + + @abstractmethod + async def add_vote(self, vote: models.Vote) -> None: + """Persist ``vote`` into the database.""" + raise NotImplementedError + + +# --------------------------------------------------------------------------- # +# 2️⃣ Concrete PostgreSQL implementation +# --------------------------------------------------------------------------- # +class PGVoteRepository(AVoteRepository): + """ + PostgreSQL implementation of :class:`AVoteRepository`. + + The repository is intentionally *minimal* – only an ``add_vote`` method + is exposed. The rest of the CRUD operations are expected to be added + later if/when the business logic grows. + """ + + _db: AsyncDB + + def __init__(self, db: AsyncDB): + self._db = db + + # --------------------------------------------------------------------- # + # 2.1 Add a vote + # --------------------------------------------------------------------- # + async def add_vote(self, vote: models.Vote) -> None: + """ + Insert ``vote`` into the ``votes`` table. + + Parameters + ---------- + vote: + Instance of :class:`~server.modules.rate.repository.models.Vote` + containing all necessary fields (``id``, ``attachment_id``, + ``beerd_id``, ``rate`` and ``created_at``). The instance + is a *mapped* dataclass, therefore SQLAlchemy can persist it + directly. + + Notes + ----- + * No explicit caching – the operation mutates the database and + should never be served from a cache. + * The method is deliberately ``async`` because it uses + ``AsyncDB.async_session``. + """ + async with self._db.async_session() as session: + # We use the *instance* directly – SQLAlchemy will handle the + # mapping for us. ``insert`` could also be used, but the + # instance‑based approach keeps type‑checking and IDE + # auto‑completion in sync with the dataclass. + session.add(vote) + await session.commit() diff --git a/server/modules/rate/service.py b/server/modules/rate/service.py new file mode 100644 index 0000000..75806c7 --- /dev/null +++ b/server/modules/rate/service.py @@ -0,0 +1,12 @@ +from server.modules.rate.domain import Vote +from server.modules.rate.repository import AVoteRepository, VoteModel + + +class VotesService: + __slots__ = ("_repository",) + + def __init__(self, repository: AVoteRepository): + self._repository = repository + + async def add_vote(self, vote: Vote): + return await self._repository.add_vote(VoteModel.from_domain(vote)) diff --git a/server/modules/rate/service/__init__.py b/server/modules/rate/service/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/server/modules/recognizer/service.py b/server/modules/recognizer/service.py index a2c53be..db3723b 100644 --- a/server/modules/recognizer/service.py +++ b/server/modules/recognizer/service.py @@ -1,13 +1,16 @@ import io import os -from typing import Any, NewType +from dataclasses import dataclass +from typing import Any, NewType, Protocol +from dataclasses_ujson.dataclasses_ujson import UJsonMixin # type: ignore from PIL import Image os.environ["CUDA_VISIBLE_DEVICES"] = "-1" import torch from torchvision import transforms # type: ignore +from server.modules.attachments.domains.attachments import Attachment from server.modules.recognizer.repository import ARecognizerRepository TorchModel = NewType("TorchModel", torch.nn.Module) @@ -23,11 +26,31 @@ DOG_MODEL = load_model("server/models/dogs_model.pth") CAT_MODEL = load_model("server/models/cats_model.pth") -class RecognizerService: - __slots__ = "_repository" +class AttachmentService(Protocol): + async def create(self, file: bytes) -> Attachment: + pass - def __init__(self, repository: ARecognizerRepository): + +@dataclass +class ResultImages(UJsonMixin): + name: str + url: list[str] + + +@dataclass +class RecognizerResult(UJsonMixin): + results: dict + images: list + description: dict[str, list] | None + uploaded_attach_id: str | None + + +class RecognizerService: + __slots__ = ("_repository", "_attachment_service") + + def __init__(self, repository: ARecognizerRepository, attachment_service: AttachmentService): self._repository = repository + self._attachment_service = attachment_service async def images_cats(self) -> dict: return await self._repository.images_cats() @@ -35,7 +58,8 @@ class RecognizerService: async def images_dogs(self) -> dict: return await self._repository.images_dogs() - async def predict_dog_image(self, image: bytes) -> dict: + async def predict_dog_image(self, image: bytes) -> RecognizerResult: + attachment = await self._attachment_service.create(image) predicted_data = self._predict(image, DOG_MODEL) results = {} images = [] @@ -43,42 +67,36 @@ class RecognizerService: images_dogs = await self._repository.images_dogs() for d in predicted_data: predicted_idx, probabilities = d - predicted_label = self._repository.labels_dogs()[str(predicted_idx)] + predicted_label: str = self._repository.labels_dogs()[str(predicted_idx)] name = predicted_label.replace("_", " ") images.append( - { - "name": name, - "url": [f"/static/assets/dog/{predicted_label}/{i}" for i in images_dogs[predicted_label]], - } + ResultImages( + name=name, url=[f"/static/assets/dog/{predicted_label}/{i}" for i in images_dogs[predicted_label]] + ) ) description.setdefault(name, []).append(f"/dogs-characteristics/{name.replace(' ', '_')}") results[probabilities] = name - return { - "results": results, - "images": images, - "description": description, - } + return RecognizerResult( + results=results, images=images, description=description, uploaded_attach_id=attachment.id + ) - async def predict_cat_image(self, image: bytes) -> dict: + async def predict_cat_image(self, image: bytes) -> RecognizerResult: + attachment = await self._attachment_service.create(image) predicted_data = self._predict(image, CAT_MODEL) results = {} images = [] images_cats = await self._repository.images_cats() for d in predicted_data: predicted_idx, probabilities = d - predicted_label = self._repository.labels_cats()[str(predicted_idx)] + predicted_label: str = self._repository.labels_cats()[str(predicted_idx)] name = predicted_label.replace("_", " ") images.append( - { - "name": name, - "url": [f"/static/assets/cat/{predicted_label}/{i}" for i in images_cats[predicted_label]], - } + ResultImages( + name=name, url=[f"/static/assets/cat/{predicted_label}/{i}" for i in images_cats[predicted_label]] + ) ) results[probabilities] = name - return { - "results": results, - "images": images, - } + return RecognizerResult(results=results, images=images, description=None, uploaded_attach_id=attachment.id) def _predict(self, image: bytes, model, device="cpu") -> list[Any]: img_size = (224, 224) diff --git a/server/static/scripts.js b/server/static/scripts.js index c923fcc..840a771 100644 --- a/server/static/scripts.js +++ b/server/static/scripts.js @@ -1,6 +1,9 @@ let urlCreator = window.URL || window.webkitURL; +let current_attachment_id = 0; +let current_beerd_name = []; + async function SavePhoto(self) { document.getElementById("result").innerHTML = ""; let photo = document.getElementById("file-input").files[0]; @@ -10,6 +13,8 @@ async function SavePhoto(self) { if (response.ok) { let json = await response.json(); + current_attachment_id = json.uploaded_attach_id; + let text = "