Skip to content

Commit 947a698

Browse files
committed
feat: фильтры и кэш списка задач
- добавлены фильтры, сортировка и пагинация для GET /tasks - реализовано персональное кэширование списка задач в Redis (TTL из настроек) - добавлен отказоустойчивый режим: ошибки Redis не роняют API - инвалидация кэша переведена на удаление по префиксу пользователя - устранён конфликт enum-поля sort_by=title для Pylance
1 parent d71480c commit 947a698

File tree

7 files changed

+272
-11
lines changed

7 files changed

+272
-11
lines changed

app/cache/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from app.cache.tasks_list import (
2+
build_tasks_list_cache_prefix,
3+
build_tasks_list_cache_key,
4+
delete_cached_tasks_list,
5+
delete_cached_tasks_list_for_user,
6+
get_cached_tasks_list,
7+
set_cached_tasks_list,
8+
)
9+
10+
__all__ = [
11+
"build_tasks_list_cache_prefix",
12+
"build_tasks_list_cache_key",
13+
"get_cached_tasks_list",
14+
"set_cached_tasks_list",
15+
"delete_cached_tasks_list",
16+
"delete_cached_tasks_list_for_user",
17+
]

app/cache/tasks_list.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Mapping
4+
from logging import getLogger
5+
from uuid import UUID
6+
7+
from pydantic import TypeAdapter, ValidationError
8+
from redis.asyncio import Redis
9+
from redis.exceptions import RedisError
10+
11+
from app.schemas.task import TaskRead
12+
13+
logger = getLogger(__name__)
14+
_CACHE_KEY_VERSION = "v1"
15+
_TASKS_LIST_ADAPTER = TypeAdapter(list[TaskRead])
16+
17+
18+
def build_tasks_list_cache_prefix(*, user_id: UUID) -> str:
19+
"""Формирует базовый префикс ключей кэша списка задач пользователя."""
20+
return f"{_CACHE_KEY_VERSION}:tasks:list:user:{user_id}"
21+
22+
23+
def build_tasks_list_cache_key(
24+
*,
25+
user_id: UUID,
26+
filters: Mapping[str, object | None] | None = None,
27+
) -> str:
28+
"""Формирует ключ кэша списка задач пользователя.
29+
30+
Параметр filters заложен заранее, чтобы позже безопасно добавить фильтрацию.
31+
"""
32+
key = build_tasks_list_cache_prefix(user_id=user_id)
33+
if not filters:
34+
return key
35+
36+
normalized = [
37+
f"{name}={value}"
38+
for name, value in sorted(filters.items())
39+
if value is not None
40+
]
41+
if not normalized:
42+
return key
43+
return f"{key}:{'&'.join(normalized)}"
44+
45+
46+
async def get_cached_tasks_list(redis: Redis, cache_key: str) -> list[TaskRead] | None:
47+
"""Читает список задач из Redis.
48+
49+
Любые ошибки Redis не ломают API, а просто отключают кэш на текущем запросе.
50+
"""
51+
try:
52+
raw_payload = await redis.get(cache_key)
53+
except RedisError:
54+
logger.warning(
55+
"Не удалось прочитать кэш списка задач",
56+
extra={"cache_key": cache_key},
57+
)
58+
return None
59+
60+
if raw_payload is None:
61+
return None
62+
63+
try:
64+
return _TASKS_LIST_ADAPTER.validate_json(raw_payload)
65+
except ValidationError:
66+
await delete_cached_tasks_list(redis, cache_key)
67+
return None
68+
69+
70+
async def set_cached_tasks_list(
71+
redis: Redis,
72+
cache_key: str,
73+
tasks: list[TaskRead],
74+
*,
75+
ttl_seconds: int,
76+
) -> None:
77+
"""Сохраняет список задач в Redis."""
78+
try:
79+
payload = _TASKS_LIST_ADAPTER.dump_json(tasks).decode("utf-8")
80+
await redis.set(cache_key, payload, ex=ttl_seconds)
81+
except RedisError:
82+
logger.warning(
83+
"Не удалось записать кэш списка задач",
84+
extra={"cache_key": cache_key},
85+
)
86+
87+
88+
async def delete_cached_tasks_list(redis: Redis, cache_key: str) -> None:
89+
"""Удаляет кэш списка задач по ключу."""
90+
try:
91+
await redis.delete(cache_key)
92+
except RedisError:
93+
logger.warning(
94+
"Не удалось удалить кэш списка задач",
95+
extra={"cache_key": cache_key},
96+
)
97+
98+
99+
async def delete_cached_tasks_list_for_user(redis: Redis, user_id: UUID) -> None:
100+
"""Удаляет все кэш-ключи списка задач для пользователя (включая фильтры)."""
101+
prefix = build_tasks_list_cache_prefix(user_id=user_id)
102+
pattern = f"{prefix}*"
103+
try:
104+
keys = [key async for key in redis.scan_iter(match=pattern)]
105+
if keys:
106+
await redis.delete(*keys)
107+
else:
108+
await redis.delete(prefix)
109+
except RedisError:
110+
logger.warning(
111+
"Не удалось удалить кэш списка задач по префиксу",
112+
extra={"cache_key_pattern": pattern},
113+
)

app/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Settings(BaseSettings):
88
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10
99
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
1010
REDIS_URL: str = "redis://localhost:6379/0"
11+
TASKS_LIST_CACHE_TTL_SECONDS: int = 60
1112
# Argon2 tuning (can be overridden via .env)
1213
ARGON_TIME_COST: int = 3
1314
ARGON_MEMORY_COST: int = 65536 # KiB (≈64 MiB)

app/repositories/task_repo.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,67 @@
33
from collections.abc import Sequence
44
from uuid import UUID
55

6-
from sqlalchemy import select
6+
from sqlalchemy import asc, desc, or_, select
77
from sqlalchemy.ext.asyncio import AsyncSession
88

99
from app.models.task import Task
1010
from app.models.user import User
11+
from app.schemas.task import SortOrder, TaskListFilters, TaskSortBy
1112

1213

1314
async def get_task_by_id(session: AsyncSession, task_id: UUID) -> Task | None:
1415
"""Возвращает задачу по id или None."""
1516
return await session.get(Task, task_id)
1617

1718

18-
async def get_tasks_by_user(session: AsyncSession, user: User) -> Sequence[Task]:
19-
"""Возвращает все задачи пользователя."""
20-
result = await session.execute(select(Task).where(Task.user_id == user.id))
19+
def _apply_ordering(stmt, filters: TaskListFilters):
20+
"""Добавляет сортировку к запросу списка задач по параметрам фильтра."""
21+
sort_columns = {
22+
TaskSortBy.created_at: Task.created_at,
23+
TaskSortBy.due_date: Task.due_date,
24+
TaskSortBy.task_title: Task.title,
25+
}
26+
sort_column = sort_columns[filters.sort_by]
27+
order_expression = (
28+
asc(sort_column) if filters.sort_order == SortOrder.asc else desc(sort_column)
29+
)
30+
return stmt.order_by(order_expression)
31+
32+
33+
async def get_tasks_by_user(
34+
session: AsyncSession,
35+
user: User,
36+
filters: TaskListFilters | None = None,
37+
) -> Sequence[Task]:
38+
"""Возвращает список задач пользователя с фильтрами, сортировкой и пагинацией."""
39+
effective_filters = filters or TaskListFilters()
40+
41+
stmt = select(Task).where(Task.user_id == user.id)
42+
43+
if effective_filters.status is not None:
44+
stmt = stmt.where(Task.status == effective_filters.status)
45+
if effective_filters.priority is not None:
46+
stmt = stmt.where(Task.priority == effective_filters.priority)
47+
if effective_filters.due_after is not None:
48+
stmt = stmt.where(Task.due_date >= effective_filters.due_after)
49+
if effective_filters.due_before is not None:
50+
stmt = stmt.where(Task.due_date <= effective_filters.due_before)
51+
52+
if effective_filters.q is not None:
53+
query = effective_filters.q.strip()
54+
if query:
55+
pattern = f"%{query}%"
56+
stmt = stmt.where(
57+
or_(
58+
Task.title.ilike(pattern),
59+
Task.description.ilike(pattern),
60+
)
61+
)
62+
63+
stmt = _apply_ordering(stmt, effective_filters)
64+
stmt = stmt.limit(effective_filters.limit).offset(effective_filters.offset)
65+
66+
result = await session.execute(stmt)
2167
return result.scalars().all()
2268

2369

app/routes/tasks.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,43 @@
11
from __future__ import annotations
22

3+
from uuid import UUID
34
from typing import Annotated
45

56
from fastapi import APIRouter, Depends, HTTPException, status
7+
from redis.asyncio import Redis
68
from sqlalchemy.ext.asyncio import AsyncSession
79

10+
from app.cache import (
11+
build_tasks_list_cache_key,
12+
delete_cached_tasks_list_for_user,
13+
get_cached_tasks_list,
14+
set_cached_tasks_list,
15+
)
16+
from app.config import settings
817
from app.database import get_db
918
from app.get_or_404 import CurrentUser, TagDep, TaskDep
19+
from app.redis import get_redis
1020
from app.schemas.tag import TaskTagLink
1121
from app.services import tag as tag_service
12-
from app.schemas.task import TaskCreate, TaskDeleted, TaskRead, TaskUpdate
22+
from app.schemas.task import TaskCreate, TaskDeleted, TaskListFilters, TaskRead, TaskUpdate
1323
from app.services import task as task_service
1424
from app.services.task import InvalidDueDate
1525

1626
router = APIRouter(prefix="/tasks", tags=["tasks"])
1727
DbSession = Annotated[AsyncSession, Depends(get_db)]
28+
RedisClient = Annotated[Redis, Depends(get_redis)]
29+
TaskFilters = Annotated[TaskListFilters, Depends()]
1830

1931
_DUE_DATE_ERROR = HTTPException(
2032
status_code=status.HTTP_400_BAD_REQUEST,
2133
detail="Дедлайн не может быть в прошлом",
2234
)
2335

2436

37+
async def _invalidate_user_tasks_cache(redis: Redis, user_id: UUID) -> None:
38+
await delete_cached_tasks_list_for_user(redis, user_id)
39+
40+
2541
@router.post(
2642
"",
2743
status_code=status.HTTP_201_CREATED,
@@ -31,11 +47,13 @@ async def create_task(
3147
payload: TaskCreate,
3248
current_user: CurrentUser,
3349
session: DbSession,
50+
redis: RedisClient,
3451
) -> TaskRead:
3552
try:
3653
task = await task_service.create_task(session, current_user, payload)
3754
except InvalidDueDate:
3855
raise _DUE_DATE_ERROR
56+
await _invalidate_user_tasks_cache(redis, current_user.id)
3957
return TaskRead.model_validate(task)
4058

4159

@@ -45,10 +63,37 @@ async def create_task(
4563
)
4664
async def list_tasks(
4765
current_user: CurrentUser,
66+
filters: TaskFilters,
4867
session: DbSession,
68+
redis: RedisClient,
4969
) -> list[TaskRead]:
50-
tasks = await task_service.get_user_tasks(session, current_user)
51-
return [TaskRead.model_validate(t) for t in tasks]
70+
if (
71+
filters.due_after is not None
72+
and filters.due_before is not None
73+
and filters.due_after > filters.due_before
74+
):
75+
raise HTTPException(
76+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
77+
detail="due_after не может быть позже due_before",
78+
)
79+
80+
cache_key = build_tasks_list_cache_key(
81+
user_id=current_user.id,
82+
filters=filters.model_dump(mode="json", exclude_none=True),
83+
)
84+
cached_tasks = await get_cached_tasks_list(redis, cache_key)
85+
if cached_tasks is not None:
86+
return cached_tasks
87+
88+
tasks = await task_service.get_user_tasks(session, current_user, filters)
89+
response = [TaskRead.model_validate(t) for t in tasks]
90+
await set_cached_tasks_list(
91+
redis,
92+
cache_key,
93+
response,
94+
ttl_seconds=settings.TASKS_LIST_CACHE_TTL_SECONDS,
95+
)
96+
return response
5297

5398

5499
@router.get(
@@ -67,11 +112,13 @@ async def update_task(
67112
payload: TaskUpdate,
68113
task: TaskDep,
69114
session: DbSession,
115+
redis: RedisClient,
70116
) -> TaskRead:
71117
try:
72118
updated = await task_service.update_task(session, task, payload)
73119
except InvalidDueDate:
74120
raise _DUE_DATE_ERROR
121+
await _invalidate_user_tasks_cache(redis, task.user_id)
75122
return TaskRead.model_validate(updated)
76123

77124

@@ -82,8 +129,10 @@ async def update_task(
82129
async def delete_task(
83130
task: TaskDep,
84131
session: DbSession,
132+
redis: RedisClient,
85133
) -> TaskDeleted:
86134
task_id = await task_service.delete_task(session, task)
135+
await _invalidate_user_tasks_cache(redis, task.user_id)
87136
return TaskDeleted(id=task_id)
88137

89138

@@ -96,8 +145,10 @@ async def attach_tag_to_task(
96145
task: TaskDep,
97146
tag: TagDep,
98147
session: DbSession,
148+
redis: RedisClient,
99149
) -> TaskTagLink:
100150
await tag_service.attach_tag_to_task(session, task, tag)
151+
await _invalidate_user_tasks_cache(redis, task.user_id)
101152
return TaskTagLink(task_id=task.id, tag_id=tag.id)
102153

103154

@@ -109,6 +160,8 @@ async def detach_tag_from_task(
109160
task: TaskDep,
110161
tag: TagDep,
111162
session: DbSession,
163+
redis: RedisClient,
112164
) -> TaskTagLink:
113165
await tag_service.detach_tag_from_task(session, task, tag)
166+
await _invalidate_user_tasks_cache(redis, task.user_id)
114167
return TaskTagLink(task_id=task.id, tag_id=tag.id)

app/schemas/task.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from datetime import date, datetime
4+
from enum import Enum
45
from uuid import UUID
56

67
from pydantic import BaseModel, ConfigDict, Field
@@ -41,3 +42,28 @@ class TaskRead(BaseModel):
4142

4243
class TaskDeleted(BaseModel):
4344
id: UUID
45+
46+
47+
class TaskSortBy(str, Enum):
48+
created_at = "created_at"
49+
due_date = "due_date"
50+
task_title = "title"
51+
52+
53+
class SortOrder(str, Enum):
54+
asc = "asc"
55+
desc = "desc"
56+
57+
58+
class TaskListFilters(BaseModel):
59+
model_config = ConfigDict(extra="forbid")
60+
61+
status: StatusEnum | None = None
62+
priority: PriorityEnum | None = None
63+
q: str | None = Field(default=None, min_length=1, max_length=255)
64+
due_before: date | None = None
65+
due_after: date | None = None
66+
sort_by: TaskSortBy = TaskSortBy.created_at
67+
sort_order: SortOrder = SortOrder.desc
68+
limit: int = Field(default=20, ge=1, le=100)
69+
offset: int = Field(default=0, ge=0)

0 commit comments

Comments
 (0)