From 798336d35a8aeb104136bb6c527c0ceff1441865 Mon Sep 17 00:00:00 2001 From: Arthur Mesquita Pickcius Date: Tue, 9 Sep 2025 19:40:07 -0300 Subject: [PATCH 1/3] Create like endpoint --- app/routers/news/routes.py | 35 ++++++++++++++++++++++++++++- app/services/database/orm/news.py | 15 +++++++++++++ tests/test_news.py | 37 +++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/app/routers/news/routes.py b/app/routers/news/routes.py index cad6a96..389b3f8 100644 --- a/app/routers/news/routes.py +++ b/app/routers/news/routes.py @@ -7,7 +7,11 @@ from app.routers.authentication import get_current_active_community from app.schemas import News from app.services.database.models import Community as DBCommunity -from app.services.database.orm.news import create_news, get_news_by_query_params +from app.services.database.orm.news import ( + create_news, + get_news_by_query_params, + like_news, +) class NewsPostResponse(BaseModel): @@ -19,6 +23,10 @@ class NewsGetResponse(BaseModel): news_list: list = [] +class NewsLikeResponse(BaseModel): + total_likes: int | None + + def setup(): router = APIRouter(prefix="/news", tags=["news"]) @@ -76,4 +84,29 @@ async def get_news( ) return NewsGetResponse(news_list=news_list) + @router.post( + path="/{news_id}/like", + response_model=NewsPostResponse, + status_code=status.HTTP_200_OK, + summary="News like endpoint", + description="Allows user to like a news item", + ) + async def post_like( + request: Request, + current_community: Annotated[ + DBCommunity, Depends(get_current_active_community) + ], + news_id, + user_email: str = Header(..., alias="user-email"), + ): + """ + News endpoint where user can set like to news item. + """ + total_likes = await like_news( + session=request.app.db_session_factory, + news_id=news_id, + user_email=user_email, + ) + return NewsLikeResponse(total_likes=total_likes) + return router diff --git a/app/services/database/orm/news.py b/app/services/database/orm/news.py index a02846f..cea82d3 100644 --- a/app/services/database/orm/news.py +++ b/app/services/database/orm/news.py @@ -42,3 +42,18 @@ async def get_news_by_query_params( statement = select(News).where(*filters) results = await session.exec(statement) return results.all() + + +async def like_news( + session: AsyncSession, news_id: str, user_email: str +) -> int | None: + statement = select(News).where(News.id == news_id) + results = await session.exec(statement) + news_item = results.first() + if news_item: + news_item.likes += 1 + session.add(news_item) + await session.commit() + await session.refresh(news_item) + return news_item.likes + return None diff --git a/tests/test_news.py b/tests/test_news.py index a47ce41..d14b625 100755 --- a/tests/test_news.py +++ b/tests/test_news.py @@ -284,3 +284,40 @@ async def test_news_integration( == news_data["social_media_url"] ) assert data["news_list"][0]["likes"] == 0 + + +@pytest.mark.asyncio +async def test_insert_news_likes_endpoint( + session: AsyncSession, + async_client: AsyncClient, + community: Community, + valid_auth_headers: Mapping[str, str], +): + news_data = { + "title": "Test News", + "content": "Test news content.", + "category": "test_category", + "tags": "test_tag", + "source_url": "https://example.com/test-news", + "social_media_url": "https://test.com/test_news", + } + response = await async_client.post( + "/api/news", json=news_data, headers=valid_auth_headers + ) + assert response.status_code == status.HTTP_200_OK + statement = select(News).where(News.title == news_data["title"]) + result = await session.exec(statement) + stored_news = result.first() + assert stored_news is not None + assert stored_news.likes == 0 + + response = await async_client.post( + f"/api/news/{stored_news.id}/like", + json=news_data, + headers=valid_auth_headers, + ) + assert response.status_code == status.HTTP_200_OK + statement = select(News).where(News.title == news_data["title"]) + result = await session.exec(statement) + stored_news = result.first() + assert stored_news.likes == 1 From 1b6fbf0e7b4c085f016a9e076cfef7670b3f5aa4 Mon Sep 17 00:00:00 2001 From: Arthur Mesquita Pickcius Date: Mon, 22 Sep 2025 22:35:13 -0300 Subject: [PATCH 2/3] Delete like endpoint - Complete like endpoint - Store encripted email --- app/routers/news/routes.py | 59 ++++++++++++++++++++++------ app/services/database/models/news.py | 38 ++++++++++++++++-- app/services/database/orm/news.py | 36 ++++++++++++++--- tests/test_news.py | 58 ++++++++++++++++++++++++++- 4 files changed, 170 insertions(+), 21 deletions(-) diff --git a/app/routers/news/routes.py b/app/routers/news/routes.py index 389b3f8..ef0e64f 100644 --- a/app/routers/news/routes.py +++ b/app/routers/news/routes.py @@ -7,11 +7,13 @@ from app.routers.authentication import get_current_active_community from app.schemas import News from app.services.database.models import Community as DBCommunity -from app.services.database.orm.news import ( - create_news, - get_news_by_query_params, - like_news, -) +import app.services.database.orm.news as orm_news + +import os +import jwt + +SECRET_KEY = os.getenv("SECRET_KEY", "default_fallback_key") +ALGORITHM = os.getenv("ALGORITHM", "HS256") class NewsPostResponse(BaseModel): @@ -26,6 +28,12 @@ class NewsGetResponse(BaseModel): class NewsLikeResponse(BaseModel): total_likes: int | None +class LikeRequest(BaseModel): + email: str + +def encode_email(email: str) -> str: + """Encodes the email to be safely stored in database.""" + return jwt.encode({"email": email}, SECRET_KEY, algorithm=ALGORITHM) def setup(): router = APIRouter(prefix="/news", tags=["news"]) @@ -50,7 +58,7 @@ async def post_news( """ news_dict = news.__dict__ news_dict["user_email"] = user_email - await create_news( + await orm_news.create_news( session=request.app.db_session_factory, news=news_dict ) return NewsPostResponse() @@ -75,7 +83,7 @@ async def get_news( """ Get News endpoint that retrieves news filtered by user and query params. """ - news_list = await get_news_by_query_params( + news_list = await orm_news.get_news_by_query_params( session=request.app.db_session_factory, id=id, email=user_email, @@ -86,7 +94,7 @@ async def get_news( @router.post( path="/{news_id}/like", - response_model=NewsPostResponse, + response_model=NewsLikeResponse, status_code=status.HTTP_200_OK, summary="News like endpoint", description="Allows user to like a news item", @@ -96,16 +104,45 @@ async def post_like( current_community: Annotated[ DBCommunity, Depends(get_current_active_community) ], - news_id, + news_id: str, + body: LikeRequest, + user_email: str = Header(..., alias="user-email"), + ): + """ + News endpoint where user can set like to news item. + """ + encoded_email = encode_email(body.email) + total_likes = await orm_news.like_news( + session=request.app.db_session_factory, + news_id=news_id, + email=encoded_email, + ) + return NewsLikeResponse(total_likes=total_likes) + + @router.delete( + path="/{news_id}/like", + response_model=NewsLikeResponse, + status_code=status.HTTP_200_OK, + summary="News undo like endpoint", + description="Allows user to undo a like to a news item", + ) + async def delete_like( + request: Request, + current_community: Annotated[ + DBCommunity, Depends(get_current_active_community) + ], + news_id: str, + email: str, user_email: str = Header(..., alias="user-email"), ): """ News endpoint where user can set like to news item. """ - total_likes = await like_news( + encoded_email = encode_email(email) + total_likes = await orm_news.delete_like( session=request.app.db_session_factory, news_id=news_id, - user_email=user_email, + email=encoded_email, ) return NewsLikeResponse(total_likes=total_likes) diff --git a/app/services/database/models/news.py b/app/services/database/models/news.py index bebd017..6f1b5a5 100644 --- a/app/services/database/models/news.py +++ b/app/services/database/models/news.py @@ -5,6 +5,37 @@ class News(SQLModel, table=True): + """ + Represents a news article in the database. + + Attributes: + id (Optional[int]): Unique identifier for the news article. + Auto-generated primary key. + title (str): The headline or title of the news article. + content (str): The main body or text content of the news. + category (str): Category or topic classification + (e.g., "Politics", "Tech"). + user_email (str): Email of the user who submitted or is associated + with this news. + source_url (str): URL pointing to the original source of the news. + tags (str): Comma-separated or JSON-encoded string of tags for + search/filtering. + user_email_list (str): encoded list of emails of users who liked + this news. Defaults to an empty list. + social_media_url (str): URL to the social media post or share link + for this news. + likes (int): Number of likes this news article has received. + Defaults to 0. + + community_id (Optional[int]): Foreign key to the associated community + (communities.id). + + created_at (Optional[datetime]): Timestamp when the news was first + created. Defaults to now. + updated_at (Optional[datetime]): Timestamp when the news was last + updated. Auto-updates on modification. + """ + __tablename__ = "news" # Campos obrigatórios e suas definições @@ -15,18 +46,19 @@ class News(SQLModel, table=True): user_email: str source_url: str tags: str + user_email_list: str = Field(default="[]") social_media_url: str likes: int = Field(default=0) # Chaves estrangeiras community_id: Optional[int] = Field( - default=None, - foreign_key="communities.id") + default=None, foreign_key="communities.id" + ) # library_id: Optional[int]=Field(default=None, foreign_key="libraries.id") # Campos de data/hora created_at: Optional[datetime] = Field(default_factory=datetime.now) updated_at: Optional[datetime] = Field( default_factory=datetime.now, - sa_column_kwargs={"onupdate": datetime.now} + sa_column_kwargs={"onupdate": datetime.now}, ) diff --git a/app/services/database/orm/news.py b/app/services/database/orm/news.py index cea82d3..f4e8b71 100644 --- a/app/services/database/orm/news.py +++ b/app/services/database/orm/news.py @@ -1,3 +1,4 @@ +import ast from typing import Optional from sqlmodel import select @@ -45,15 +46,38 @@ async def get_news_by_query_params( async def like_news( - session: AsyncSession, news_id: str, user_email: str + session: AsyncSession, news_id: str, email: str ) -> int | None: statement = select(News).where(News.id == news_id) results = await session.exec(statement) news_item = results.first() if news_item: - news_item.likes += 1 - session.add(news_item) - await session.commit() - await session.refresh(news_item) - return news_item.likes + users = ast.literal_eval(news_item.user_email_list) + if email not in users: + users.append(email) + news_item.user_email_list = str(users) + news_item.likes += 1 + session.add(news_item) + await session.commit() + await session.refresh(news_item) + return news_item.likes + return None + + +async def delete_like( + session: AsyncSession, news_id: str, email: str +) -> int | None: + statement = select(News).where(News.id == news_id) + results = await session.exec(statement) + news_item = results.first() + if news_item: + users = ast.literal_eval(news_item.user_email_list) + if email in users: + users.remove(email) + news_item.user_email_list = str(users) + news_item.likes -= 1 + session.add(news_item) + await session.commit() + await session.refresh(news_item) + return news_item.likes return None diff --git a/tests/test_news.py b/tests/test_news.py index d14b625..7a9dead 100755 --- a/tests/test_news.py +++ b/tests/test_news.py @@ -8,6 +8,7 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from app.routers.news.routes import encode_email from app.services.database.models import Community, News @@ -311,9 +312,51 @@ async def test_insert_news_likes_endpoint( assert stored_news is not None assert stored_news.likes == 0 + email = "like@test.com" + + response = await async_client.post( + f"/api/news/{stored_news.id}/like", + json={"email": email}, + headers=valid_auth_headers, + ) + assert response.status_code == status.HTTP_200_OK + statement = select(News).where(News.title == news_data["title"]) + result = await session.exec(statement) + stored_news = result.first() + assert stored_news.likes == 1 + assert stored_news.user_email_list == f"['{encode_email(email)}']" + + +@pytest.mark.asyncio +async def test_delete_news_likes_endpoint( + session: AsyncSession, + async_client: AsyncClient, + community: Community, + valid_auth_headers: Mapping[str, str], +): + news_data = { + "title": "Test News", + "content": "Test news content.", + "category": "test_category", + "tags": "test_tag", + "source_url": "https://example.com/test-news", + "social_media_url": "https://test.com/test_news", + } + response = await async_client.post( + "/api/news", json=news_data, headers=valid_auth_headers + ) + assert response.status_code == status.HTTP_200_OK + statement = select(News).where(News.title == news_data["title"]) + result = await session.exec(statement) + stored_news = result.first() + assert stored_news is not None + assert stored_news.likes == 0 + + email = "like@test.com" + response = await async_client.post( f"/api/news/{stored_news.id}/like", - json=news_data, + json={"email": email}, headers=valid_auth_headers, ) assert response.status_code == status.HTTP_200_OK @@ -321,3 +364,16 @@ async def test_insert_news_likes_endpoint( result = await session.exec(statement) stored_news = result.first() assert stored_news.likes == 1 + assert stored_news.user_email_list == f"['{encode_email(email)}']" + + response = await async_client.delete( + f"/api/news/{stored_news.id}/like", + params={"email": email}, + headers=valid_auth_headers, + ) + assert response.status_code == status.HTTP_200_OK + statement = select(News).where(News.title == news_data["title"]) + result = await session.exec(statement) + stored_news = result.first() + assert stored_news.likes == 0 + assert stored_news.user_email_list == "[]" From 9ac9d0a6cdaec2b825d35a206969653f788b0111 Mon Sep 17 00:00:00 2001 From: Arthur Mesquita Pickcius Date: Tue, 23 Sep 2025 16:47:10 -0300 Subject: [PATCH 3/3] improve tests for like endpoint --- app/routers/news/routes.py | 10 ++++---- tests/test_news.py | 47 +++++++++++++++----------------------- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/app/routers/news/routes.py b/app/routers/news/routes.py index ef0e64f..795174d 100644 --- a/app/routers/news/routes.py +++ b/app/routers/news/routes.py @@ -1,16 +1,15 @@ +import os from typing import Annotated +import jwt from fastapi import APIRouter, Depends, Request, status from fastapi.params import Header from pydantic import BaseModel +import app.services.database.orm.news as orm_news from app.routers.authentication import get_current_active_community from app.schemas import News from app.services.database.models import Community as DBCommunity -import app.services.database.orm.news as orm_news - -import os -import jwt SECRET_KEY = os.getenv("SECRET_KEY", "default_fallback_key") ALGORITHM = os.getenv("ALGORITHM", "HS256") @@ -28,13 +27,16 @@ class NewsGetResponse(BaseModel): class NewsLikeResponse(BaseModel): total_likes: int | None + class LikeRequest(BaseModel): email: str + def encode_email(email: str) -> str: """Encodes the email to be safely stored in database.""" return jwt.encode({"email": email}, SECRET_KEY, algorithm=ALGORITHM) + def setup(): router = APIRouter(prefix="/news", tags=["news"]) diff --git a/tests/test_news.py b/tests/test_news.py index 7a9dead..832ba7c 100755 --- a/tests/test_news.py +++ b/tests/test_news.py @@ -288,7 +288,7 @@ async def test_news_integration( @pytest.mark.asyncio -async def test_insert_news_likes_endpoint( +async def test_news_likes_endpoint( session: AsyncSession, async_client: AsyncClient, community: Community, @@ -312,11 +312,12 @@ async def test_insert_news_likes_endpoint( assert stored_news is not None assert stored_news.likes == 0 - email = "like@test.com" + emails = ["like@test.com", "like2@test.com"] + # Add likes response = await async_client.post( f"/api/news/{stored_news.id}/like", - json={"email": email}, + json={"email": emails[0]}, headers=valid_auth_headers, ) assert response.status_code == status.HTTP_200_OK @@ -324,39 +325,27 @@ async def test_insert_news_likes_endpoint( result = await session.exec(statement) stored_news = result.first() assert stored_news.likes == 1 - assert stored_news.user_email_list == f"['{encode_email(email)}']" + assert stored_news.user_email_list == f"['{encode_email(emails[0])}']" - -@pytest.mark.asyncio -async def test_delete_news_likes_endpoint( - session: AsyncSession, - async_client: AsyncClient, - community: Community, - valid_auth_headers: Mapping[str, str], -): - news_data = { - "title": "Test News", - "content": "Test news content.", - "category": "test_category", - "tags": "test_tag", - "source_url": "https://example.com/test-news", - "social_media_url": "https://test.com/test_news", - } response = await async_client.post( - "/api/news", json=news_data, headers=valid_auth_headers + f"/api/news/{stored_news.id}/like", + json={"email": emails[1]}, + headers=valid_auth_headers, ) assert response.status_code == status.HTTP_200_OK statement = select(News).where(News.title == news_data["title"]) result = await session.exec(statement) stored_news = result.first() - assert stored_news is not None - assert stored_news.likes == 0 - - email = "like@test.com" + assert stored_news.likes == 2 + assert ( + stored_news.user_email_list + == f"['{encode_email(emails[0])}', '{encode_email(emails[1])}']" + ) - response = await async_client.post( + # Remove likes + response = await async_client.delete( f"/api/news/{stored_news.id}/like", - json={"email": email}, + params={"email": emails[0]}, headers=valid_auth_headers, ) assert response.status_code == status.HTTP_200_OK @@ -364,11 +353,11 @@ async def test_delete_news_likes_endpoint( result = await session.exec(statement) stored_news = result.first() assert stored_news.likes == 1 - assert stored_news.user_email_list == f"['{encode_email(email)}']" + assert stored_news.user_email_list == f"['{encode_email(emails[1])}']" response = await async_client.delete( f"/api/news/{stored_news.id}/like", - params={"email": email}, + params={"email": emails[1]}, headers=valid_auth_headers, ) assert response.status_code == status.HTTP_200_OK