From 12b3ff37964dc87e169b7360ecd9257409fb0ade Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 12:56:27 +0100 Subject: [PATCH 01/18] feat: Implemented blog scheduling logic --- api/v1/services/blog.py | 67 +++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 46c02b147..9fcb45b2b 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 Optional from fastapi import HTTPException, status @@ -8,7 +10,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 class BlogService: @@ -21,6 +23,18 @@ def create(self, db: Session, 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 + db.add(new_blogpost) db.commit() db.refresh(new_blogpost) @@ -40,6 +54,18 @@ def fetch(self, blog_id: str): raise HTTPException(status_code=404, detail="Post not found") return blog_post + 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, @@ -80,9 +106,7 @@ def create_blog_like( self, db: Session, blog_id: str, user_id: str, ip_address: str = None ): """Create new blog like.""" - blog_like = BlogLike( - blog_id=blog_id, user_id=user_id, ip_address=ip_address - ) + blog_like = BlogLike(blog_id=blog_id, user_id=user_id, ip_address=ip_address) db.add(blog_like) db.commit() db.refresh(blog_like) @@ -103,9 +127,7 @@ def create_blog_dislike( 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 @@ -117,7 +139,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): existing_like = self.fetch_blog_like(blog.id, user.id) if isinstance(existing_like, BlogLike): @@ -125,7 +147,7 @@ def check_user_already_liked_blog(self, blog: Blog, user: User): detail="You have already liked this blog post", status_code=status.HTTP_403_FORBIDDEN, ) - + def check_user_already_disliked_blog(self, blog: Blog, user: User): existing_dislike = self.fetch_blog_dislike(blog.id, user.id) if isinstance(existing_dislike, BlogDislike): @@ -133,10 +155,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 @@ -146,19 +170,19 @@ def delete_opposite_blog_like_or_dislike(self, blog: Blog, user: User, creating: if creating == "like": existing_dislike = self.fetch_blog_dislike(blog.id, user.id) if existing_dislike: - # delete, but do not commit yet. Allow everything + # delete, but do not commit yet. Allow everything # to be commited after the actual like is created self.db.delete(existing_dislike) elif creating == "dislike": existing_like = self.fetch_blog_like(blog.id, user.id) if existing_like: - # delete, but do not commit yet. Allow everything + # delete, but do not commit yet. Allow everything # to be commited after the actual dislike is created self.db.delete(existing_like) 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: @@ -211,9 +235,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) @@ -234,7 +256,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 @@ -258,13 +281,13 @@ def delete(self, blog_like_id: str, user_id: str): if blog_like.user_id != user_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Insufficient permission" + detail="Insufficient permission", ) self.db.delete(blog_like) self.db.commit() - + class BlogDislikeService: """BlogDislike service functionality""" @@ -283,7 +306,7 @@ def delete(self, blog_dislike_id: str, user_id: str): if blog_dislike.user_id != user_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Insufficient permission" + detail="Insufficient permission", ) self.db.delete(blog_dislike) From 26fc471c385fa5410275eead4267bca016680d4d Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 12:59:06 +0100 Subject: [PATCH 02/18] feat: Add scheduling-related field to BlogResponse schema --- api/v1/schemas/blog.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/v1/schemas/blog.py b/api/v1/schemas/blog.py index 70d4f730c..fdbf166ad 100644 --- a/api/v1/schemas/blog.py +++ b/api/v1/schemas/blog.py @@ -1,6 +1,7 @@ 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 @@ -11,12 +12,15 @@ 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 @@ -35,6 +39,8 @@ class BlogResponse(BaseModel): excerpt: Optional[str] created_at: datetime updated_at: datetime + status: BlogStatus + scheduled_at: Optional[datetime] class Config: from_attributes = True From b92f9fe716ac81969feafdeb73c260c7225e85d5 Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:01:22 +0100 Subject: [PATCH 03/18] Blog Route Update - added endpoint for a user to retrieve their schedules blogs - modified create_blog endpoint access to user and not only super_admin --- api/v1/routes/blog.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index ca475a9be..ee4c4ec20 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -34,15 +34,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") + raise HTTPException(status_code=401, detail="Not authenticated") blog_service = BlogService(db) new_blogpost = blog_service.create(db=db, 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), ) @@ -50,16 +51,34 @@ 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""" + """Endpoint to get all blogs except scheduled blogs""" + blog_service = BlogService(db) + blogs = blog_service.fetch_all() return paginated_response( db=db, - model=Blog, + model=blogs, limit=limit, skip=skip, ) +@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)): """ From 4a7684307129fcac4b9b5d31d2d281f3ed0430bf Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:06:06 +0100 Subject: [PATCH 04/18] feat: add scheduled_at field and scheduling status enum --- api/v1/models/blog.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/v1/models/blog.py b/api/v1/models/blog.py index 96586da3b..8abb9f8ce 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 +from sqlalchemy import Column, DateTime, Enum, 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" From edbdcb49264655e12780ab123683a351b267af2a Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:08:31 +0100 Subject: [PATCH 05/18] feat: implement background blog scheduler service --- api/v1/services/blog_scheduler.py | 48 +++++++++++++++++++++++++++++++ main.py | 5 ++-- 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 api/v1/services/blog_scheduler.py 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 2ceb69dda..15840d3f4 100644 --- a/main.py +++ b/main.py @@ -20,13 +20,12 @@ from api.utils.logger import logger from api.v1.routes import api_version_one from api.utils.settings import settings -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''' - + setup_blog_scheduler(app) yield From a17d1f2c46c065ee3917a07a3f2abc28d7d5a151 Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:09:36 +0100 Subject: [PATCH 06/18] feat: modified fetch_all to return only published posts --- api/v1/services/blog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 9fcb45b2b..31cbe4fa8 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -43,7 +43,7 @@ def create(self, db: Session, 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): From 3b020b747d4ca9fcfedaa71fa31d8c935ea5a82c Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:10:02 +0100 Subject: [PATCH 07/18] test: comprehensive test for blog scheduiling features --- tests/v1/blog/test_create_scheduled_blog.py | 225 ++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 tests/v1/blog/test_create_scheduled_blog.py 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 From 8441a3103e0d88061719b935d0a58f0d4215cae7 Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:16:12 +0100 Subject: [PATCH 08/18] dependency: update dependencies --- requirements.txt | Bin 2032 -> 4342 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index d825842fef12227889b28ffb9506327cb2ed2d43..62f91a8fbd55a7ecb4ed616fc1450bea22a52237 100644 GIT binary patch literal 4342 zcmZvfOK%%T5QOI(AU}nImXhuG;Ddl52O|a&I6#n-K#>&5h>tZNk(nQ#q`t0g&MspR zYtihUepPk%?7#m^%Bt+jvK;kk%Lje#%1wE%=S}&(d{>6@QQ4QdH238kx?}16py#fq zdyvIN+3MsvbefRglxCo_34d$pK3y!`N^{(4t>tGU*{V!0R+@+{HMa7xla+~{d6|`w z{+^Y?yd2^L*52v4?JRC3C0BC1*Y7+jJLv3QvX$&)4an`jJA2UCts=q4N^#Ald(!u` zg|DmumE3nRjJt@_(?gtEsxq1Pb#<`1i!54I7W>2jCa&L;qM-V1&%14a){`txr18?7 z%sgE)r1Ow3L;g0bW^71T%JM}z`;cQ7bbWf-odo}#KI<;WH`1uJoW^OY^sLA6-^v0S zpy7&6B~3YAnnEl_1KFbd6=%u2Z+od2s4X#!0{^|xfN87p_a$yl@nqacvnJ$&$9IA3 zH1gQV<|OP8FIeL9ZKsjd-YF{dT6K%re;;1Z&&Usojp(TJv_lAQVA$6I<<+HvNSlbt z82_l#t$bl4*-d0=sp+aX3mfh-GZ4Js0TMV}d3G(|O5Q4uqq5nllF2l40vmXrNox`^ z&Nnd^oCn>>Y*?2^VPynn5iQkBYc=nA`6_$lMdo;t$A1ZnO@-`wS&;Q3l?dF{hy?_R>7-b?^%3gEH7sg~1IqTs*R?<3W zJ@Zpz*yqSAJ5uNC#mBfeZJDEF;5krtMeTAl*by1I`;pn&MqJ6=$B6Nu-%k1lgFM}_ zL{3JLxkZn|D{xwbU9&IqrX$GM)pEt_lvwHagJPP> zhK>Z5ZS-I*bMxLZ2iEF?jgUn~47LqopMp8wPuL?i;{hl4>W7))f0gf1*dM7$cp;7xk{Qg_G{hO;mB-MM0&8JZU->X5BZk zw3ZHtgOTfRCe#esXlL1EPO#6JPo5L5BmRleIkNd-7<%3m$hoz+i&|wyk4}UoXeA$N zH>1I?KGxS8*wuA+_Bg(=Af2#m>NeR z1wX0%N`IX<)+$F@c{?eaMTa~w!y;G3JA+xblNGZtIp|3Q^47{Zm%HB4)C#xqe-2Bn zyp4qncQHD)fA^!}?_=YD9u?gxr>s@&cBDiFW=yoR^6=y_EBQBbK$Gfw&-ZtU`G+m{ zDV#DgoUb>PYmJe7G9}T63$?4s+I@R{<}u>U`Dg@;ziW8z&HCEuHIH8ul(eSN=lb`f_F29zaLk<)CWPIF>y<0g4Qq?jYmYczl?UVnrove| z(szgC5lHqUs=Cx+I46cU<_&11nT}8M5_>$~@?R&Kd1{2*JNMkaP-#_-z6TaVj@6^txqr7oJ#eI9wjN?vY zt}XOGZo>G@+2a0S8hh89Wwp1Np-jN*+gSC$ZuWhDxZRwuIs70i(}-oG>LeSe>0LJ^ zedA*%xtEQTHOSG^0WE64)OHqVAa@PfRqWqUAe8rD^AC^XsDbPIDl6P6cw1)P_a`p8^`>i0?qp01 iFttTzpVnX}{LJX@2NwK3W~{Dx$v(;(3^K4Xm;VC`rEd=a literal 2032 zcmYjSO>f&c5WVxiNYIB9+Xo#26niK(K#MN0*i%4Lw8WYsS&~Yk{Q7;5a=HuTkfDad znfEc4#?Lv2Vd(2~{UGEXR#E4;4MSI->Yj(mOitQiXzRXi%fXU^H5aBdbtk0N%f%=r zv^`_ze3n^{Ro;S5!_d^ny20^g{4gBq1A~;KHzS|!>I1^fsVbXA`!zGC;nO)s-0qmt zh^IBr<;%9_to`F4nl4j%@dYj|!i3 z_TJ*DhtiwUF`KKw^#%owXir@kV!TJ(SJ&y4eRB*o%b0YooR5pN=7VK>^nim0}yG@7aO}DR2Z6v81nZKUtyI4(~Q;D6BaNAti{!$E+{+} zVy@ADXlF6S=Y=V19B9OZs26kFmPZLam8*Qz%x$1v#&;T7G0A? zGdf_=gB-I29|_z`)XA?=QL>L{_#lik%DbzXhz=(=38~ac5xU7~ly1^Ms*psPel_l= z?8<~FV9kQ05@7;i-xq-KP#}GlH@Jafeh`a{x6!}3%H%4b#a$53I9?0@RI5?RLGNrru=!YQloPvhsp#Zcd0%YjlaGUyW5QY&d0((VP^k=X#U*VF` z(9>`wdJcA-CX9>GcnJe2zW}hp3-81t(%n=jjhwV8AEvIl7$^57b*|4MY%;kb4N@DB zGwPH#y8}mBa29CQUFUdu19O>6i)-$`xPqHA<}Q4~*WY>V%Z( z^F66rKAlt$FHWyuWzC&&uU7}3a;b_nP>ou_(gfvRr5t)lq42)X#f4G*^r>V~LHiD= zijV*ynxnyqdH%pNT#+qi`se8xBT-|m_!UV2r)WgfzgCR2z$Z z#t)7O2%#hHgX|YBE84m(!Hv2@z?t)#u`GWE4ZYy67<7Yg_XvHy{NF-AfONr)@JP?1 zC0xyTEZ0zReZ?k;SjFfDe~7L3pdCfY`>Tn0#qTJYfWWY4d+{c-nz=H89gZloH+|6# h*zN*_ozXka5|p3`G1@>9epWEb9k~rSFgQcY{{p-0VlDsx From b9c8bb3368acb67b97c45a5668d93b1686dbb837 Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 12:56:27 +0100 Subject: [PATCH 09/18] feat: Implemented blog scheduling logic --- api/v1/services/blog.py | 51 ++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 58f2896e5..78a83da7a 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) @@ -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 From a51d9d922693893496e79319095372b5be246f0b Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 12:59:06 +0100 Subject: [PATCH 10/18] feat: Add scheduling-related field to BlogResponse schema --- api/v1/schemas/blog.py | 9 +++++++++ 1 file changed, 9 insertions(+) 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 From 1c027c9dbbf12b588e94e9c35d00529e2f412efc Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:01:22 +0100 Subject: [PATCH 11/18] Blog Route Update - added endpoint for a user to retrieve their schedules blogs - modified create_blog endpoint access to user and not only super_admin --- api/v1/routes/blog.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index fd5bc9afd..4306bc78f 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -37,15 +37,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") + raise HTTPException(status_code=401, detail="Not authenticated") blog_service = BlogService(db) new_blogpost = blog_service.create(db=db, 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,11 +54,13 @@ 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""" + """Endpoint to get all blogs except scheduled blogs""" + blog_service = BlogService(db) + blogs = blog_service.fetch_all() return paginated_response( db=db, - model=Blog, + model=blogs, limit=limit, skip=skip, filters={"is_deleted": False} #filter out soft-deleted blogs @@ -175,6 +178,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)): """ From 11af957baeb8c8a00eae200e9b350a0bee6e587b Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:06:06 +0100 Subject: [PATCH 12/18] feat: add scheduled_at field and scheduling status enum --- api/v1/models/blog.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/v1/models/blog.py b/api/v1/models/blog.py index ffd219bb1..84ffab26f 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, 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" From 039ca6c9c2dcaeafdcf3a17ee7a90b7a0c0fafd4 Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:08:31 +0100 Subject: [PATCH 13/18] feat: implement background blog scheduler service --- api/v1/services/blog_scheduler.py | 48 +++++++++++++++++++++++++++++++ main.py | 7 ++--- 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 api/v1/services/blog_scheduler.py 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 From 8a308bf10fbddb12968b5597676e94f4641f2b27 Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:09:36 +0100 Subject: [PATCH 14/18] feat: modified fetch_all to return only published posts --- api/v1/services/blog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 78a83da7a..d17d81090 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -69,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): From 284e8de26718d65ab41e616c8ba7b06a5b2c3ad7 Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:10:02 +0100 Subject: [PATCH 15/18] test: comprehensive test for blog scheduiling features --- tests/v1/blog/test_create_scheduled_blog.py | 225 ++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 tests/v1/blog/test_create_scheduled_blog.py 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 From 537163dbdf1b8e3dd1886cbe9ecb68228432f5b4 Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 13:16:12 +0100 Subject: [PATCH 16/18] dependency: update dependencies --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) 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 From 10411a78483810b95a4873472eac45bf034d1ad2 Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 15:24:41 +0100 Subject: [PATCH 17/18] refactor: get_all_blogs with pagination --- api/v1/routes/blog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index 4306bc78f..ed211b036 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -17,6 +17,7 @@ BlogCreate, BlogPostResponse, BlogRequest, + BlogStatus, BlogUpdateResponseModel, BlogLikeDislikeResponse, CommentRequest, @@ -58,11 +59,12 @@ def get_all_blogs(db: Session = Depends(get_db), limit: int = 10, skip: int = 0) blog_service = BlogService(db) blogs = blog_service.fetch_all() - return paginated_response( - db=db, - model=blogs, - limit=limit, - skip=skip, + paginated_blogs = blogs[skip: skip+limit] + + return success_response( + message="Blogs retrieved successfully", + status_code=200, + data=jsonable_encoder(paginated_blogs), filters={"is_deleted": False} #filter out soft-deleted blogs ) From b0b2411b8177322d2c2f83efc0b7c1511c1d6f2a Mon Sep 17 00:00:00 2001 From: Kehinde Bello Date: Sun, 2 Mar 2025 20:28:09 +0100 Subject: [PATCH 18/18] model: added missing imports --- api/v1/models/blog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1/models/blog.py b/api/v1/models/blog.py index 84ffab26f..c14211e3c 100644 --- a/api/v1/models/blog.py +++ b/api/v1/models/blog.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """The Blog Post Model.""" -from sqlalchemy import Column, DateTime, Enum, String, Text, ForeignKey, Boolean, text +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