Skip to content

Commit 05fdc30

Browse files
committed
Add favorite characters
1 parent c06964b commit 05fdc30

File tree

11 files changed

+413
-4
lines changed

11 files changed

+413
-4
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Add favorite character model
2+
3+
Revision ID: 8e79c0472843
4+
Revises: ca664de1bf44
5+
Create Date: 2026-01-01 22:42:59.969711
6+
7+
"""
8+
9+
from collections.abc import Sequence
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
revision: str = "8e79c0472843"
15+
down_revision: str | None = "ca664de1bf44"
16+
branch_labels: str | Sequence[str] | None = None
17+
depends_on: str | Sequence[str] | None = None
18+
19+
20+
def upgrade() -> None:
21+
op.create_table(
22+
"favorite_characters",
23+
sa.Column(
24+
"user_uuid",
25+
sa.UUID(),
26+
nullable=False,
27+
),
28+
sa.Column(
29+
"character_uuid",
30+
sa.UUID(),
31+
nullable=False,
32+
),
33+
sa.Column(
34+
"id",
35+
sa.Integer(),
36+
nullable=False,
37+
),
38+
sa.Column(
39+
"created_at",
40+
sa.DateTime(
41+
timezone=True,
42+
),
43+
server_default=sa.text(
44+
"now()",
45+
),
46+
nullable=False,
47+
),
48+
sa.Column(
49+
"uuid",
50+
sa.UUID(),
51+
nullable=False,
52+
),
53+
sa.ForeignKeyConstraint(
54+
[
55+
"character_uuid",
56+
],
57+
[
58+
"characters.uuid",
59+
],
60+
),
61+
sa.ForeignKeyConstraint(
62+
[
63+
"user_uuid",
64+
],
65+
[
66+
"users.uuid",
67+
],
68+
),
69+
sa.PrimaryKeyConstraint("id"),
70+
sa.UniqueConstraint(
71+
"user_uuid",
72+
"character_uuid",
73+
name="uniq_favorite_user_character",
74+
),
75+
sa.UniqueConstraint("uuid"),
76+
)
77+
78+
79+
def downgrade() -> None:
80+
op.drop_table("favorite_characters")

futuramaapi/repositories/models.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from datetime import UTC, datetime, timedelta
23
from enum import Enum
34
from functools import partial
@@ -18,6 +19,7 @@
1819
Result,
1920
Select,
2021
SmallInteger,
22+
UniqueConstraint,
2123
Update,
2224
func,
2325
select,
@@ -169,6 +171,9 @@ class CharacterGender(Enum):
169171
secondary="episode_character_association",
170172
back_populates="characters",
171173
)
174+
favorite_characters: Mapped[list["FavoriteCharacterModel"]] = relationship(
175+
back_populates="character",
176+
)
172177

173178
@classmethod
174179
def get_cond_list(cls, **kwargs) -> list[BinaryExpression]:
@@ -249,6 +254,9 @@ class UserModel(Base):
249254
active_sessions: Mapped[list["AuthSessionModel"]] = relationship(
250255
back_populates="user",
251256
)
257+
favorite_characters: Mapped[list["FavoriteCharacterModel"]] = relationship(
258+
back_populates="user",
259+
)
252260

253261
@staticmethod
254262
def get_select_in_load() -> list[Load]:
@@ -498,3 +506,31 @@ class SystemMessage(Base):
498506
nullable=False,
499507
unique=True,
500508
)
509+
510+
511+
class FavoriteCharacterModel(Base):
512+
__tablename__ = "favorite_characters"
513+
514+
user_uuid: Mapped[uuid.UUID] = mapped_column(
515+
ForeignKey("users.uuid"),
516+
nullable=False,
517+
)
518+
character_uuid: Mapped[uuid.UUID] = mapped_column(
519+
ForeignKey("characters.uuid"),
520+
nullable=False,
521+
)
522+
523+
user: Mapped["UserModel"] = relationship(
524+
back_populates="favorite_characters",
525+
)
526+
character: Mapped["CharacterModel"] = relationship(
527+
back_populates="favorite_characters",
528+
)
529+
530+
__table_args__ = (
531+
UniqueConstraint(
532+
"user_uuid",
533+
"character_uuid",
534+
name="uniq_favorite_user_character",
535+
),
536+
)

futuramaapi/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .rest.characters import router as characters_router
66
from .rest.crypto import router as crypto_router
77
from .rest.episodes import router as episodes_router
8+
from .rest.favorites import router as favorites_router
89
from .rest.notifications import router as notification_router
910
from .rest.randoms import router as randoms_router
1011
from .rest.root import router as root_router
@@ -29,3 +30,4 @@
2930
api_router.include_router(seasons_router)
3031
api_router.include_router(tokens_router)
3132
api_router.include_router(users_router)
33+
api_router.include_router(favorites_router)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .api import router
2+
3+
__all__ = [
4+
"router",
5+
]
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Depends, status
4+
from fastapi_pagination import Page
5+
6+
from futuramaapi.routers.exceptions import NotFoundResponse
7+
from futuramaapi.routers.rest.users.dependencies import _oauth2_scheme
8+
from futuramaapi.routers.services.favorites.create_favorite_character import CreateFavoriteCharacterService
9+
from futuramaapi.routers.services.favorites.delete_favorite_character import DeleteFavoriteCharacterService
10+
from futuramaapi.routers.services.favorites.list_favorite_characters import (
11+
ListFavoriteCharacters,
12+
ListFavoriteCharactersResponse,
13+
)
14+
15+
router = APIRouter(
16+
prefix="/favorites/characters",
17+
tags=[
18+
"Favorite Characters",
19+
],
20+
)
21+
22+
23+
@router.post(
24+
"/{character_id}",
25+
status_code=status.HTTP_204_NO_CONTENT,
26+
responses={
27+
status.HTTP_404_NOT_FOUND: {
28+
"model": NotFoundResponse,
29+
},
30+
status.HTTP_409_CONFLICT: {
31+
"example": "Character is already in favorites",
32+
},
33+
},
34+
name="create_favorite_character",
35+
)
36+
async def create_favorite_character(
37+
character_id: int,
38+
token: Annotated[str, Depends(_oauth2_scheme)],
39+
) -> None:
40+
"""
41+
Add character to favorites.
42+
43+
Adds the specified character to the authenticated user's favorites list.
44+
45+
If the character is already present in favorites, the request will fail
46+
with a conflict error.
47+
48+
This endpoint requires authentication.
49+
"""
50+
service: CreateFavoriteCharacterService = CreateFavoriteCharacterService(
51+
token=token,
52+
character_id=character_id,
53+
)
54+
await service()
55+
56+
57+
@router.get(
58+
"",
59+
status_code=status.HTTP_200_OK,
60+
response_model=Page[ListFavoriteCharactersResponse],
61+
name="favorite_characters",
62+
)
63+
async def get_favorite_characters(
64+
token: Annotated[str, Depends(_oauth2_scheme)],
65+
) -> Page[ListFavoriteCharactersResponse]:
66+
"""
67+
Retrieve favorite characters.
68+
69+
Returns a paginated list of characters added to the current user's favorites.
70+
71+
You can use standard pagination parameters to control the size and order of the response.
72+
This endpoint requires authentication and returns only the favorites of the authorized user.
73+
74+
Check the query parameters section for available pagination options.
75+
"""
76+
service: ListFavoriteCharacters = ListFavoriteCharacters(token=token)
77+
return await service()
78+
79+
80+
@router.delete(
81+
"/{character_id}",
82+
status_code=status.HTTP_204_NO_CONTENT,
83+
responses={
84+
status.HTTP_404_NOT_FOUND: {
85+
"model": NotFoundResponse,
86+
},
87+
status.HTTP_409_CONFLICT: {
88+
"example": "Character is already in favorites",
89+
},
90+
},
91+
name="create_favorite_character",
92+
)
93+
async def delete_favorite_character(
94+
character_id: int,
95+
token: Annotated[str, Depends(_oauth2_scheme)],
96+
) -> None:
97+
"""
98+
Remove character from favorites.
99+
100+
Removes the specified character from the authenticated user's favorites list.
101+
102+
If the character does not exist or is not present in the user's favorites,
103+
the request will fail with a not found error.
104+
105+
This endpoint requires authentication and performs a destructive operation
106+
without returning a response body.
107+
"""
108+
service: DeleteFavoriteCharacterService = DeleteFavoriteCharacterService(
109+
token=token,
110+
character_id=character_id,
111+
)
112+
await service()
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
from ._base import BaseService
1+
from ._base import (
2+
BaseService,
3+
BaseSessionService,
4+
BaseUserAuthenticatedService,
5+
)
26

37
__all__ = [
48
"BaseService",
9+
"BaseSessionService",
10+
"BaseUserAuthenticatedService",
511
]
Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,89 @@
11
from abc import ABC, abstractmethod
22
from collections.abc import Sequence
3-
from typing import Any
3+
from typing import Any, Generic, TypeVar
4+
5+
from fastapi import HTTPException, status
6+
from fastapi_pagination import Page
7+
from sqlalchemy import Select, select
8+
from sqlalchemy.exc import NoResultFound
9+
from sqlalchemy.ext.asyncio import AsyncSession
410

511
from futuramaapi.helpers.pydantic import BaseModel
12+
from futuramaapi.mixins.pydantic import DecodedTokenError
13+
from futuramaapi.repositories.models import UserModel
14+
from futuramaapi.repositories.session import session_manager
15+
from futuramaapi.routers.rest.tokens.schemas import DecodedUserToken
16+
17+
TResponse = TypeVar(
18+
"TResponse",
19+
bound=BaseModel | Sequence[BaseModel] | Page[BaseModel] | None,
20+
)
621

722

8-
class BaseService(BaseModel, ABC):
23+
class BaseService(BaseModel, ABC, Generic[TResponse]):
924
context: dict[str, Any] | None = None
1025

1126
@abstractmethod
12-
async def __call__(self, *args, **kwargs) -> BaseModel | Sequence[BaseModel] | None:
27+
async def __call__(self, *args, **kwargs) -> TResponse:
1328
pass
29+
30+
31+
class BaseSessionService(BaseService[TResponse], ABC, Generic[TResponse]):
32+
def __init__(self, /, **data: Any) -> None:
33+
super().__init__(**data)
34+
35+
self._session: AsyncSession | None = None
36+
37+
@property
38+
def session(self) -> AsyncSession:
39+
if self._session is None:
40+
raise RuntimeError("Session is not initialized")
41+
42+
return self._session
43+
44+
@abstractmethod
45+
async def process(self, *args, **kwargs) -> TResponse: ...
46+
47+
async def __call__(self, *args, **kwargs) -> TResponse:
48+
async with session_manager.session() as session:
49+
self._session = session
50+
51+
return await self.process(*args, **kwargs)
52+
53+
54+
class BaseUserAuthenticatedService(BaseSessionService[TResponse], ABC, Generic[TResponse]):
55+
token: str
56+
57+
def __init__(self, /, **data: Any) -> None:
58+
super().__init__(**data)
59+
60+
self._user: UserModel | None = None
61+
62+
@property
63+
def user(self) -> UserModel:
64+
if self._user is None:
65+
raise RuntimeError("User is not initialized")
66+
67+
return self._user
68+
69+
@property
70+
def __get_user_statement(self) -> Select[tuple[UserModel]]:
71+
try:
72+
decoded_token: DecodedUserToken = DecodedUserToken.decode(self.token, allowed_type="access")
73+
except DecodedTokenError:
74+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from None
75+
76+
return select(UserModel).where(UserModel.id == decoded_token.user.id)
77+
78+
async def __set_user(self) -> None:
79+
try:
80+
self._user = (await self.session.execute(self.__get_user_statement)).scalars().one()
81+
except NoResultFound:
82+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from None
83+
84+
async def __call__(self, *args, **kwargs) -> TResponse:
85+
async with session_manager.session() as session:
86+
self._session = session
87+
await self.__set_user()
88+
89+
return await self.process(*args, **kwargs)

futuramaapi/routers/services/favorites/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)