rating
Gitea Actions Demo / build_and_push (push) Has been cancelled
Details
Gitea Actions Demo / build_and_push (push) Has been cancelled
Details
This commit is contained in:
parent
e121c6af34
commit
e1ecf09470
|
|
@ -26,7 +26,7 @@ class AppConfig:
|
||||||
db_pass_salt: str = field("DB_PASS_SALT", "")
|
db_pass_salt: str = field("DB_PASS_SALT", "")
|
||||||
db_search_path: str = field("DB_SEARCH_PATH", "beerds")
|
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_bucket: str = field("FS_S3_BUCKET", "")
|
||||||
fs_s3_access_key_id: str = field("FS_ACCESS_KEY_ID", "")
|
fs_s3_access_key_id: str = field("FS_ACCESS_KEY_ID", "")
|
||||||
fs_s3_access_key: str = field("FS_SECRET_ACCESS_KEY", "")
|
fs_s3_access_key: str = field("FS_SECRET_ACCESS_KEY", "")
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
|
|
||||||
from sqlalchemy.orm import registry
|
from sqlalchemy.orm import registry
|
||||||
|
|
||||||
mapper_registry = registry()
|
mapper_registry = registry()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def dict_to_dataclass[T](data: dict, class_type: type[T]) -> T:
|
def dict_to_dataclass[T](data: dict, class_type: type[T]) -> T:
|
||||||
return class_type(**data)
|
return class_type(**data)
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,9 @@ class AsyncDB(AbstractDB):
|
||||||
|
|
||||||
def new_session(self):
|
def new_session(self):
|
||||||
return asyncio.async_sessionmaker(self.engine, expire_on_commit=False)()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from server.infra.web.description import DescriptionController
|
from server.infra.web.description import DescriptionController
|
||||||
from server.infra.web.recognizer import BreedsController
|
from server.infra.web.recognizer import BreedsController
|
||||||
from server.infra.web.seo import SeoController
|
from server.infra.web.seo import SeoController
|
||||||
|
from server.infra.web.vote import VoteController
|
||||||
|
|
||||||
__all__ = ("DescriptionController", "SeoController", "BreedsController")
|
__all__ = ("DescriptionController", "SeoController", "BreedsController", "VoteController")
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,12 @@ class BreedsController(Controller):
|
||||||
async def beerds_dogs(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict:
|
async def beerds_dogs(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict:
|
||||||
recognizer_service: RecognizerService = inject.instance(RecognizerService)
|
recognizer_service: RecognizerService = inject.instance(RecognizerService)
|
||||||
body = await data.read()
|
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")
|
@post("/cats")
|
||||||
async def beerds_cats(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict:
|
async def beerds_cats(self, data: Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)]) -> dict:
|
||||||
recognizer_service: RecognizerService = inject.instance(RecognizerService)
|
recognizer_service: RecognizerService = inject.instance(RecognizerService)
|
||||||
body = await data.read()
|
body = await data.read()
|
||||||
return await recognizer_service.predict_cat_image(body)
|
result = await recognizer_service.predict_cat_image(body)
|
||||||
|
return result.to_serializable()
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -12,9 +12,11 @@ from litestar.template.config import TemplateConfig
|
||||||
|
|
||||||
from server.config import get_app_config
|
from server.config import get_app_config
|
||||||
from server.infra.db import AsyncDB
|
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.descriptions import CharactersService, PGCharactersRepository
|
||||||
from server.modules.recognizer import RecognizerRepository, RecognizerService
|
from server.modules.recognizer import RecognizerRepository, RecognizerService
|
||||||
|
from server.modules.rate import PGVoteRepository, VotesService
|
||||||
|
|
||||||
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
|
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
|
||||||
|
|
||||||
|
|
@ -28,8 +30,11 @@ def inject_config(binder: inject.Binder):
|
||||||
cnf = get_app_config()
|
cnf = get_app_config()
|
||||||
db = AsyncDB(cnf)
|
db = AsyncDB(cnf)
|
||||||
loop.run_until_complete(db.connect())
|
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(CharactersService, CharactersService(PGCharactersRepository(db)))
|
||||||
|
binder.bind(VotesService, VotesService(PGVoteRepository(db)))
|
||||||
|
binder.bind(AtachmentService, attach_service)
|
||||||
|
|
||||||
|
|
||||||
inject.configure(inject_config)
|
inject.configure(inject_config)
|
||||||
|
|
@ -39,6 +44,7 @@ app = Litestar(
|
||||||
BreedsController,
|
BreedsController,
|
||||||
DescriptionController,
|
DescriptionController,
|
||||||
SeoController,
|
SeoController,
|
||||||
|
VoteController,
|
||||||
create_static_files_router(path="/static", directories=["server/static"]),
|
create_static_files_router(path="/static", directories=["server/static"]),
|
||||||
],
|
],
|
||||||
template_config=TemplateConfig(
|
template_config=TemplateConfig(
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,11 @@ def upgrade() -> None:
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"votes",
|
"votes",
|
||||||
sa.Column("id", sa.String(), nullable=False),
|
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("beerd_id", sa.String(), nullable=False),
|
||||||
sa.Column("rate", sa.BigInteger(), nullable=False),
|
sa.Column("rate", sa.BigInteger(), nullable=False),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), 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.ForeignKeyConstraint(["beerd_id"], ["beerds.id"], name="votes_beerd_id_fk"),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,5 @@ class Attachment(UJsonMixin):
|
||||||
storage_driver_name: str
|
storage_driver_name: str
|
||||||
path: str
|
path: str
|
||||||
media_type: str
|
media_type: str
|
||||||
created_by: str
|
|
||||||
content_type: str
|
content_type: str
|
||||||
is_deleted: bool = False
|
is_deleted: bool = False
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from sqlalchemy import CursorResult, delete, insert, select, update
|
||||||
|
|
||||||
from server.config import get_app_config
|
from server.config import get_app_config
|
||||||
from server.infra.db import AbstractDB, AbstractSession, AsyncDB, MockDB
|
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):
|
class AttachmentRepository(metaclass=ABCMeta):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from sqlalchemy import BigInteger, Boolean, Column, DateTime, String
|
||||||
|
|
||||||
from server.config import get_app_config
|
from server.config import get_app_config
|
||||||
from server.infra.db.db_mapper import mapper_registry
|
from server.infra.db.db_mapper import mapper_registry
|
||||||
|
from server.modules.attachments.domains.attachments import Attachment as AttachmentModel
|
||||||
|
|
||||||
|
|
||||||
@mapper_registry.mapped
|
@mapper_registry.mapped
|
||||||
|
|
@ -33,3 +34,15 @@ class Attachment(UJsonMixin):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{get_app_config().app_public_url}/api/v0/attachment/{self.id}.original.ext"
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from server.infra.db import AbstractSession
|
||||||
from server.infra.logger import get_logger
|
from server.infra.logger import get_logger
|
||||||
from server.modules.attachments.domains.attachments import Attachment
|
from server.modules.attachments.domains.attachments import Attachment
|
||||||
from server.modules.attachments.repository.attachments import AttachmentRepository
|
from server.modules.attachments.repository.attachments import AttachmentRepository
|
||||||
|
from server.modules.attachments.repository.models import Attachment as AttachmentModel
|
||||||
|
|
||||||
|
|
||||||
class StorageDriversType(str, Enum):
|
class StorageDriversType(str, Enum):
|
||||||
|
|
@ -240,22 +241,21 @@ class AtachmentService:
|
||||||
def url(self, attachment_id: str, content_type: str | None = None) -> str:
|
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)}"
|
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)
|
path = await self._driver.put(file)
|
||||||
content_type = self.content_type(file)
|
content_type = self.content_type(file)
|
||||||
attach = Attachment(
|
attach = AttachmentModel(
|
||||||
size=len(file),
|
size=len(file),
|
||||||
storage_driver_name=str(self._driver.get_name()),
|
storage_driver_name=str(self._driver.get_name()),
|
||||||
path=path,
|
path=path,
|
||||||
media_type=self.media_type(content_type),
|
media_type=self.media_type(content_type),
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
created_by=user_id,
|
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
created_at=datetime.now(UTC),
|
created_at=datetime.now(UTC),
|
||||||
updated_at=datetime.now(UTC),
|
updated_at=datetime.now(UTC),
|
||||||
)
|
)
|
||||||
await self._repository.create(attach)
|
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]:
|
async def get_info(self, session: AbstractSession | None, attach_id: list[str]) -> list[Attachment]:
|
||||||
if not attach_id:
|
if not attach_id:
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class PGCharactersRepository(ACharactersRepository):
|
||||||
|
|
||||||
async with self._db.async_session() as session:
|
async with self._db.async_session() as session:
|
||||||
# Писем SELECT‑запрос (получаем все строки)
|
# Писем SELECT‑запрос (получаем все строки)
|
||||||
stmt = select(
|
stmt = select( # type: ignore
|
||||||
models.Beerds.id,
|
models.Beerds.id,
|
||||||
models.Beerds.name,
|
models.Beerds.name,
|
||||||
models.Beerds.alias,
|
models.Beerds.alias,
|
||||||
|
|
@ -104,7 +104,7 @@ class PGCharactersRepository(ACharactersRepository):
|
||||||
|
|
||||||
async with self._db.async_session() as session:
|
async with self._db.async_session() as session:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select( # type: ignore
|
||||||
models.Beerds.id,
|
models.Beerds.id,
|
||||||
models.Beerds.name,
|
models.Beerds.name,
|
||||||
models.Beerds.alias,
|
models.Beerds.alias,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -2,8 +2,6 @@ from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from dataclasses_ujson.dataclasses_ujson import UJsonMixin # type: ignore
|
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 (
|
from sqlalchemy import (
|
||||||
BigInteger,
|
BigInteger,
|
||||||
Column,
|
Column,
|
||||||
|
|
@ -12,6 +10,10 @@ from sqlalchemy import (
|
||||||
String,
|
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
|
@mapper_registry.mapped
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -19,12 +21,12 @@ class Vote(UJsonMixin):
|
||||||
__sa_dataclass_metadata_key__ = "sa"
|
__sa_dataclass_metadata_key__ = "sa"
|
||||||
__tablename__ = "votes"
|
__tablename__ = "votes"
|
||||||
__table_args__ = (
|
__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"),
|
ForeignKeyConstraint(["beerd_id"], ["beerds.id"], "votes_beerd_id_fk"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: str = field(metadata={"sa": Column(String(), primary_key=True, nullable=False)})
|
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)})
|
beerd_id: str = field(metadata={"sa": Column(String(), nullable=False)})
|
||||||
rate: int = field(metadata={"sa": Column(BigInteger(), nullable=False)})
|
rate: int = field(metadata={"sa": Column(BigInteger(), nullable=False)})
|
||||||
created_at: datetime = field(
|
created_at: datetime = field(
|
||||||
|
|
@ -34,3 +36,13 @@ class Vote(UJsonMixin):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{get_app_config().app_public_url}/api/v0/attachment/{self.id}.original.ext"
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import io
|
import io
|
||||||
import os
|
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
|
from PIL import Image
|
||||||
|
|
||||||
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
|
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"
|
||||||
import torch
|
import torch
|
||||||
from torchvision import transforms # type: ignore
|
from torchvision import transforms # type: ignore
|
||||||
|
|
||||||
|
from server.modules.attachments.domains.attachments import Attachment
|
||||||
from server.modules.recognizer.repository import ARecognizerRepository
|
from server.modules.recognizer.repository import ARecognizerRepository
|
||||||
|
|
||||||
TorchModel = NewType("TorchModel", torch.nn.Module)
|
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")
|
CAT_MODEL = load_model("server/models/cats_model.pth")
|
||||||
|
|
||||||
|
|
||||||
class RecognizerService:
|
class AttachmentService(Protocol):
|
||||||
__slots__ = "_repository"
|
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._repository = repository
|
||||||
|
self._attachment_service = attachment_service
|
||||||
|
|
||||||
async def images_cats(self) -> dict:
|
async def images_cats(self) -> dict:
|
||||||
return await self._repository.images_cats()
|
return await self._repository.images_cats()
|
||||||
|
|
@ -35,7 +58,8 @@ class RecognizerService:
|
||||||
async def images_dogs(self) -> dict:
|
async def images_dogs(self) -> dict:
|
||||||
return await self._repository.images_dogs()
|
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)
|
predicted_data = self._predict(image, DOG_MODEL)
|
||||||
results = {}
|
results = {}
|
||||||
images = []
|
images = []
|
||||||
|
|
@ -43,42 +67,36 @@ class RecognizerService:
|
||||||
images_dogs = await self._repository.images_dogs()
|
images_dogs = await self._repository.images_dogs()
|
||||||
for d in predicted_data:
|
for d in predicted_data:
|
||||||
predicted_idx, probabilities = d
|
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("_", " ")
|
name = predicted_label.replace("_", " ")
|
||||||
images.append(
|
images.append(
|
||||||
{
|
ResultImages(
|
||||||
"name": name,
|
name=name, url=[f"/static/assets/dog/{predicted_label}/{i}" for i in images_dogs[predicted_label]]
|
||||||
"url": [f"/static/assets/dog/{predicted_label}/{i}" for i in images_dogs[predicted_label]],
|
)
|
||||||
}
|
|
||||||
)
|
)
|
||||||
description.setdefault(name, []).append(f"/dogs-characteristics/{name.replace(' ', '_')}")
|
description.setdefault(name, []).append(f"/dogs-characteristics/{name.replace(' ', '_')}")
|
||||||
results[probabilities] = name
|
results[probabilities] = name
|
||||||
return {
|
return RecognizerResult(
|
||||||
"results": results,
|
results=results, images=images, description=description, uploaded_attach_id=attachment.id
|
||||||
"images": images,
|
)
|
||||||
"description": description,
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
predicted_data = self._predict(image, CAT_MODEL)
|
||||||
results = {}
|
results = {}
|
||||||
images = []
|
images = []
|
||||||
images_cats = await self._repository.images_cats()
|
images_cats = await self._repository.images_cats()
|
||||||
for d in predicted_data:
|
for d in predicted_data:
|
||||||
predicted_idx, probabilities = d
|
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("_", " ")
|
name = predicted_label.replace("_", " ")
|
||||||
images.append(
|
images.append(
|
||||||
{
|
ResultImages(
|
||||||
"name": name,
|
name=name, url=[f"/static/assets/cat/{predicted_label}/{i}" for i in images_cats[predicted_label]]
|
||||||
"url": [f"/static/assets/cat/{predicted_label}/{i}" for i in images_cats[predicted_label]],
|
)
|
||||||
}
|
|
||||||
)
|
)
|
||||||
results[probabilities] = name
|
results[probabilities] = name
|
||||||
return {
|
return RecognizerResult(results=results, images=images, description=None, uploaded_attach_id=attachment.id)
|
||||||
"results": results,
|
|
||||||
"images": images,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _predict(self, image: bytes, model, device="cpu") -> list[Any]:
|
def _predict(self, image: bytes, model, device="cpu") -> list[Any]:
|
||||||
img_size = (224, 224)
|
img_size = (224, 224)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
|
||||||
let urlCreator = window.URL || window.webkitURL;
|
let urlCreator = window.URL || window.webkitURL;
|
||||||
|
|
||||||
|
let current_attachment_id = 0;
|
||||||
|
let current_beerd_name = [];
|
||||||
|
|
||||||
async function SavePhoto(self) {
|
async function SavePhoto(self) {
|
||||||
document.getElementById("result").innerHTML = "";
|
document.getElementById("result").innerHTML = "";
|
||||||
let photo = document.getElementById("file-input").files[0];
|
let photo = document.getElementById("file-input").files[0];
|
||||||
|
|
@ -10,6 +13,8 @@ async function SavePhoto(self) {
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
let json = await response.json();
|
let json = await response.json();
|
||||||
|
current_attachment_id = json.uploaded_attach_id;
|
||||||
|
|
||||||
let text = "<h3 class='image-results'>Результаты</h3>";
|
let text = "<h3 class='image-results'>Результаты</h3>";
|
||||||
let uniqChecker = {};
|
let uniqChecker = {};
|
||||||
|
|
||||||
|
|
@ -32,6 +37,7 @@ async function SavePhoto(self) {
|
||||||
|
|
||||||
// Обработка основных результатов
|
// Обработка основных результатов
|
||||||
for (let key in json.results) {
|
for (let key in json.results) {
|
||||||
|
current_beerd_name.push(json.results[key]);
|
||||||
if (json.description != undefined) {
|
if (json.description != undefined) {
|
||||||
text += `<div class='image-block'><div class='image-text'>${json.results[key]} (вероятность: ${Math.round(parseFloat(key) * 100)}%) <br/><a href="${json.description[json.results[key]]}" target='_blank'>Описание </a></div>`;
|
text += `<div class='image-block'><div class='image-text'>${json.results[key]} (вероятность: ${Math.round(parseFloat(key) * 100)}%) <br/><a href="${json.description[json.results[key]]}" target='_blank'>Описание </a></div>`;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -51,7 +57,6 @@ async function SavePhoto(self) {
|
||||||
text += "</div>";
|
text += "</div>";
|
||||||
uniqChecker[json.results[key]] = key;
|
uniqChecker[json.results[key]] = key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработка дополнительных результатов
|
// Обработка дополнительных результатов
|
||||||
for (let key in json.results_net) {
|
for (let key in json.results_net) {
|
||||||
if (uniqChecker[json.results_net[key]] !== undefined) continue;
|
if (uniqChecker[json.results_net[key]] !== undefined) continue;
|
||||||
|
|
@ -70,6 +75,9 @@ async function SavePhoto(self) {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("result").innerHTML = text;
|
document.getElementById("result").innerHTML = text;
|
||||||
|
document.querySelector(".star-rating").style.display = "block";
|
||||||
|
document.getElementById("rate-stars").style.display = "block";
|
||||||
|
document.getElementById("feedback").textContent = "";
|
||||||
setTimeout(function(){
|
setTimeout(function(){
|
||||||
window.scrollBy({
|
window.scrollBy({
|
||||||
top: 300,
|
top: 300,
|
||||||
|
|
@ -182,3 +190,89 @@ function openModal(imgElement) {
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
document.getElementById('modal').style.display = "none";
|
document.getElementById('modal').style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────── */
|
||||||
|
/* 3️⃣ Скрипт: выбор звезды, отправка POST‑запроса */
|
||||||
|
/* ────────────────────────────────────────────────────── */
|
||||||
|
(() => {
|
||||||
|
const ratingContainer = document.querySelector('.star-rating');
|
||||||
|
const stars = Array.from(ratingContainer.querySelectorAll('.star'));
|
||||||
|
const feedback = document.getElementById('feedback');
|
||||||
|
|
||||||
|
let currentRating = 0; // 0 = пока не выбрано
|
||||||
|
|
||||||
|
// Установить визуальное состояние (цвет, aria‑checked)
|
||||||
|
function setRating(value) {
|
||||||
|
currentRating = value;
|
||||||
|
stars.forEach(star => {
|
||||||
|
const starValue = Number(star.dataset.value);
|
||||||
|
const isSelected = starValue <= value;
|
||||||
|
star.classList.toggle('selected', isSelected);
|
||||||
|
star.setAttribute('aria-checked', isSelected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка POST /vote
|
||||||
|
async function sendVote(value) {
|
||||||
|
// Подготовьте объект‑payload. Добавьте id‑токены, если нужны.
|
||||||
|
const payload = {
|
||||||
|
rate: value,
|
||||||
|
attachment_id: current_attachment_id,
|
||||||
|
beerd_name: current_beerd_name[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/votes/do', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
credentials: 'include' // если ваш API использует cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`Ошибка ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
feedback.textContent = 'Спасибо за оценку!';
|
||||||
|
feedback.style.color = '#080';
|
||||||
|
document.getElementById("rate-stars").style.display = "none";
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
feedback.textContent = 'Произошла ошибка, попробуйте позже.';
|
||||||
|
feedback.style.color = '#a00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчики клика и клавиатуры
|
||||||
|
stars.forEach(star => {
|
||||||
|
// Клик мышкой
|
||||||
|
star.addEventListener('click', () => {
|
||||||
|
const value = Number(star.dataset.value);
|
||||||
|
setRating(value);
|
||||||
|
sendVote(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Навигация клавиатурой: пробел/Enter выбирает звезду
|
||||||
|
star.addEventListener('keydown', e => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const value = Number(star.dataset.value);
|
||||||
|
setRating(value);
|
||||||
|
sendVote(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// При наведении меняем состояние всех звёзд до текущего
|
||||||
|
star.addEventListener('mouseover', () => {
|
||||||
|
setRating(Number(star.dataset.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
star.addEventListener('mouseout', () => {
|
||||||
|
setRating(currentRating); // вернём к сохранённому
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// По умолчанию не показываем выбранную оценку
|
||||||
|
setRating(0);
|
||||||
|
})();
|
||||||
|
|
@ -340,3 +340,33 @@ input[type="text"]:hover {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
/* ────────────────────────────────────────────────────── */
|
||||||
|
/* 1️⃣ Стили для звездочек */
|
||||||
|
/* ────────────────────────────────────────────────────── */
|
||||||
|
.star-rating {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-rating span {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: .2rem; /* отступы между звёздами */
|
||||||
|
font-size: 2rem; /* размер звёзд */
|
||||||
|
user-select: none; /* отключаем выделение текста */
|
||||||
|
}
|
||||||
|
|
||||||
|
.star {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #f0f0f0; /* белый (светло‑серый) фон */
|
||||||
|
transition: color .15s ease;/* плавное изменение цвета */
|
||||||
|
}
|
||||||
|
|
||||||
|
.star.selected,
|
||||||
|
.star:hover,
|
||||||
|
.star:focus {
|
||||||
|
color: #ffd700; /* золотой при наведении/выборе */
|
||||||
|
}
|
||||||
|
|
||||||
|
.star:focus-visible { /* обводка для клавиатурного фокуса */
|
||||||
|
outline: 2px solid #333;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
{% block meta %}{% endblock %}
|
{% block meta %}{% endblock %}
|
||||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||||
<title>{% block title %}{% endblock %}</title>
|
<title>{% block title %}{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css?v=4">
|
<link rel="stylesheet" href="/static/styles.css?v=5">
|
||||||
<!-- Yandex.Metrika counter -->
|
<!-- Yandex.Metrika counter -->
|
||||||
<script type="text/javascript" >
|
<script type="text/javascript" >
|
||||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
|
@ -45,6 +45,6 @@
|
||||||
{% block form %}{% endblock %}
|
{% block form %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</section>
|
</section>
|
||||||
<script src="/static/scripts.js?v=8"></script>
|
<script src="/static/scripts.js?v=6"></script>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,20 @@
|
||||||
<div id="upload-image">
|
<div id="upload-image">
|
||||||
<div id="upload-image-text"></div>
|
<div id="upload-image-text"></div>
|
||||||
<img id="image" style="max-width: 200px;"/>
|
<img id="image" style="max-width: 200px;"/>
|
||||||
|
|
||||||
|
<!-- 2️⃣ Контейнер со звёздочками -->
|
||||||
|
<div class="star-rating" role="radiogroup" aria-label="Оценка от 1 до 5">
|
||||||
|
<h2>Оцените результат</h2>
|
||||||
|
<div id="rate-stars">
|
||||||
|
<!-- 5 звёзд – каждая имеет data-value и tabindex="0" для клавиатуры -->
|
||||||
|
<span class="star" role="radio" aria-checked="false" data-value="1" tabindex="0" aria-label="1 звезда">★</span>
|
||||||
|
<span class="star" role="radio" aria-checked="false" data-value="2" tabindex="0" aria-label="2 звезды">★</span>
|
||||||
|
<span class="star" role="radio" aria-checked="false" data-value="3" tabindex="0" aria-label="3 звезды">★</span>
|
||||||
|
<span class="star" role="radio" aria-checked="false" data-value="4" tabindex="0" aria-label="4 звезды">★</span>
|
||||||
|
<span class="star" role="radio" aria-checked="false" data-value="5" tabindex="0" aria-label="5 звёзд">★</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="feedback" style="margin-top:.5rem;"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="result"></div>
|
<div id="result"></div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue