diff --git a/api/v1/models/blog.py b/api/v1/models/blog.py index ffd219bb1..c14211e3c 100644 --- a/api/v1/models/blog.py +++ b/api/v1/models/blog.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """The Blog Post Model.""" -from sqlalchemy import Column, String, Text, ForeignKey, Boolean, text, Index, Integer +from sqlalchemy import Column, DateTime, Enum, Index, Integer, String, Text, ForeignKey, Boolean, text from sqlalchemy.orm import relationship +from sqlalchemy.dialects.postgresql import ENUM from api.v1.models.base_model import BaseTableModel +from api.v1.schemas.blog import BlogStatus - +blog_status_enum = ENUM(BlogStatus, name="blogstatus", create_type=True) class Blog(BaseTableModel): __tablename__ = "blogs" @@ -20,7 +22,10 @@ class Blog(BaseTableModel): tags = Column( Text, nullable=True ) # Assuming tags are stored as a comma-separated string + scheduled_at = Column(DateTime(timezone=True), nullable=True) + status = Column(blog_status_enum, nullable=True) + # Relationships author = relationship("User", back_populates="blogs") comments = relationship( "Comment", back_populates="blog", cascade="all, delete-orphan" diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index fd5bc9afd..82c7d6012 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -17,6 +17,7 @@ BlogCreate, BlogPostResponse, BlogRequest, + BlogStatus, BlogUpdateResponseModel, BlogLikeDislikeResponse, CommentRequest, @@ -37,15 +38,16 @@ def create_blog( blog: BlogCreate, db: Session = Depends(get_db), - current_user: User = Depends(user_service.get_current_super_admin), + current_user: User = Depends(user_service.get_current_user), ): if not current_user: - raise HTTPException(status_code=401, detail="You are not Authorized") - blog_service = BlogService(db) - new_blogpost = blog_service.create(db=db, schema=blog, author_id=current_user.id) - + raise HTTPException(status_code=401, detail="Not authenticated") + blog_service = BlogService(db=db) + new_blogpost = blog_service.create(schema=blog, author_id=current_user.id) + message = "Blog post scheduled successfully!" if blog.scheduled_at else "Blog created successfully!" + return success_response( - message="Blog created successfully!", + message=message, status_code=200, data=jsonable_encoder(new_blogpost), ) @@ -53,14 +55,16 @@ def create_blog( @blog.get("/", response_model=success_response) def get_all_blogs(db: Session = Depends(get_db), limit: int = 10, skip: int = 0): - """Endpoint to get all blogs""" - - return paginated_response( - db=db, - model=Blog, - limit=limit, - skip=skip, - filters={"is_deleted": False} #filter out soft-deleted blogs + """Endpoint to get all blogs except scheduled blogs""" + blog_service = BlogService(db) + blogs = blog_service.fetch_all() + + paginated_blogs = blogs[skip: skip+limit] + + return success_response( + message="Blogs retrieved successfully", + status_code=200, + data=jsonable_encoder(paginated_blogs), ) # blog search endpoint @@ -175,6 +179,22 @@ def search_blogs( "blogs": processed_blogs } +@blog.get("/scheduled", response_model=success_response) +def get_scheduled_blogs( + db: Session = Depends(get_db), + current_user: User = Depends(user_service.get_current_user) +): + """Endpoint to get all scheduled blogs for the current user""" + blog_service = BlogService(db) + scheduled_blogs = blog_service.fetch_scheduled_blogs(current_user) + + return success_response( + message="Scheduled blogs retrieved successfully", + status_code=200, + data=jsonable_encoder(scheduled_blogs) + ) + + @blog.get("/{id}", response_model=BlogPostResponse) def get_blog_by_id(id: str, db: Session = Depends(get_db)): """ diff --git a/api/v1/schemas/blog.py b/api/v1/schemas/blog.py index 75d83b2ba..2bccedb9f 100644 --- a/api/v1/schemas/blog.py +++ b/api/v1/schemas/blog.py @@ -1,6 +1,8 @@ from datetime import datetime from typing import List, Optional from pydantic import BaseModel, Field +from enum import Enum + from api.v1.schemas.comment import CommentData class BlogCreate(BaseModel): @@ -9,11 +11,16 @@ class BlogCreate(BaseModel): image_url: str = None tags: list[str] = None excerpt: str = Field(None, max_length=500) + scheduled_at: datetime = None class BlogRequest(BaseModel): title: str content: str +class BlogStatus(str, Enum): + PENDING = "pending" + PUBLISHED = "published" + class BlogUpdateResponseModel(BaseModel): status: str message: str @@ -30,6 +37,8 @@ class BlogBaseResponse(BaseModel): created_at: datetime updated_at: datetime views: int + status: BlogStatus + scheduled_at: Optional[datetime] class Config: from_attributes = True diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 58f2896e5..d17d81090 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -1,3 +1,5 @@ +import asyncio +from datetime import datetime, timezone from typing import Generic, TypeVar, Optional from fastapi import HTTPException, status @@ -9,7 +11,7 @@ from api.v1.models.blog import Blog, BlogDislike, BlogLike from api.v1.models.comment import Comment from api.v1.models.user import User -from api.v1.schemas.blog import BlogCreate +from api.v1.schemas.blog import BlogCreate, BlogStatus ModelType = TypeVar("ModelType") @@ -47,6 +49,18 @@ def create(self, schema: BlogCreate, author_id: str): """Create a new blog post""" new_blogpost = Blog(**schema.model_dump(), author_id=author_id) + + if new_blogpost.scheduled_at: + if new_blogpost.scheduled_at.astimezone(timezone.utc) < datetime.now(timezone.utc): + raise HTTPException( + status_code=400, detail="Scheduled time must be in the future." + ) + else: + new_blogpost.status = BlogStatus.PENDING + new_blogpost.scheduled_at = new_blogpost.scheduled_at.astimezone(timezone.utc) + else: + new_blogpost.status = BlogStatus.PUBLISHED + self.db.add(new_blogpost) self.db.commit() self.db.refresh(new_blogpost) @@ -55,7 +69,7 @@ def create(self, schema: BlogCreate, author_id: str): def fetch_all(self): """Fetch all blog posts""" - blogs = self.db.query(Blog).filter(Blog.is_deleted == False).all() + blogs = self.db.query(Blog).filter(Blog.is_deleted == False, Blog.status == BlogStatus.PUBLISHED).all() return blogs def fetch(self, blog_id: str): @@ -130,6 +144,18 @@ def search_blogs(self, filters=None, page=1, per_page=10): } + def fetch_scheduled_blogs( + self, + current_user: User, + ): + """Fetch all scheduled blog posts for the current user""" + + scheduled_blogs = self.db.query(Blog).filter( + Blog.status == BlogStatus.PENDING, + Blog.author_id == current_user.id + ).all() + return scheduled_blogs + def update( self, blog_id: str, @@ -187,9 +213,7 @@ def create_blog_dislike(self, blog_id: str, user_id: str, ip_address: str = None def fetch_blog_like(self, blog_id: str, user_id: str): """Fetch a blog like by blog ID & ID of user who liked it""" blog_like = ( - self.db.query(BlogLike) - .filter_by(blog_id=blog_id, user_id=user_id) - .first() + self.db.query(BlogLike).filter_by(blog_id=blog_id, user_id=user_id).first() ) return blog_like @@ -201,7 +225,7 @@ def fetch_blog_dislike(self, blog_id: str, user_id: str): .first() ) return blog_dislike - + def check_user_already_liked_blog(self, blog: Blog, user: User): if not user: raise HTTPException(status_code=401, detail="Not authenticated") @@ -221,10 +245,12 @@ def check_user_already_disliked_blog(self, blog: Blog, user: User): detail="You have already disliked this blog post", status_code=status.HTTP_403_FORBIDDEN, ) - - def delete_opposite_blog_like_or_dislike(self, blog: Blog, user: User, creating: str): + + def delete_opposite_blog_like_or_dislike( + self, blog: Blog, user: User, creating: str + ): """ - This method checks if there's a BlogLike by `user` on `blog` when a BlogDislike + This method checks if there's a BlogLike by `user` on `blog` when a BlogDislike is being created and deletes the BlogLike. The same for BlogLike creation. \n :param blog: `Blog` The blog being liked or disliked @@ -244,7 +270,7 @@ def delete_opposite_blog_like_or_dislike(self, blog: Blog, user: User, creating: else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid `creating` value for blog like/dislike" + detail="Invalid `creating` value for blog like/dislike", ) def num_of_likes(self, blog_id: str) -> int: @@ -323,9 +349,7 @@ def update_blog_comment( db = self.db if not content: - raise HTTPException( - status_code=400, detail="Blog comment cannot be empty" - ) + raise HTTPException(status_code=400, detail="Blog comment cannot be empty") # check if the blog and comment exist blog_post = check_model_existence(db, Blog, blog_id) @@ -346,7 +370,8 @@ def update_blog_comment( except Exception as exc: db.rollback() raise HTTPException( - status_code=500, detail=f"An error occurred while updating the blog comment; {exc}" + status_code=500, + detail=f"An error occurred while updating the blog comment; {exc}", ) return comment diff --git a/api/v1/services/blog_scheduler.py b/api/v1/services/blog_scheduler.py new file mode 100644 index 000000000..c74373cdf --- /dev/null +++ b/api/v1/services/blog_scheduler.py @@ -0,0 +1,48 @@ +import asyncio +from fastapi import FastAPI +from datetime import datetime + +from api.utils.logger import logger +from api.v1.models.blog import Blog, BlogStatus +from api.db.database import get_db + +class BlogScheduler: + def __init__(self, app: FastAPI): + self.app = app + self.db = next(get_db()) + + def publish_schedule_blog(self): + """ + Publish scheduled blogs which are due for publishing + """ + scheduled_blogs = ( + self.db.query(Blog).filter( + Blog.status == BlogStatus.PENDING, + Blog.scheduled_at <= datetime.now(), + Blog.is_deleted == False + ).all() + ) + if len(scheduled_blogs) > 0: + logger.info(f"Found {len(scheduled_blogs)} scheduled blogs ready for publication") + for blog in scheduled_blogs: + blog.status = BlogStatus.PUBLISHED + self.db.commit() + self.db.refresh(blog) + logger.info(f"Published {len(scheduled_blogs)} scheduled blogs") + else: + logger.info("No scheduled blog posts are ready for publication at this time.") + + + async def schedule_checker(self): + """ Background task to check for scheduled blogs and publish them """ + while True: + logger.info("Checking for scheduled blogs...") + self.publish_schedule_blog() + await asyncio.sleep(60) + + +def setup_blog_scheduler(app: FastAPI): + """ Setup the blog scheduler """ + scheduler = BlogScheduler(app) + asyncio.create_task(scheduler.schedule_checker()) + diff --git a/main.py b/main.py index e72225227..bbb629a58 100644 --- a/main.py +++ b/main.py @@ -21,13 +21,12 @@ from api.v1.routes import api_version_one from api.utils.settings import settings from api.utils.send_logs import send_error_to_telex -from scripts.populate_db import populate_roles_and_permissions - +from api.v1.services.blog_scheduler import setup_blog_scheduler @asynccontextmanager async def lifespan(app: FastAPI): - """Lifespan function""" - + '''Lifespan function''' + setup_blog_scheduler(app) yield diff --git a/requirements.txt b/requirements.txt index 12775597c..4ee1c768a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,6 +37,7 @@ fastapi-cli==0.0.4 fastapi-mail==1.4.1 filelock==3.15.4 flake8==7.1.0 +freezegun==1.5.1 frozenlist==1.4.1 geoip2==5.0.1 greenlet==3.0.3 @@ -123,5 +124,6 @@ virtualenv==20.26.3 watchfiles==0.22.0 webencodings==0.5.1 websockets==12.0 +wheel==0.45.1 wrapt==1.16.0 yarl==1.9.4 diff --git a/tests/v1/blog/test_create_scheduled_blog.py b/tests/v1/blog/test_create_scheduled_blog.py new file mode 100644 index 000000000..56393f7de --- /dev/null +++ b/tests/v1/blog/test_create_scheduled_blog.py @@ -0,0 +1,225 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock +from freezegun import freeze_time + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from uuid_extensions import uuid7 + +from api.v1.models.blog import Blog, BlogStatus +from api.v1.models.user import User +from api.v1.routes.blog import get_db +from api.v1.services.user import user_service +from api.v1.schemas.blog import BlogResponse +from main import app + + +# Mock database dependency +@pytest.fixture +def db_session_mock(): + db_session = MagicMock(spec=Session) + return db_session + + +@pytest.fixture +def client(db_session_mock): + app.dependency_overrides[get_db] = lambda: db_session_mock + client = TestClient(app) + yield client + app.dependency_overrides = {} + + +def mock_get_current_user(): + return User( + id=str(uuid7()), + email="user@gmail.com", + password=user_service.hash_password("Testuser@123"), + first_name="User", + last_name="User", + is_active=True, + is_superadmin=False, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + +def test_create_scheduled_blog_success(client, db_session_mock): + # Setup test data + scheduled_time = datetime.now() + timedelta(minutes=1) + + blog_data = { + "title": "Scheduled Test Blog", + "content": "Test Content", + "image_url": "http://example.com/image.png", + "tags": ["test", "scheduled"], + "excerpt": "Test Excerpt", + "scheduled_at": scheduled_time.isoformat() + } + + # Create a mock blog instance from blog_data + mock_blog = Blog(**blog_data) + + app.dependency_overrides[user_service.get_current_user] = mock_get_current_user + + # Mock the database operations + db_session_mock.add.return_value = None + db_session_mock.commit.return_value = None + db_session_mock.refresh.return_value = None + db_session_mock.query.return_value.filter.return_value.first.return_value = mock_blog + + # Make the request + response = client.post( + "/api/v1/blogs/", + json=blog_data, + headers={"Authorization": "Bearer token"}, + ) + + # Assertions + assert response.status_code == 200 + response_data = response.json() + assert response_data["message"] == "Blog post scheduled successfully!" + assert response_data["data"]["title"] == blog_data["title"] + assert response_data["data"]["status"] == BlogStatus.PENDING.value + assert "scheduled_at" in response_data["data"] + + + +def test_create_scheduled_blog_past_date(client, db_session_mock): + # Test scheduling a blog in the past + past_time = datetime.now() - timedelta(days=1) + + app.dependency_overrides[user_service.get_current_user] = ( + mock_get_current_user + ) + + # Mock the database add and commit operations + db_session_mock.add.return_value = None + db_session_mock.commit.return_value = None + db_session_mock.refresh.return_value = None + + blog_data = { + "title": "Past Scheduled Blog", + "content": "Test Content", + "image_url": "http://example.com/image.png", + "tags": ["test"], + "excerpt": "Test Excerpt", + "scheduled_at": past_time.isoformat(), + } + + response = client.post( + "/api/v1/blogs/", + json=blog_data, + headers={"Authorization": "Bearer token"}, + ) + + assert response.status_code == 400 + assert "Scheduled time must be in the future." in response.json()["message"] + + +def test_create_scheduled_blog_unauthenticated(client, db_session_mock): + # Remove super admin override to test unauthorized access + app.dependency_overrides[user_service.get_current_user] = lambda: None + scheduled_time = datetime.now() + timedelta(minutes=1) + + blog_data = { + "title": "Past Scheduled Blog", + "content": "Test Content", + "image_url": "http://example.com/image.png", + "tags": ["test"], + "excerpt": "Test Excerpt", + "scheduled_at": scheduled_time.isoformat(), + } + + response = client.post("/api/v1/blogs/", json=blog_data) + + assert response.status_code == 401 + assert "Not authenticated" in response.json()["message"] + + +def test_scheduled_blog_is_published_after_scheduled_time(client, db_session_mock): + # Setup initial time + current_time = datetime.now(timezone.utc) + scheduled_time = current_time + timedelta(minutes=1) + + # Create a mock blog + mock_blog = Blog( + id=str(uuid7()), + title="Scheduled Test Blog", + content="Test Content", + image_url="http://example.com/image.png", + tags=["test", "scheduled"], + excerpt="Test Excerpt", + scheduled_at=scheduled_time, + status=BlogStatus.PENDING, + author_id=str(uuid7()), + is_deleted=False + ) + + # Mock the database query to return our blog + def mock_query_filter(*args): + mock = MagicMock() + mock.all.return_value = [mock_blog] + return mock + + db_session_mock.query.return_value.filter.side_effect = mock_query_filter + + # Verify blog status is pending + assert mock_blog.status == BlogStatus.PENDING + + # Create scheduler with mocked db + from api.v1.services.blog_scheduler import BlogScheduler + scheduler = BlogScheduler(app) + scheduler.db = db_session_mock # Use our mocked db + + # Move time forward and run the scheduler + with freeze_time(scheduled_time + timedelta(minutes=1)): + scheduler.publish_schedule_blog() + # Verify commit was called + db_session_mock.commit.assert_called_once() + # Verify blog status was updated + assert mock_blog.status == BlogStatus.PUBLISHED + + + +def test_retrieve_scheduled_blogs(client, db_session_mock): + # Setup test data + scheduled_time = datetime.now() + timedelta(minutes=1) + blog_data = { + "title": "Scheduled Test Blog", + "content": "Test Content", + "image_url": "http://example.com/image.png", + "tags": ["test", "scheduled"], + "excerpt": "Test Excerpt", + "scheduled_at": scheduled_time.isoformat(), + } + # Mock the database query to return our blog + def mock_query_filter(*args): + mock = MagicMock() + mock.all.return_value = [mock_blog] + return mock + + + app.dependency_overrides[user_service.get_current_user] = ( + mock_get_current_user + ) + + # Create a mock blog instance from blog_data + mock_blog = Blog(**blog_data) + + # Mock the database operations + db_session_mock.add.return_value = None + db_session_mock.commit.return_value = None + db_session_mock.refresh.return_value = None + db_session_mock.query.return_value.filter.side_effect = mock_query_filter + + # Make the request + response = client.get( + "/api/v1/blogs/scheduled", + headers={"Authorization": "Bearer token"}, + ) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["message"] == "Scheduled blogs retrieved successfully" + assert response_data["data"][0]["title"] == mock_blog.title