100 lines
2.7 KiB
Python
100 lines
2.7 KiB
Python
|
|
"""
|
||
|
|
Service layer for BlogPost CRUD operations.
|
||
|
|
"""
|
||
|
|
import math
|
||
|
|
import nh3
|
||
|
|
from typing import Optional
|
||
|
|
from sqlalchemy import select, func
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.models.post import BlogPost
|
||
|
|
from app.schemas.post import PostCreate, PostUpdate, PaginatedPostsResponse, PostResponse
|
||
|
|
|
||
|
|
|
||
|
|
def _sanitize_body(body: str) -> str:
|
||
|
|
"""Strip dangerous HTML tags/attributes using nh3."""
|
||
|
|
return nh3.clean(body)
|
||
|
|
|
||
|
|
|
||
|
|
async def get_published_posts(
|
||
|
|
db: AsyncSession, page: int = 1, per_page: int = 10
|
||
|
|
) -> PaginatedPostsResponse:
|
||
|
|
offset = (page - 1) * per_page
|
||
|
|
|
||
|
|
count_result = await db.execute(
|
||
|
|
select(func.count()).select_from(BlogPost).where(BlogPost.published == True)
|
||
|
|
)
|
||
|
|
total = count_result.scalar_one()
|
||
|
|
|
||
|
|
result = await db.execute(
|
||
|
|
select(BlogPost)
|
||
|
|
.where(BlogPost.published == True)
|
||
|
|
.order_by(BlogPost.created_at.desc())
|
||
|
|
.offset(offset)
|
||
|
|
.limit(per_page)
|
||
|
|
)
|
||
|
|
items = list(result.scalars().all())
|
||
|
|
|
||
|
|
total_pages = math.ceil(total / per_page) if per_page > 0 else 0
|
||
|
|
|
||
|
|
return PaginatedPostsResponse(
|
||
|
|
items=[PostResponse.model_validate(p) for p in items],
|
||
|
|
total=total,
|
||
|
|
page=page,
|
||
|
|
per_page=per_page,
|
||
|
|
total_pages=total_pages,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def get_post_by_slug(
|
||
|
|
db: AsyncSession, slug: str, published_only: bool = True
|
||
|
|
) -> Optional[BlogPost]:
|
||
|
|
stmt = select(BlogPost).where(BlogPost.slug == slug)
|
||
|
|
if published_only:
|
||
|
|
stmt = stmt.where(BlogPost.published == True)
|
||
|
|
result = await db.execute(stmt)
|
||
|
|
return result.scalars().first()
|
||
|
|
|
||
|
|
|
||
|
|
async def create_post(db: AsyncSession, data: PostCreate) -> BlogPost:
|
||
|
|
post = BlogPost(
|
||
|
|
title=data.title,
|
||
|
|
slug=data.slug,
|
||
|
|
excerpt=data.excerpt,
|
||
|
|
body=_sanitize_body(data.body),
|
||
|
|
author=data.author,
|
||
|
|
featured_image_url=data.featured_image_url,
|
||
|
|
tags=data.tags,
|
||
|
|
published=data.published,
|
||
|
|
)
|
||
|
|
db.add(post)
|
||
|
|
await db.flush()
|
||
|
|
await db.refresh(post)
|
||
|
|
return post
|
||
|
|
|
||
|
|
|
||
|
|
async def update_post(db: AsyncSession, slug: str, data: PostUpdate) -> Optional[BlogPost]:
|
||
|
|
post = await get_post_by_slug(db, slug, published_only=False)
|
||
|
|
if post is None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
update_data = data.model_dump(exclude_unset=True)
|
||
|
|
if "body" in update_data and update_data["body"] is not None:
|
||
|
|
update_data["body"] = _sanitize_body(update_data["body"])
|
||
|
|
|
||
|
|
for field, value in update_data.items():
|
||
|
|
setattr(post, field, value)
|
||
|
|
|
||
|
|
await db.flush()
|
||
|
|
await db.refresh(post)
|
||
|
|
return post
|
||
|
|
|
||
|
|
|
||
|
|
async def delete_post(db: AsyncSession, slug: str) -> bool:
|
||
|
|
post = await get_post_by_slug(db, slug, published_only=False)
|
||
|
|
if post is None:
|
||
|
|
return False
|
||
|
|
await db.delete(post)
|
||
|
|
await db.flush()
|
||
|
|
return True
|