Skip to content

Commit 355f5fb

Browse files
committed
feat: добавить теги и связи many-to-many с задачами
Что сделано: - добавлена модель тега и слой данных для CRUD - реализованы схемы и сервис для управления тегами - добавлены роуты /tags и привязка тега к задаче - вынесены общие dependency-хелперы get_or_404 для задач и тегов
1 parent 05013ab commit 355f5fb

7 files changed

Lines changed: 483 additions & 0 deletions

File tree

app/get_or_404.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from __future__ import annotations
2+
3+
from typing import Annotated
4+
from uuid import UUID
5+
6+
from fastapi import Depends, HTTPException, status
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from app.database import get_db
10+
from app.models.tag import Tag
11+
from app.models.task import Task
12+
from app.models.user import User
13+
from app.repositories import tag_repo, task_repo
14+
from app.security.dependences import get_current_user
15+
16+
17+
async def get_task_or_404(
18+
task_id: UUID,
19+
session: AsyncSession = Depends(get_db),
20+
current_user: User = Depends(get_current_user),
21+
) -> Task:
22+
"""Возвращает задачу по id. 404 если не найдена, 403 если чужая."""
23+
task = await task_repo.get_task_by_id(session, task_id)
24+
if not task:
25+
raise HTTPException(
26+
status_code=status.HTTP_404_NOT_FOUND, detail="Задача не найдена"
27+
)
28+
if task.user_id != current_user.id:
29+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Нет доступа")
30+
return task
31+
32+
33+
async def get_tag_or_404(
34+
tag_id: UUID,
35+
session: AsyncSession = Depends(get_db),
36+
) -> Tag:
37+
"""Возвращает тег по id или 404 если не найден."""
38+
tag = await tag_repo.get_tag_by_id(session, tag_id)
39+
if not tag:
40+
raise HTTPException(
41+
status_code=status.HTTP_404_NOT_FOUND, detail="Тег не найден"
42+
)
43+
return tag
44+
45+
46+
TagDep = Annotated[Tag, Depends(get_tag_or_404)]
47+
CurrentUser = Annotated[User, Depends(get_current_user)]
48+
TaskDep = Annotated[Task, Depends(get_task_or_404)]

app/models/tag.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
from typing import TYPE_CHECKING
5+
6+
from sqlalchemy import Column, ForeignKey, String, Table
7+
from sqlalchemy.dialects.postgresql import UUID
8+
from sqlalchemy.orm import Mapped, mapped_column, relationship
9+
10+
from app.database import Base
11+
from app.models.mixins import CreatedAtMixin
12+
13+
if TYPE_CHECKING:
14+
from app.models.task import Task
15+
16+
# Связь Task <-> Tag (многие ко многим).
17+
# task_tags определён здесь; Task импортирует его когда понадобится relationship.
18+
task_tags = Table(
19+
"task_tags",
20+
Base.metadata,
21+
Column(
22+
"task_id",
23+
UUID(as_uuid=True),
24+
ForeignKey("tasks.id", ondelete="CASCADE"),
25+
primary_key=True,
26+
),
27+
Column(
28+
"tag_id",
29+
UUID(as_uuid=True),
30+
ForeignKey("tags.id", ondelete="CASCADE"),
31+
primary_key=True,
32+
),
33+
)
34+
35+
36+
class Tag(CreatedAtMixin, Base):
37+
__tablename__ = "tags"
38+
39+
id: Mapped[uuid.UUID] = mapped_column(
40+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
41+
)
42+
# Теги глобальные, имя уникально в системе.
43+
name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
44+
# Формат #RRGGBB, валидация на уровне схемы Pydantic.
45+
color: Mapped[str] = mapped_column(String(7), nullable=False)
46+
tasks: Mapped[list[Task]] = relationship(
47+
secondary=task_tags,
48+
back_populates="tags",
49+
)

app/repositories/tag_repo.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Sequence
4+
from typing import Any
5+
from uuid import UUID
6+
7+
from sqlalchemy import delete, insert, select
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from app.models.tag import Tag, task_tags
11+
from app.models.task import Task
12+
13+
14+
async def get_tag_by_id(session: AsyncSession, tag_id: UUID) -> Tag | None:
15+
"""Возвращает тег по id или None."""
16+
return await session.get(Tag, tag_id)
17+
18+
19+
async def get_tag_by_name(session: AsyncSession, name: str) -> Tag | None:
20+
"""Возвращает тег по имени или None."""
21+
result = await session.execute(select(Tag).where(Tag.name == name))
22+
return result.scalar_one_or_none()
23+
24+
25+
async def list_tags(session: AsyncSession) -> Sequence[Tag]:
26+
"""Возвращает список тегов по имени."""
27+
result = await session.execute(select(Tag).order_by(Tag.name.asc()))
28+
return result.scalars().all()
29+
30+
31+
async def create_tag(session: AsyncSession, *, name: str, color: str) -> Tag:
32+
"""Создаёт и сохраняет тег."""
33+
tag = Tag(name=name, color=color)
34+
session.add(tag)
35+
await session.commit()
36+
await session.refresh(tag)
37+
return tag
38+
39+
40+
async def update_tag(session: AsyncSession, tag: Tag, **data: Any) -> Tag:
41+
"""Обновляет переданные поля тега."""
42+
for field, value in data.items():
43+
setattr(tag, field, value)
44+
await session.commit()
45+
await session.refresh(tag)
46+
return tag
47+
48+
49+
async def delete_tag(session: AsyncSession, tag: Tag) -> UUID:
50+
"""Удаляет тег и возвращает его id."""
51+
tag_id = tag.id
52+
await session.delete(tag)
53+
await session.commit()
54+
return tag_id
55+
56+
57+
async def attach_tag_to_task(session: AsyncSession, task: Task, tag: Tag) -> None:
58+
"""Привязывает тег к задаче (идемпотентно)."""
59+
exists_result = await session.execute(
60+
select(task_tags.c.task_id).where(
61+
task_tags.c.task_id == task.id,
62+
task_tags.c.tag_id == tag.id,
63+
)
64+
)
65+
if exists_result.first():
66+
return
67+
68+
await session.execute(insert(task_tags).values(task_id=task.id, tag_id=tag.id))
69+
await session.commit()
70+
71+
72+
async def detach_tag_from_task(session: AsyncSession, task: Task, tag: Tag) -> None:
73+
"""Отвязывает тег от задачи (идемпотентно)."""
74+
await session.execute(
75+
delete(task_tags).where(
76+
task_tags.c.task_id == task.id,
77+
task_tags.c.tag_id == tag.id,
78+
)
79+
)
80+
await session.commit()

app/routes/tags.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from __future__ import annotations
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
6+
from app.database import get_db
7+
from app.get_or_404 import CurrentUser, TagDep
8+
from app.schemas.tag import TagCreate, TagDeleted, TagRead, TagUpdate
9+
from app.services import tag as tag_service
10+
from app.services.tag import TagAlreadyExists
11+
12+
router = APIRouter(prefix="/tags", tags=["tags"])
13+
14+
_TAG_EXISTS_ERROR = HTTPException(
15+
status_code=status.HTTP_409_CONFLICT,
16+
detail="Тег с таким именем уже существует",
17+
)
18+
19+
20+
@router.post(
21+
"",
22+
response_model=TagRead,
23+
status_code=status.HTTP_201_CREATED,
24+
summary="Создать тег",
25+
)
26+
async def create_tag(
27+
payload: TagCreate,
28+
_: CurrentUser,
29+
session: AsyncSession = Depends(get_db),
30+
) -> TagRead:
31+
try:
32+
created = await tag_service.create_tag(session, payload)
33+
except TagAlreadyExists:
34+
raise _TAG_EXISTS_ERROR
35+
return TagRead.model_validate(created)
36+
37+
38+
@router.get(
39+
"",
40+
response_model=list[TagRead],
41+
summary="Список тегов",
42+
)
43+
async def list_tags(
44+
_: CurrentUser,
45+
session: AsyncSession = Depends(get_db),
46+
) -> list[TagRead]:
47+
tags = await tag_service.list_tags(session)
48+
return [TagRead.model_validate(tag) for tag in tags]
49+
50+
51+
@router.get(
52+
"/{tag_id}",
53+
response_model=TagRead,
54+
summary="Получить тег по id",
55+
)
56+
async def get_tag(
57+
_: CurrentUser,
58+
tag: TagDep,
59+
) -> TagRead:
60+
return TagRead.model_validate(tag)
61+
62+
63+
@router.patch(
64+
"/{tag_id}",
65+
response_model=TagRead,
66+
summary="Обновить тег",
67+
)
68+
async def update_tag(
69+
payload: TagUpdate,
70+
_: CurrentUser,
71+
tag: TagDep,
72+
session: AsyncSession = Depends(get_db),
73+
) -> TagRead:
74+
try:
75+
updated = await tag_service.update_tag(session, tag, payload)
76+
except TagAlreadyExists:
77+
raise _TAG_EXISTS_ERROR
78+
return TagRead.model_validate(updated)
79+
80+
81+
@router.delete(
82+
"/{tag_id}",
83+
response_model=TagDeleted,
84+
summary="Удалить тег",
85+
)
86+
async def delete_tag(
87+
_: CurrentUser,
88+
tag: TagDep,
89+
session: AsyncSession = Depends(get_db),
90+
) -> TagDeleted:
91+
tag_id = await tag_service.delete_tag(session, tag)
92+
return TagDeleted(id=tag_id)

app/routes/tasks.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
6+
from app.database import get_db
7+
from app.get_or_404 import CurrentUser, TagDep, TaskDep
8+
from app.schemas.tag import TaskTagLink
9+
from app.services import tag as tag_service
10+
from app.schemas.task import TaskCreate, TaskDeleted, TaskRead, TaskUpdate
11+
from app.services import task as task_service
12+
from app.services.task import InvalidDueDate
13+
14+
router = APIRouter(prefix="/tasks", tags=["tasks"])
15+
16+
_DUE_DATE_ERROR = HTTPException(
17+
status_code=status.HTTP_400_BAD_REQUEST,
18+
detail="Дедлайн не может быть в прошлом",
19+
)
20+
21+
22+
@router.post(
23+
"",
24+
response_model=TaskRead,
25+
status_code=status.HTTP_201_CREATED,
26+
summary="Создать задачу",
27+
)
28+
async def create_task(
29+
payload: TaskCreate,
30+
current_user: CurrentUser,
31+
session: AsyncSession = Depends(get_db),
32+
) -> TaskRead:
33+
try:
34+
task = await task_service.create_task(session, current_user, payload)
35+
except InvalidDueDate:
36+
raise _DUE_DATE_ERROR
37+
return TaskRead.model_validate(task)
38+
39+
40+
@router.get(
41+
"",
42+
response_model=list[TaskRead],
43+
summary="Список задач текущего пользователя",
44+
)
45+
async def list_tasks(
46+
current_user: CurrentUser,
47+
session: AsyncSession = Depends(get_db),
48+
) -> list[TaskRead]:
49+
tasks = await task_service.get_user_tasks(session, current_user)
50+
return [TaskRead.model_validate(t) for t in tasks]
51+
52+
53+
@router.get(
54+
"/{task_id}",
55+
response_model=TaskRead,
56+
summary="Получить задачу по id",
57+
)
58+
async def get_task(task: TaskDep) -> TaskRead:
59+
return TaskRead.model_validate(task)
60+
61+
62+
@router.patch(
63+
"/{task_id}",
64+
response_model=TaskRead,
65+
summary="Обновить задачу",
66+
)
67+
async def update_task(
68+
payload: TaskUpdate,
69+
task: TaskDep,
70+
session: AsyncSession = Depends(get_db),
71+
) -> TaskRead:
72+
try:
73+
updated = await task_service.update_task(session, task, payload)
74+
except InvalidDueDate:
75+
raise _DUE_DATE_ERROR
76+
return TaskRead.model_validate(updated)
77+
78+
79+
@router.delete(
80+
"/{task_id}",
81+
response_model=TaskDeleted,
82+
summary="Удалить задачу",
83+
)
84+
async def delete_task(
85+
task: TaskDep,
86+
session: AsyncSession = Depends(get_db),
87+
) -> TaskDeleted:
88+
task_id = await task_service.delete_task(session, task)
89+
return TaskDeleted(id=task_id)
90+
91+
92+
@router.post(
93+
"/{task_id}/tags/{tag_id}",
94+
response_model=TaskTagLink,
95+
status_code=status.HTTP_201_CREATED,
96+
summary="Привязать тег к задаче",
97+
)
98+
async def attach_tag_to_task(
99+
task: TaskDep,
100+
tag: TagDep,
101+
session: AsyncSession = Depends(get_db),
102+
) -> TaskTagLink:
103+
await tag_service.attach_tag_to_task(session, task, tag)
104+
return TaskTagLink(task_id=task.id, tag_id=tag.id)
105+
106+
107+
@router.delete(
108+
"/{task_id}/tags/{tag_id}",
109+
response_model=TaskTagLink,
110+
summary="Отвязать тег от задачи",
111+
)
112+
async def detach_tag_from_task(
113+
task: TaskDep,
114+
tag: TagDep,
115+
session: AsyncSession = Depends(get_db),
116+
) -> TaskTagLink:
117+
await tag_service.detach_tag_from_task(session, task, tag)
118+
return TaskTagLink(task_id=task.id, tag_id=tag.id)

0 commit comments

Comments
 (0)