Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions app/routers/news/routes.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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"])

Expand All @@ -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()
Expand All @@ -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,
Expand All @@ -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
38 changes: 35 additions & 3 deletions app/services/database/models/news.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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},
)
39 changes: 39 additions & 0 deletions app/services/database/orm/news.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ast
from typing import Optional

from sqlmodel import select
Expand Down Expand Up @@ -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
82 changes: 82 additions & 0 deletions tests/test_news.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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 == "[]"