diff --git a/app/api/api_v1/__init__.py b/app/api/api_v1/__init__.py index 992c4a6..b088f38 100644 --- a/app/api/api_v1/__init__.py +++ b/app/api/api_v1/__init__.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from api.api_v1.projects import router as project_router +from api.api_v1.tags import router as tag_router from .auth import router as auth_router from .upload_file import router as upload_file_router @@ -15,4 +16,5 @@ router.include_router(auth_router) router.include_router(upload_file_router) router.include_router(project_router) +router.include_router(tag_router) router.include_router(user_router) diff --git a/app/api/api_v1/projects/__init__.py b/app/api/api_v1/projects/__init__.py index c3d24ce..df4592b 100644 --- a/app/api/api_v1/projects/__init__.py +++ b/app/api/api_v1/projects/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter from .details_views import router as details_router -from .list_view import router as list_router +from .list_views import router as list_router router = APIRouter( prefix="/projects", diff --git a/app/api/api_v1/projects/list_view.py b/app/api/api_v1/projects/list_views.py similarity index 100% rename from app/api/api_v1/projects/list_view.py rename to app/api/api_v1/projects/list_views.py diff --git a/app/api/api_v1/tags/__init__.py b/app/api/api_v1/tags/__init__.py new file mode 100644 index 0000000..bafcb82 --- /dev/null +++ b/app/api/api_v1/tags/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from .details_views import router as details_views_router +from .list_views import router as list_views_router + +router = APIRouter( + prefix="/tags", + tags=["Tags"], +) + +router.include_router(list_views_router) +router.include_router(details_views_router) diff --git a/app/api/api_v1/tags/details_views.py b/app/api/api_v1/tags/details_views.py new file mode 100644 index 0000000..7db00f7 --- /dev/null +++ b/app/api/api_v1/tags/details_views.py @@ -0,0 +1,36 @@ +from dependencies.annotations import AuthUserByAccessTokenDep, TagServiceDep +from fastapi import APIRouter, status +from schemas.tag import TagResponseList + +router = APIRouter() + + +@router.get( + "/project/{project_id}", + status_code=status.HTTP_200_OK, + response_model=TagResponseList, +) +async def get_project_tags( + project_id: int, + user_id: AuthUserByAccessTokenDep, + tag_service: TagServiceDep, +) -> TagResponseList: + return await tag_service.get_project_tags( + project_id=project_id, + user_id=user_id, + ) + + +@router.delete( + "/{tag_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_project_tag( + tag_id: int, + user_id: AuthUserByAccessTokenDep, + tag_service: TagServiceDep, +) -> None: + return await tag_service.delete_by_id( + tag_id=tag_id, + user_id=user_id, + ) diff --git a/app/api/api_v1/tags/list_views.py b/app/api/api_v1/tags/list_views.py new file mode 100644 index 0000000..a3a69db --- /dev/null +++ b/app/api/api_v1/tags/list_views.py @@ -0,0 +1,21 @@ +from dependencies.annotations import AuthUserByAccessTokenDep, TagServiceDep +from fastapi import APIRouter, status +from schemas.tag import TagCreate, TagResponse + +router = APIRouter() + + +@router.post( + "/create", + status_code=status.HTTP_201_CREATED, + response_model=TagResponse, +) +async def create_tag( + user_id: AuthUserByAccessTokenDep, + create_tag_data: TagCreate, + tag_service: TagServiceDep, +) -> TagResponse: + return await tag_service.create_tag( + user_id=user_id, + create_tag_data=create_tag_data, + ) diff --git a/app/core/constants.py b/app/core/constants.py index 69ebe74..ac5fbca 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -26,6 +26,9 @@ PROJECT_PAGINATION_SIZE_MIN_VALUE = 1 PROJECT_PAGINATION_PAGE_MIN_VALUE = 1 +TAG_MIN_LENGTH = 3 +TAG_MAX_LENGTH = 25 + RENDER_WIDTH_MIN_VALUE = 20 RENDER_WIDTH_MAX_VALUE = 2160 diff --git a/app/core/exceptions/tag.py b/app/core/exceptions/tag.py new file mode 100644 index 0000000..594bd24 --- /dev/null +++ b/app/core/exceptions/tag.py @@ -0,0 +1,23 @@ +from core.exceptions.base import NotFoundError + + +class TagNotFoundError(NotFoundError): + """ + Класс для ошибок, связанных с + ненахождением тэга проекта. + """ + + def __init__(self, detail: str) -> None: + self.detail = detail + + +class TagIdNotFoundError(TagNotFoundError): + """ + Класс для ошибок, связанных с + ненахождением тэга по id. + """ + + def __init__(self, tag_id: int) -> None: + self.tag_id = tag_id + detail = f"Tag with id = {tag_id} not found." + super().__init__(detail) diff --git a/app/core/interfaces/repositories.py b/app/core/interfaces/repositories.py index 4673931..2d0f0ca 100644 --- a/app/core/interfaces/repositories.py +++ b/app/core/interfaces/repositories.py @@ -1,11 +1,12 @@ from abc import ABC, abstractmethod -from infrastructure.database.models import File, Outbox, Project, Render, User +from infrastructure.database.models import File, Outbox, Project, Render, Tag, User from pydantic import EmailStr from schemas.event import EventCreate from schemas.file import FileCreate from schemas.project import ProjectCreate, ProjectPartialUpdate from schemas.render import RenderCreate +from schemas.tag import TagCreate from schemas.user import UserCreate, UserUpdate @@ -182,9 +183,42 @@ async def delete_by_id(self, project_id: int) -> None: """ +class AbstractTagRepository(AbstractRepository): + """ + Интерфейс для работы с тэгами проектов. + """ + + @abstractmethod + async def get_by_id(self, tag_id: int) -> Tag | None: + """ + Метод для получения информации о тэге по id. + """ + + @abstractmethod + async def get_project_tags(self, project_id: int) -> list[Tag] | None: + """ + Метод для получения тэгов проекта. + """ + + @abstractmethod + async def create_tag( + self, + create_tag_data: TagCreate, + ) -> Tag: + """ + Метод для добавления тэга к проекту. + """ + + @abstractmethod + async def delete_by_id(self, tag_id: int) -> None: + """ + Метод для удаления тэга по id. + """ + + class AbstractOutboxRepository(AbstractRepository): """ - Класс для работы с outbox таблицей. + Интерфейс для работы с outbox таблицей. """ @abstractmethod diff --git a/app/dependencies/annotations.py b/app/dependencies/annotations.py index da15738..f39106a 100644 --- a/app/dependencies/annotations.py +++ b/app/dependencies/annotations.py @@ -8,6 +8,7 @@ from services.auth import AuthService from services.file_uploader import FileUploader from services.project import ProjectService +from services.tag import TagService from services.user import UserService from dependencies.auth import ( @@ -21,6 +22,7 @@ get_auth_service, get_input_file_uploader, get_project_service, + get_tag_service, get_user_service, ) @@ -39,6 +41,11 @@ Depends(get_auth_service), ] +TagServiceDep = Annotated[ + TagService, + Depends(get_tag_service), +] + AuthUserByAccessTokenDep = Annotated[ int, Depends(get_auth_user_by_access_token), diff --git a/app/dependencies/services.py b/app/dependencies/services.py index a5e320f..8f9dcc9 100644 --- a/app/dependencies/services.py +++ b/app/dependencies/services.py @@ -10,6 +10,7 @@ from services.auth import AuthService from services.file_uploader import FileUploader from services.project import ProjectService +from services.tag import TagService from services.user import UserService from dependencies.minio import get_minio_client @@ -70,3 +71,15 @@ async def get_project_service( unit_of_work, ) yield project_service + + +async def get_tag_service( + unit_of_work: Annotated[ + UnitOfWork, + Depends(get_unit_of_work), + ], +) -> AsyncGenerator[TagService]: + tag_service = TagService( + unit_of_work, + ) + yield tag_service diff --git a/app/infrastructure/database/models/__init__.py b/app/infrastructure/database/models/__init__.py index 0b233cb..d0957e2 100644 --- a/app/infrastructure/database/models/__init__.py +++ b/app/infrastructure/database/models/__init__.py @@ -3,6 +3,7 @@ "Outbox", "Project", "Render", + "Tag", "User", ) @@ -10,4 +11,5 @@ from .outbox import Outbox from .project import Project from .render import Render +from .tag import Tag from .user import User diff --git a/app/infrastructure/database/models/project.py b/app/infrastructure/database/models/project.py index 9c91e8d..9f84af3 100644 --- a/app/infrastructure/database/models/project.py +++ b/app/infrastructure/database/models/project.py @@ -14,7 +14,7 @@ from infrastructure.database.core import Base if TYPE_CHECKING: - from infrastructure.database.models import File, Render, User + from infrastructure.database.models import File, Render, Tag, User class Project(Base): @@ -59,6 +59,11 @@ class Project(Base): foreign_keys=[render_id], back_populates="project", ) + tags: Mapped[list["Tag"]] = relationship( + "Tag", + foreign_keys="Tag.project_id", + back_populates="project", + ) __table_args__ = ( CheckConstraint( diff --git a/app/infrastructure/database/models/tag.py b/app/infrastructure/database/models/tag.py new file mode 100644 index 0000000..3a1ebb3 --- /dev/null +++ b/app/infrastructure/database/models/tag.py @@ -0,0 +1,34 @@ +from typing import TYPE_CHECKING + +from core.constants import TAG_MAX_LENGTH, TAG_MIN_LENGTH +from sqlalchemy import CheckConstraint, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from infrastructure.database.core import Base +from infrastructure.database.models import Project + +if TYPE_CHECKING: + from infrastructure.database.models import Project + + +class Tag(Base): + __tablename__ = "tags" + + name: Mapped[str] = mapped_column(String(TAG_MAX_LENGTH)) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id")) + + project: Mapped["Project"] = relationship( + "Project", + foreign_keys=[project_id], + back_populates="tags", + ) + + __table_args__ = ( + CheckConstraint( + f""" + LENGTH(name) >= {TAG_MIN_LENGTH} + AND LENGTH(name) <= {TAG_MAX_LENGTH} + """, + name="length_tag_name", + ), + ) diff --git a/app/infrastructure/database/repositories/__init__.py b/app/infrastructure/database/repositories/__init__.py index 6d2a7cb..1e40387 100644 --- a/app/infrastructure/database/repositories/__init__.py +++ b/app/infrastructure/database/repositories/__init__.py @@ -3,10 +3,12 @@ "OutboxRepository", "ProjectRepository", "RenderRepository", + "TagRepository", "UserRepository", ) from .file import FileRepository from .outbox import OutboxRepository from .project import ProjectRepository from .render import RenderRepository +from .tag import TagRepository from .user import UserRepository diff --git a/app/infrastructure/database/repositories/tag.py b/app/infrastructure/database/repositories/tag.py new file mode 100644 index 0000000..dd2ce94 --- /dev/null +++ b/app/infrastructure/database/repositories/tag.py @@ -0,0 +1,33 @@ +from core.interfaces.repositories import AbstractTagRepository +from schemas.tag import TagCreate +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload + +from infrastructure.database.models import Tag + + +class TagRepository(AbstractTagRepository): + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get_by_id(self, tag_id: int) -> Tag | None: + stmt = select(Tag).options(joinedload(Tag.project)).where(Tag.id == tag_id) + result = await self.session.execute(stmt) + return result.scalar() + + async def get_project_tags(self, project_id: int) -> list[Tag] | None: + stmt = select(Tag).where(Tag.project_id == project_id) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def create_tag(self, create_tag_data: TagCreate) -> Tag: + tag = Tag(**create_tag_data.model_dump()) + self.session.add(tag) + await self.session.flush() + return tag + + async def delete_by_id(self, tag_id: int) -> None: + stmt = delete(Tag).where(Tag.id == tag_id) + await self.session.execute(stmt) + await self.session.flush() diff --git a/app/migrations/versions/c237a3caa1ac_add_tags_table.py b/app/migrations/versions/c237a3caa1ac_add_tags_table.py new file mode 100644 index 0000000..4885a60 --- /dev/null +++ b/app/migrations/versions/c237a3caa1ac_add_tags_table.py @@ -0,0 +1,39 @@ +"""add tags table + +Revision ID: c237a3caa1ac +Revises: cde50160235e +Create Date: 2026-08-26 16:00:57.287138 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c237a3caa1ac' +down_revision: Union[str, Sequence[str], None] = 'cde50160235e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tags', + sa.Column('name', sa.String(length=25), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.CheckConstraint('\n LENGTH(name) >= 3\n AND LENGTH(name) <= 25\n ', name=op.f('ck_tags_length_tag_name')), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name=op.f('fk_tags_project_id_projects')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_tags_id')) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('tags') + # ### end Alembic commands ### diff --git a/app/schemas/constraints/tag.py b/app/schemas/constraints/tag.py new file mode 100644 index 0000000..d554472 --- /dev/null +++ b/app/schemas/constraints/tag.py @@ -0,0 +1,12 @@ +from typing import Annotated + +from annotated_types import Len +from core.constants import TAG_MAX_LENGTH, TAG_MIN_LENGTH + +NameConstraint = Annotated[ + str, + Len( + min_length=TAG_MIN_LENGTH, + max_length=TAG_MAX_LENGTH, + ), +] diff --git a/app/schemas/project.py b/app/schemas/project.py index dad560f..5f14717 100644 --- a/app/schemas/project.py +++ b/app/schemas/project.py @@ -19,7 +19,7 @@ class ProjectBase(BaseModel): """ - Базовая схема для создания проекта. + Базовая схема для проекта. """ name: NameConstraint diff --git a/app/schemas/tag.py b/app/schemas/tag.py new file mode 100644 index 0000000..3ac6a65 --- /dev/null +++ b/app/schemas/tag.py @@ -0,0 +1,37 @@ +from typing import ClassVar + +from pydantic import BaseModel, ConfigDict + +from schemas.constraints.tag import NameConstraint + + +class TagBase(BaseModel): + """ + Базовая схема для работы с тэгами. + """ + + name: NameConstraint + project_id: int + model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) + + +class TagCreate(TagBase): + """ + Схема для добавления тэга к проекту. + """ + + +class TagResponse(TagBase): + """ + Схема для вывода информации о тэге. + """ + + id: int + + +class TagResponseList(BaseModel): + """ + Схема для вывода информации о списке тэгов проекта. + """ + + tag_list: list[TagResponse] diff --git a/app/services/tag.py b/app/services/tag.py new file mode 100644 index 0000000..72b8619 --- /dev/null +++ b/app/services/tag.py @@ -0,0 +1,69 @@ +from typing import cast + +from core.constants import ProjectVisibility +from core.exceptions.auth import PermissionDeniedError +from core.exceptions.project import ProjectIdNotFoundError +from core.exceptions.tag import TagIdNotFoundError +from core.interfaces.clients import AbstractUnitOfWorkClient +from infrastructure.database.models import User +from infrastructure.database.repositories import ProjectRepository, TagRepository +from schemas.tag import TagCreate, TagResponse, TagResponseList + + +class TagService: + def __init__(self, unit_of_work: AbstractUnitOfWorkClient) -> None: + self.unit_of_work = unit_of_work + self.project_repository = self.unit_of_work.get_repository(ProjectRepository) + self.tag_repository = self.unit_of_work.get_repository(TagRepository) + + async def get_project_tags(self, project_id: int, user_id: int) -> TagResponseList: + project = await self.project_repository.get_by_id(project_id) + if project is None: + raise ProjectIdNotFoundError(project_id) + + get_owner_coroutine = self.project_repository.get_project_owner( + project_id, + ) + owner = cast(User, await get_owner_coroutine) + if project.visibility == ProjectVisibility.private and user_id != owner.id: + raise PermissionDeniedError + + tag_list = [ + TagResponse.model_validate(tag) + for tag in await self.tag_repository.get_project_tags(project_id) + ] + return TagResponseList(tag_list=tag_list) + + async def create_tag( + self, + create_tag_data: TagCreate, + user_id: int, + ) -> TagResponse: + project_id = create_tag_data.project_id + project = await self.project_repository.get_by_id(project_id) + if project is None: + raise ProjectIdNotFoundError(project_id) + + get_owner_coroutine = self.project_repository.get_project_owner( + project_id, + ) + owner = cast(User, await get_owner_coroutine) + if owner.id != user_id: + raise PermissionDeniedError + + tag = await self.tag_repository.create_tag(create_tag_data) + return TagResponse.model_validate(tag) + + async def delete_by_id(self, tag_id: int, user_id: int) -> None: + tag = await self.tag_repository.get_by_id(tag_id) + if tag is None: + raise TagIdNotFoundError(tag_id) + + get_owner_coroutine = self.project_repository.get_project_owner( + tag.project.id, + ) + owner = cast(User, await get_owner_coroutine) + if owner.id != user_id: + raise PermissionDeniedError + + await self.tag_repository.delete_by_id(tag_id) diff --git a/frontend/script.js b/frontend/script.js index 37de06e..c492b70 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -195,6 +195,151 @@ async function updateCurrentUserProfile(surname, name, username, email) { }); } +// ===== ТЭГИ ===== +async function getProjectTags(projectId) { + return await apiRequest(`/tags/project/${projectId}`); +} + +async function createTag(name, projectId) { + return await apiRequest('/tags/create', { + method: 'POST', + body: JSON.stringify({ + name: name, + project_id: projectId + }) + }); +} + +// ===== УДАЛЕНИЕ ТЭГА ===== +async function deleteTag(tagId) { + const token = getAccessToken(); + const response = await fetch(`${API_BASE}/tags/${tagId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + let errorMessage = 'Ошибка удаления тега'; + try { + const error = await response.json(); + errorMessage = error.detail || errorMessage; + } catch (e) { + // Если ответ не JSON + const text = await response.text(); + if (text) errorMessage = text; + } + throw new Error(errorMessage); + } + + // DELETE возвращает 204 No Content - ничего не возвращаем + return null; +} + +// ===== ЗАГРУЗКА ТЭГОВ ПРОЕКТА ===== +async function loadProjectTags(projectId, isOwner) { + const container = document.getElementById('tagsList'); + if (!container) return; + + try { + const data = await getProjectTags(projectId); + console.log('🏷️ Тэги проекта:', data); + + const tags = data.tag_list || []; + + if (tags.length === 0) { + container.innerHTML = ` +
+ `; + return; + } + + container.innerHTML = tags.map(tag => ` +${project.description || 'Нет описания'}
${project.status === 'rendering' ? '⏳ Рендерится...' : 'Рендер не готов'}
- ${project.status === 'rendering' ? 'Обновите страницу через несколько минут' : ''} -${project.status === 'rendering' ? '⏳ Рендерится' : 'Рендер не готов'}
+ ${project.status === 'rendering' ? 'Обновите страницу через несколько минут' : ''} +