diff --git a/src/api/auth/routes.py b/src/api/auth/routes.py index 84bfb5e..93ed636 100644 --- a/src/api/auth/routes.py +++ b/src/api/auth/routes.py @@ -11,18 +11,6 @@ router = APIRouter(prefix="/auth", tags=["Authentication"]) -@router.post("/register", status_code=status.HTTP_201_CREATED) -async def register(args: UserRegistrationRequest, service: UserServiceDepends) -> AccessTokenResponse: - if service.is_email_registered(args.email): - raise HTTPException(status.HTTP_409_CONFLICT, _("Email already registered.")) - if not expire_otp_if_correct(args.email, args.otp): - raise HTTPException(status.HTTP_406_NOT_ACCEPTABLE, _("The One-Time Password (OTP) is incorrect or expired.")) - - user = service.register_user(args) - - return create_access_token(user.id) - - @router.post("/login") async def login(form: OAuth2PasswordRequestFormDepends, service: UserServiceDepends) -> AccessTokenResponse: email = form.username # The OAuth2 spec requires the exact name `username`. @@ -38,6 +26,18 @@ async def login(form: OAuth2PasswordRequestFormDepends, service: UserServiceDepe return create_access_token(user.id) +@router.post("/register", status_code=status.HTTP_201_CREATED) +async def register(args: UserRegistrationRequest, service: UserServiceDepends) -> AccessTokenResponse: + if service.is_email_registered(args.email): + raise HTTPException(status.HTTP_409_CONFLICT, _("Email already registered.")) + if not expire_otp_if_correct(args.email, args.otp): + raise HTTPException(status.HTTP_406_NOT_ACCEPTABLE, _("The One-Time Password (OTP) is incorrect or expired.")) + + user = service.register_user(args) + + return create_access_token(user.id) + + @router.patch("/reset-password") def reset_password(args: UserPasswordResetRequest, service: UserServiceDepends) -> AccessTokenResponse: user = service.get_user_by_email(args.email) diff --git a/src/api/authors/routes.py b/src/api/authors/routes.py index 8a8ef2c..b5bd288 100644 --- a/src/api/authors/routes.py +++ b/src/api/authors/routes.py @@ -9,6 +9,14 @@ router = APIRouter(prefix="/authors", tags=["Authors"]) +@router.post("/", response_model=AuthorResponse, status_code=status.HTTP_201_CREATED) +def create_author(args: AuthorCreateRequest, current_user: CurrentUser, service: AuthorServiceDepends): + if service.exists(args.name): + raise HTTPException(status.HTTP_409_CONFLICT, _("An author with the name '%s' already exists." % (args.name,))) + + return service.create_author(name=args.name, created_by_user_id=current_user.id) + + @router.get("/{name}", response_model=AuthorResponse) def get_author(name: str, service: AuthorServiceDepends): author = service.get_author_by_name(name) @@ -27,11 +35,3 @@ def get_authors(search_params: SearchParamsDepends, service: AuthorServiceDepend raise HTTPException(status.HTTP_404_NOT_FOUND, _("No authors found matching the provided search parameters.")) return authors - - -@router.post("/", response_model=AuthorResponse, status_code=status.HTTP_201_CREATED) -def create_author(args: AuthorCreateRequest, current_user: CurrentUser, service: AuthorServiceDepends): - if service.exists(args.name): - raise HTTPException(status.HTTP_409_CONFLICT, _("An author with the name '%s' already exists." % (args.name,))) - - return service.create_author(name=args.name, created_by_user_id=current_user.id) diff --git a/src/api/authors/service.py b/src/api/authors/service.py index c901a55..9f09763 100644 --- a/src/api/authors/service.py +++ b/src/api/authors/service.py @@ -9,12 +9,21 @@ class AuthorService: def __init__(self, session: SessionDepends) -> None: self.session = session - def get_author_by_name(self, name: str) -> Author | None: - return self.session.query(Author).filter(Author.name == name).first() + def create_author(self, *, name: str, created_by_user_id: int) -> Author: + author = Author(name=name, created_by_user_id=created_by_user_id) + + self.session.add(author) + self.session.commit() + self.session.refresh(author) + + return author def get_author_by_id(self, author_id: int) -> Author | None: return self.session.get(Author, author_id) + def get_author_by_name(self, name: str) -> Author | None: + return self.session.query(Author).filter(Author.name == name).first() + def get_authors(self, search_params: SearchParams) -> list[Author]: query = self.session.query(Author) @@ -23,14 +32,5 @@ def get_authors(self, search_params: SearchParams) -> list[Author]: return query.order_by(Author.name).offset(search_params.offset).limit(search_params.limit).all() - def create_author(self, *, name: str, created_by_user_id: int) -> Author: - author = Author(name=name, created_by_user_id=created_by_user_id) - - self.session.add(author) - self.session.commit() - self.session.refresh(author) - - return author - def exists(self, name: str) -> bool: return self.session.query(exists().where(Author.name == name)).scalar() diff --git a/src/api/collections/routes.py b/src/api/collections/routes.py index 8177ef6..2c758d3 100644 --- a/src/api/collections/routes.py +++ b/src/api/collections/routes.py @@ -12,66 +12,56 @@ router = APIRouter(prefix="/collections", tags=["Collections"]) -@router.get("/{id}", response_model=CollectionResponse) -def get_collection(id: int, service: CollectionServiceDepends, current_user: CurrentUserOrNone): - collection = service.get_collection(id) - - if not collection: - raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (id,))) - - is_private = collection.visibility == Collection.Visibility.PRIVATE - has_access = current_user and current_user.id == collection.created_by_user_id - - if is_private and not has_access: - raise HTTPException(status.HTTP_403_FORBIDDEN, _("Access denied to this private collection.")) - - return collection - - -@router.get("/", response_model=list[CollectionResponse]) -def get_public_collections(search_params: SearchParamsDepends, service: CollectionServiceDepends): - collections = service.get_public_collections(search_params) - - if not collections: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - _("No collections found matching the provided search parameters."), - ) - - return collections - - @router.post("/", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED) def create_collection(args: CollectionCreateRequest, current_user: CurrentUser, service: CollectionServiceDepends): return service.create_collection(args, created_by_user_id=current_user.id) -@router.patch("/{id}", response_model=CollectionResponse) +@router.patch("/{collection_id}", response_model=CollectionResponse) def update_collection( - id: int, args: CollectionUpdateRequest, current_user: CurrentUser, service: CollectionServiceDepends + collection_id: int, + args: CollectionUpdateRequest, + current_user: CurrentUser, + service: CollectionServiceDepends, ): - collection = service.get_collection(id) + collection = service.get_collection(collection_id) if not collection: - raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (id,))) + raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (collection_id,))) if collection.created_by_user_id != current_user.id: raise HTTPException(status.HTTP_403_FORBIDDEN, _("You do not have permission to modify this collection.")) return service.update_collection(collection, args) -@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_collection(id: int, current_user: CurrentUser, service: CollectionServiceDepends): - collection = service.get_collection(id) +@router.delete("/{collection_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_collection(collection_id: int, current_user: CurrentUser, service: CollectionServiceDepends): + collection = service.get_collection(collection_id) if not collection: - raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (id,))) + raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (collection_id,))) if collection.created_by_user_id != current_user.id: raise HTTPException(status.HTTP_403_FORBIDDEN, _("You do not have permission to modify this collection.")) service.delete_collection(collection) +@router.get("/{collection_id}", response_model=CollectionResponse) +def get_collection(collection_id: int, service: CollectionServiceDepends, current_user: CurrentUserOrNone): + collection = service.get_collection(collection_id) + + if not collection: + raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (collection_id,))) + + is_private = collection.visibility == Collection.Visibility.PRIVATE + has_access = current_user and current_user.id == collection.created_by_user_id + + if is_private and not has_access: + raise HTTPException(status.HTTP_403_FORBIDDEN, _("Access denied to this private collection.")) + + return collection + + @router.get("/{collection_id}/quotes", response_model=list[QuoteResponse]) def get_collection_quotes( collection_id: int, @@ -94,6 +84,19 @@ def get_collection_quotes( return quote_service.get_collection_quotes(collection_id, search_params) +@router.get("/", response_model=list[CollectionResponse]) +def get_public_collections(search_params: SearchParamsDepends, service: CollectionServiceDepends): + collections = service.get_public_collections(search_params) + + if not collections: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + _("No collections found matching the provided search parameters."), + ) + + return collections + + @router.post("/{collection_id}/quotes", response_model=QuoteCollectionsResponse, status_code=status.HTTP_201_CREATED) def add_quote_to_collection( collection_id: int, @@ -111,6 +114,8 @@ def add_quote_to_collection( if not collection: raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (collection_id,))) + if quote.id in (quote.id for quote in collection.quotes): + raise HTTPException(status.HTTP_409_CONFLICT, _("Quote with the ID %s is in collection." % (quote_id,))) if current_user.id != collection.created_by_user_id: raise HTTPException(status.HTTP_403_FORBIDDEN, _("Access denied to this private collection.")) @@ -135,6 +140,8 @@ def remove_quote_from_collection( if not collection: raise HTTPException(status.HTTP_404_NOT_FOUND, _("No collection found with the ID %s." % (collection_id,))) + if quote.id not in (quote.id for quote in collection.quotes): + raise HTTPException(status.HTTP_409_CONFLICT, _("Quote with the ID %s is not in collection." % (quote_id,))) if current_user.id != collection.created_by_user_id: raise HTTPException(status.HTTP_403_FORBIDDEN, _("Access denied to this private collection.")) diff --git a/src/api/collections/service.py b/src/api/collections/service.py index cf33ee1..e7d2677 100644 --- a/src/api/collections/service.py +++ b/src/api/collections/service.py @@ -1,7 +1,7 @@ from sqlalchemy import or_ from sqlalchemy.orm import Query -from src.api.collections.models import Collection +from src.api.collections.models import Collection, QuoteCollection from src.api.collections.schemas import CollectionCreateRequest, CollectionUpdateRequest from src.api.params import SearchParams from src.api.quotes.models import Quote @@ -12,8 +12,8 @@ class CollectionService: def __init__(self, session: SessionDepends) -> None: self.session = session - def get_collection(self, id: int) -> Collection | None: - return self.session.get(Collection, id) + def get_collection(self, collection_id: int) -> Collection | None: + return self.session.get(Collection, collection_id) def get_public_collections(self, search_params: SearchParams) -> list[Collection]: query = self.session.query(Collection).filter(Collection.visibility == Collection.Visibility.PUBLIC) @@ -61,17 +61,16 @@ def delete_collection(self, collection: Collection) -> None: self.session.commit() def add_quote_to_collection(self, quote: Quote, collection: Collection) -> Quote: - collection.quotes.append(quote) + quote_collection = QuoteCollection(quote_id=quote.id, collection_id=collection.id) - self.session.add(collection) + self.session.add(quote_collection) self.session.commit() - self.session.refresh(quote) - return quote def remove_quote_from_collection(self, quote: Quote, collection: Collection) -> None: - collection.quotes.remove(quote) + quote_collection = self.session.get(QuoteCollection, {"quote_id": quote.id, "collection_id": collection.id}) + self.session.delete(quote_collection) self.session.add(collection) self.session.commit() diff --git a/src/api/quotes/routes.py b/src/api/quotes/routes.py index 02134c2..a35cf6e 100644 --- a/src/api/quotes/routes.py +++ b/src/api/quotes/routes.py @@ -10,26 +10,6 @@ router = APIRouter(prefix="/quotes", tags=["Quotes"]) -@router.get("/{quote_id}", response_model=QuoteCollectionsResponse) -def get_quote(quote_id: int, service: QuoteServiceDepends): - quote = service.get_quote_by_id(quote_id) - - if not quote: - raise HTTPException(status.HTTP_404_NOT_FOUND, _("No quote found with the ID %s." % (quote_id,))) - - return quote - - -@router.get("/", response_model=list[QuoteResponse]) -def get_quotes(search_params: SearchParamsDepends, service: QuoteServiceDepends): - quotes = service.get_quotes(search_params) - - if not quotes: - raise HTTPException(status.HTTP_404_NOT_FOUND, _("No quotes found matching the provided search parameters.")) - - return quotes - - @router.post("/", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED) def create_quote( args: QuoteCreateRequest, @@ -64,3 +44,23 @@ def delete_quote(quote_id: int, current_user: CurrentUser, service: QuoteService raise HTTPException(status.HTTP_403_FORBIDDEN, _("You do not have permission to modify this quote.")) service.delete_quote(quote) + + +@router.get("/{quote_id}", response_model=QuoteCollectionsResponse) +def get_quote(quote_id: int, service: QuoteServiceDepends): + quote = service.get_quote_by_id(quote_id) + + if not quote: + raise HTTPException(status.HTTP_404_NOT_FOUND, _("No quote found with the ID %s." % (quote_id,))) + + return quote + + +@router.get("/", response_model=list[QuoteResponse]) +def get_quotes(search_params: SearchParamsDepends, service: QuoteServiceDepends): + quotes = service.get_quotes(search_params) + + if not quotes: + raise HTTPException(status.HTTP_404_NOT_FOUND, _("No quotes found matching the provided search parameters.")) + + return quotes diff --git a/src/api/users/me/routes.py b/src/api/users/me/routes.py index 4768e35..031bf66 100644 --- a/src/api/users/me/routes.py +++ b/src/api/users/me/routes.py @@ -87,7 +87,7 @@ def delete_current_user_avatar(current_user: CurrentUser, service: UserServiceDe service.delete_avatar(current_user) -@router.get("/collections", response_model=list[CollectionResponse], tags=["Collections"]) +@router.get("/collections", response_model=list[CollectionResponse]) def get_current_user_collections( search_params: SearchParamsDepends, current_user: CurrentUser, @@ -104,7 +104,7 @@ def get_current_user_collections( return collections -@router.get("/quotes", response_model=list[QuoteResponse], tags=["Quotes"]) +@router.get("/quotes", response_model=list[QuoteResponse]) def get_current_user_quotes( service: QuoteServiceDepends, current_user: CurrentUser,