rating
Gitea Actions Demo / build_and_push (push) Has been cancelled Details

This commit is contained in:
artem 2026-01-14 19:05:24 +03:00
parent e121c6af34
commit e1ecf09470
26 changed files with 411 additions and 52 deletions

View File

@ -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", "")

View File

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

View File

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

View File

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

View File

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

39
server/infra/web/vote.py Normal file
View File

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

View File

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

View File

@ -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"),
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
)

View File

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

View File

@ -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",
)

View File

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

View File

@ -0,0 +1,81 @@
"""
Repository for working with the :class:`~server.modules.rate.repository.models.Vote` model.
Only the writeonly 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 unittesting
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
# instancebased approach keeps typechecking and IDE
# autocompletion in sync with the dataclass.
session.add(vote)
await session.commit()

View File

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

View File

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

View File

@ -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 = пока не выбрано
// Установить визуальное состояние (цвет, ariachecked)
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);
})();

View File

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

View File

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

View File

@ -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 звезда">&#9733;</span>
<span class="star" role="radio" aria-checked="false" data-value="2" tabindex="0" aria-label="2 звезды">&#9733;</span>
<span class="star" role="radio" aria-checked="false" data-value="3" tabindex="0" aria-label="3 звезды">&#9733;</span>
<span class="star" role="radio" aria-checked="false" data-value="4" tabindex="0" aria-label="4 звезды">&#9733;</span>
<span class="star" role="radio" aria-checked="false" data-value="5" tabindex="0" aria-label="5 звёзд">&#9733;</span>
</div>
</div>
<p id="feedback" style="margin-top:.5rem;"></p>
</div> </div>
<div id="result"></div> <div id="result"></div>