Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT]: Schedule blog post feature #1234

Open
wants to merge 19 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
9 changes: 7 additions & 2 deletions api/v1/models/blog.py
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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"
Expand Down
48 changes: 34 additions & 14 deletions api/v1/routes/blog.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
BlogCreate,
BlogPostResponse,
BlogRequest,
BlogStatus,
BlogUpdateResponseModel,
BlogLikeDislikeResponse,
CommentRequest,
Expand All @@ -37,30 +38,33 @@
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),
)


@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
Expand Down Expand Up @@ -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)):
"""
Expand Down
9 changes: 9 additions & 0 deletions api/v1/schemas/blog.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand All @@ -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
Expand Down
53 changes: 39 additions & 14 deletions api/v1/services/blog.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import asyncio
from datetime import datetime, timezone
from typing import Generic, TypeVar, Optional

from fastapi import HTTPException, status
Expand All @@ -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")

Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
48 changes: 48 additions & 0 deletions api/v1/services/blog_scheduler.py
Original file line number Diff line number Diff line change
@@ -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())

7 changes: 3 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading
Loading