diff --git a/app/routers/news/routes.py b/app/routers/news/routes.py index cad6a96..795174d 100644 --- a/app/routers/news/routes.py +++ b/app/routers/news/routes.py @@ -1,13 +1,18 @@ +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 -from app.services.database.orm.news import create_news, get_news_by_query_params + +SECRET_KEY = os.getenv("SECRET_KEY", "default_fallback_key") +ALGORITHM = os.getenv("ALGORITHM", "HS256") class NewsPostResponse(BaseModel): @@ -19,6 +24,19 @@ class NewsGetResponse(BaseModel): news_list: list = [] +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"]) @@ -42,7 +60,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() @@ -67,7 +85,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, @@ -76,4 +94,58 @@ async def get_news( ) return NewsGetResponse(news_list=news_list) + @router.post( + path="/{news_id}/like", + response_model=NewsLikeResponse, + 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: 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. + """ + encoded_email = encode_email(email) + total_likes = await orm_news.delete_like( + session=request.app.db_session_factory, + news_id=news_id, + email=encoded_email, + ) + return NewsLikeResponse(total_likes=total_likes) + return router 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 a02846f..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 @@ -42,3 +43,41 @@ 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, 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 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 a47ce41..832ba7c 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 @@ -284,3 +285,84 @@ async def test_news_integration( == news_data["social_media_url"] ) assert data["news_list"][0]["likes"] == 0 + + +@pytest.mark.asyncio +async def test_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 + + emails = ["like@test.com", "like2@test.com"] + + # Add likes + response = await async_client.post( + f"/api/news/{stored_news.id}/like", + json={"email": emails[0]}, + 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(emails[0])}']" + + response = await async_client.post( + 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.likes == 2 + assert ( + stored_news.user_email_list + == f"['{encode_email(emails[0])}', '{encode_email(emails[1])}']" + ) + + # Remove likes + response = await async_client.delete( + f"/api/news/{stored_news.id}/like", + params={"email": emails[0]}, + 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(emails[1])}']" + + response = await async_client.delete( + f"/api/news/{stored_news.id}/like", + params={"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.likes == 0 + assert stored_news.user_email_list == "[]"