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_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", "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 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)
|
||||
|
|
|
|||
|
|
@ -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 = "<h3 class='image-results'>Результаты</h3>";
|
||||
let uniqChecker = {};
|
||||
|
||||
|
|
@ -32,6 +37,7 @@ async function SavePhoto(self) {
|
|||
|
||||
// Обработка основных результатов
|
||||
for (let key in json.results) {
|
||||
current_beerd_name.push(json.results[key]);
|
||||
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>`;
|
||||
} else {
|
||||
|
|
@ -51,7 +57,6 @@ async function SavePhoto(self) {
|
|||
text += "</div>";
|
||||
uniqChecker[json.results[key]] = key;
|
||||
}
|
||||
|
||||
// Обработка дополнительных результатов
|
||||
for (let key in json.results_net) {
|
||||
if (uniqChecker[json.results_net[key]] !== undefined) continue;
|
||||
|
|
@ -70,6 +75,9 @@ async function SavePhoto(self) {
|
|||
}
|
||||
|
||||
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(){
|
||||
window.scrollBy({
|
||||
top: 300,
|
||||
|
|
@ -182,3 +190,89 @@ function openModal(imgElement) {
|
|||
function closeModal() {
|
||||
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;
|
||||
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 %}
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<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 -->
|
||||
<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)};
|
||||
|
|
@ -45,6 +45,6 @@
|
|||
{% block form %}{% endblock %}
|
||||
</body>
|
||||
</section>
|
||||
<script src="/static/scripts.js?v=8"></script>
|
||||
<script src="/static/scripts.js?v=6"></script>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,20 @@
|
|||
<div id="upload-image">
|
||||
<div id="upload-image-text"></div>
|
||||
<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 id="result"></div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue