Skip to content

Commit cbc824a

Browse files
committed
feat: add quote routes and service
1 parent be2f953 commit cbc824a

File tree

20 files changed

+438
-54
lines changed

20 files changed

+438
-54
lines changed

src/api/v1/__init__.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
from fastapi import APIRouter
22

3-
from src.api.v1 import auth, authors, collections, media, otp, users
3+
from src.api.v1 import auth, authors, collections, media, otp, quotes, users
44

55
v1_router = APIRouter(prefix="/v1")
66

77
v1_router.include_router(auth.router)
88
v1_router.include_router(authors.router)
99
v1_router.include_router(collections.router)
10+
v1_router.include_router(media.router)
1011
v1_router.include_router(otp.router)
12+
v1_router.include_router(quotes.router)
1113
v1_router.include_router(users.router)
12-
v1_router.include_router(media.router)
1314

1415
__all__ = [
1516
"v1_router",

src/api/v1/auth/__init__.py

-3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
from src.api.v1.auth.deps import OAuth2BearerDepends, OAuth2PasswordRequestFormDepends, OptionalOAuth2BearerDepends
22
from src.api.v1.auth.routes import router
3-
from src.api.v1.auth.schemas import JWT, AccessTokenResponse
43

54
__all__ = [
65
"router",
76
"OAuth2BearerDepends",
87
"OptionalOAuth2BearerDepends",
98
"OAuth2PasswordRequestFormDepends",
10-
"AccessTokenResponse",
11-
"JWT",
129
]

src/api/v1/authors/routes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
@router.get("/{name}", response_model=AuthorResponse)
1313
def get_author(name: str, service: AuthorServiceDepends):
14-
author = service.get_author(name)
14+
author = service.get_author_by_name(name)
1515

1616
if not author:
1717
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No author found with the name '%s'." % (name,)))

src/api/v1/authors/service.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
from src.api.params import SearchParams
44
from src.api.v1.authors.models import Author
5-
from src.db.deps import Session
5+
from src.db.deps import SessionDepends
66

77

88
class AuthorService:
9-
def __init__(self, session: Session) -> None:
9+
def __init__(self, session: SessionDepends) -> None:
1010
self.session = session
1111

12-
def get_author(self, name: str) -> Author | None:
12+
def get_author_by_name(self, name: str) -> Author | None:
1313
return self.session.query(Author).filter(Author.name == name).first()
1414

15+
def get_author_by_id(self, author_id: int) -> Author | None:
16+
return self.session.get(Author, author_id)
17+
1518
def get_authors(self, search_params: SearchParams) -> list[Author]:
1619
query = self.session.query(Author)
1720

src/api/v1/collections/routes.py

+70-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from src.api.v1.collections.deps import CollectionServiceDepends
55
from src.api.v1.collections.models import Collection
66
from src.api.v1.collections.schemas import CollectionCreateRequest, CollectionResponse, CollectionUpdateRequest
7+
from src.api.v1.quotes.deps import QuoteServiceDepends
8+
from src.api.v1.quotes.schemas import QuoteCollectionsResponse, QuoteResponse
79
from src.api.v1.users.me.deps import CurrentUser, CurrentUserOrNone
810
from src.i18n import gettext as _
911

@@ -70,7 +72,71 @@ def delete_collection(id: int, current_user: CurrentUser, service: CollectionSer
7072
service.delete_collection(collection)
7173

7274

73-
# TODO:
74-
# @router.get("/{collection_id}/quotes")
75-
# @router.post("/{collection_id}/quotes")
76-
# @router.delete("/{collection_id}/quotes/{quote_id}")
75+
@router.get("/{collection_id}/quotes", response_model=list[QuoteResponse])
76+
def get_collection_quotes(
77+
collection_id: int,
78+
search_params: SearchParamsDepends,
79+
current_user: CurrentUserOrNone,
80+
collection_service: CollectionServiceDepends,
81+
quote_service: QuoteServiceDepends,
82+
):
83+
collection = collection_service.get_collection(collection_id)
84+
85+
if not collection:
86+
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (collection_id,)))
87+
88+
is_private = collection.visibility == Collection.Visibility.PRIVATE
89+
has_access = current_user and current_user.id == collection.created_by_user_id
90+
91+
if is_private and not has_access:
92+
raise HTTPException(status.HTTP_403_FORBIDDEN, _("Access denied to this private collection."))
93+
94+
return quote_service.get_collection_quotes(collection_id, search_params)
95+
96+
97+
@router.post("/{collection_id}/quotes", response_model=QuoteCollectionsResponse, status_code=status.HTTP_201_CREATED)
98+
def add_quote_to_collection(
99+
collection_id: int,
100+
quote_id: int,
101+
current_user: CurrentUser,
102+
collection_service: CollectionServiceDepends,
103+
quote_service: QuoteServiceDepends,
104+
):
105+
quote = quote_service.get_quote_by_id(quote_id)
106+
107+
if not quote:
108+
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No quote found with the ID %s." % (quote_id,)))
109+
110+
collection = collection_service.get_collection(collection_id)
111+
112+
if not collection:
113+
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (collection_id,)))
114+
115+
if current_user.id != collection.created_by_user_id:
116+
raise HTTPException(status.HTTP_403_FORBIDDEN, _("Access denied to this private collection."))
117+
118+
return collection_service.add_quote_to_collection(quote, collection)
119+
120+
121+
@router.delete("/{collection_id}/quotes/{quote_id}", status_code=status.HTTP_204_NO_CONTENT)
122+
def remove_quote_from_collection(
123+
collection_id: int,
124+
quote_id: int,
125+
current_user: CurrentUser,
126+
collection_service: CollectionServiceDepends,
127+
quote_service: QuoteServiceDepends,
128+
):
129+
quote = quote_service.get_quote_by_id(quote_id)
130+
131+
if not quote:
132+
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No quote found with the ID %s." % (quote_id,)))
133+
134+
collection = collection_service.get_collection(collection_id)
135+
136+
if not collection:
137+
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (collection_id,)))
138+
139+
if current_user.id != collection.created_by_user_id:
140+
raise HTTPException(status.HTTP_403_FORBIDDEN, _("Access denied to this private collection."))
141+
142+
collection_service.remove_quote_from_collection(quote, collection)

src/api/v1/collections/service.py

+19-2
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
from src.api.params import SearchParams
44
from src.api.v1.collections.models import Collection
55
from src.api.v1.collections.schemas import CollectionCreateRequest, CollectionUpdateRequest
6-
from src.db.deps import Session
6+
from src.api.v1.quotes.models import Quote
7+
from src.db.deps import SessionDepends
78

89

910
class CollectionService:
10-
def __init__(self, session: Session) -> None:
11+
def __init__(self, session: SessionDepends) -> None:
1112
self.session = session
1213

1314
def get_collection(self, id: int) -> Collection | None:
@@ -54,3 +55,19 @@ def update_collection(self, collection: Collection, args: CollectionUpdateReques
5455
def delete_collection(self, collection: Collection) -> None:
5556
self.session.delete(collection)
5657
self.session.commit()
58+
59+
def add_quote_to_collection(self, quote: Quote, collection: Collection) -> Quote:
60+
collection.quotes.append(quote)
61+
62+
self.session.add(collection)
63+
self.session.commit()
64+
65+
self.session.refresh(quote)
66+
67+
return quote
68+
69+
def remove_quote_from_collection(self, quote: Quote, collection: Collection) -> None:
70+
collection.quotes.remove(quote)
71+
72+
self.session.add(collection)
73+
self.session.commit()

src/api/v1/quotes/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from src.api.v1.quotes.models import Quote
2+
from src.api.v1.quotes.routes import router
23

3-
__all__ = ["Quote"]
4+
__all__ = ["Quote", "router"]

src/api/v1/quotes/deps.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from typing import Annotated
2+
3+
from fastapi import Depends
4+
5+
from src.api.v1.quotes.service import QuoteService
6+
7+
QuoteServiceDepends = Annotated[QuoteService, Depends(QuoteService)]

src/api/v1/quotes/enums.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from enum import StrEnum, auto
2+
3+
4+
class UserQuotesType(StrEnum):
5+
ALL = auto()
6+
SAVED = auto()
7+
CREATED = auto()

src/api/v1/quotes/query.py

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from typing import Self
2+
3+
from sqlalchemy import or_
4+
from sqlalchemy.orm import Query
5+
6+
from src.api.params import SearchParams
7+
from src.api.v1.authors.models import Author
8+
from src.api.v1.collections.models import Collection
9+
from src.api.v1.quotes.enums import UserQuotesType
10+
from src.api.v1.quotes.models import Quote
11+
12+
13+
class QuoteQuery(Query[Quote]):
14+
def filter_by_search_params(self, search_params: SearchParams) -> Self:
15+
if search_params.q:
16+
self = self.filter(
17+
or_(
18+
Quote.content.ilike(f"%{search_params.q}%"),
19+
Quote.author.has(Author.name.ilike(f"%{search_params.q}%")),
20+
)
21+
)
22+
23+
return self.offset(search_params.offset).limit(search_params.limit)
24+
25+
def filter_by_collection_id(self, collection_id: int) -> Self:
26+
return self.filter(Quote.collections.any(Collection.id == collection_id))
27+
28+
def filter_by_user_quotes_type(self, user_id: int, type: UserQuotesType) -> Self:
29+
match type:
30+
case UserQuotesType.ALL:
31+
return self.filter_by_user_id_all(user_id)
32+
case UserQuotesType.SAVED:
33+
return self.filter_by_user_id_saved(user_id)
34+
case UserQuotesType.CREATED:
35+
return self.filter_by_user_id_created(user_id)
36+
37+
def filter_by_user_id_all(self, user_id: int) -> Self:
38+
return self.outerjoin(Quote.collections).filter(
39+
or_(
40+
Quote.collections.any((Collection.created_by_user_id == user_id)),
41+
Quote.created_by_user_id == user_id,
42+
)
43+
)
44+
45+
def filter_by_user_id_saved(self, user_id: int) -> Self:
46+
return self.join(Quote.collections).filter(
47+
Quote.collections.any((Collection.created_by_user_id == user_id)),
48+
Quote.created_by_user_id != user_id,
49+
)
50+
51+
def filter_by_user_id_created(self, user_id: int) -> Self:
52+
return self.filter(Quote.created_by_user_id == user_id)

src/api/v1/quotes/routes.py

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from fastapi import APIRouter, HTTPException, status
2+
3+
from src.api.deps import SearchParamsDepends
4+
from src.api.v1.authors import AuthorServiceDepends
5+
from src.api.v1.quotes.deps import QuoteServiceDepends
6+
from src.api.v1.quotes.schemas import QuoteCollectionsResponse, QuoteCreateRequest, QuoteResponse, QuoteUpdateRequest
7+
from src.api.v1.users.me.deps import CurrentUser
8+
from src.i18n import gettext as _
9+
10+
router = APIRouter(prefix="/quotes", tags=["Quotes"])
11+
12+
13+
@router.get("/{quote_id}", response_model=QuoteCollectionsResponse)
14+
def get_quote(quote_id: int, service: QuoteServiceDepends):
15+
quote = service.get_quote_by_id(quote_id)
16+
17+
if not quote:
18+
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No quote found with the ID %s." % (quote_id,)))
19+
20+
return quote
21+
22+
23+
@router.get("/", response_model=list[QuoteResponse])
24+
def get_quotes(search_params: SearchParamsDepends, service: QuoteServiceDepends):
25+
quotes = service.get_quotes(search_params)
26+
27+
if not quotes:
28+
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No quotes found matching the provided search parameters."))
29+
30+
return quotes
31+
32+
33+
@router.post("/", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED)
34+
def create_quote(
35+
args: QuoteCreateRequest,
36+
current_user: CurrentUser,
37+
quote_service: QuoteServiceDepends,
38+
author_service: AuthorServiceDepends,
39+
):
40+
if args.author_id and not author_service.get_author_by_id(args.author_id):
41+
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No author found with the ID '%s'." % (args.author_id,)))
42+
return quote_service.create_quote(args, created_by_user_id=current_user.id)
43+
44+
45+
@router.patch("/{quote_id}", response_model=QuoteResponse)
46+
def update_quote(quote_id: int, args: QuoteUpdateRequest, current_user: CurrentUser, service: QuoteServiceDepends):
47+
quote = service.get_quote_by_id(quote_id)
48+
49+
if not quote:
50+
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No quote found with the ID %s." % (quote_id,)))
51+
if quote.created_by_user_id != current_user.id:
52+
raise HTTPException(status.HTTP_403_FORBIDDEN, _("You do not have permission to modify this quote."))
53+
54+
return service.update_quote(quote, args)
55+
56+
57+
@router.delete("/{quote_id}", status_code=status.HTTP_204_NO_CONTENT)
58+
def delete_quote(quote_id: int, current_user: CurrentUser, service: QuoteServiceDepends):
59+
quote = service.get_quote_by_id(quote_id)
60+
61+
if not quote:
62+
raise HTTPException(status.HTTP_404_NOT_FOUND, _("No quote found with the ID %s." % (quote_id,)))
63+
if quote.created_by_user_id != current_user.id:
64+
raise HTTPException(status.HTTP_403_FORBIDDEN, _("You do not have permission to modify this quote."))
65+
66+
service.delete_quote(quote)

src/api/v1/quotes/schemas.py

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from pydantic import BaseModel
2+
3+
from src.api.v1.authors.schemas import AuthorResponse
4+
from src.api.v1.collections.schemas import CollectionResponse
5+
from src.api.v1.schemas import AuditResponse
6+
7+
8+
class QuoteResponse(AuditResponse):
9+
id: int
10+
content: str
11+
created_by_user_id: int | None
12+
author: AuthorResponse | None
13+
14+
15+
class QuoteCollectionsResponse(QuoteResponse):
16+
collections: list[CollectionResponse]
17+
18+
19+
class QuoteCreateRequest(BaseModel):
20+
content: str
21+
author_id: int | None
22+
23+
24+
class QuoteUpdateRequest(BaseModel):
25+
content: str | None = None
26+
author_id: int | None = None

0 commit comments

Comments
 (0)