Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Content module."""
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ContentPage(Base):
|
||||
__tablename__ = "content_pages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
slug: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft", index=True)
|
||||
author_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.content.models import ContentPage
|
||||
|
||||
ALLOWED_PAGE_STATUSES = {"draft", "published"}
|
||||
|
||||
|
||||
def _detach(db, instance):
|
||||
db.refresh(instance)
|
||||
db.expunge(instance)
|
||||
return instance
|
||||
|
||||
|
||||
def list_published_pages() -> list[ContentPage]:
|
||||
with session_scope() as db:
|
||||
pages = list(
|
||||
db.scalars(select(ContentPage).where(ContentPage.status == "published").order_by(ContentPage.slug))
|
||||
)
|
||||
return [_detach(db, page) for page in pages]
|
||||
|
||||
|
||||
def list_all_pages() -> list[ContentPage]:
|
||||
with session_scope() as db:
|
||||
pages = list(db.scalars(select(ContentPage).order_by(ContentPage.slug)))
|
||||
return [_detach(db, page) for page in pages]
|
||||
|
||||
|
||||
def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None:
|
||||
with session_scope() as db:
|
||||
page = db.scalar(select(ContentPage).where(ContentPage.slug == slug))
|
||||
if not page:
|
||||
return None
|
||||
if include_draft or page.status == "published":
|
||||
return _detach(db, page)
|
||||
return None
|
||||
|
||||
|
||||
def get_page_by_id(page_id: str) -> ContentPage | None:
|
||||
with session_scope() as db:
|
||||
page = db.get(ContentPage, page_id)
|
||||
if not page:
|
||||
return None
|
||||
return _detach(db, page)
|
||||
|
||||
|
||||
def create_page(
|
||||
slug: str,
|
||||
title: str,
|
||||
body: str,
|
||||
status: str,
|
||||
author_id: str,
|
||||
) -> ContentPage:
|
||||
if status not in ALLOWED_PAGE_STATUSES:
|
||||
raise ValueError("INVALID_STATUS")
|
||||
with session_scope() as db:
|
||||
page = ContentPage(
|
||||
id=str(uuid4()),
|
||||
slug=slug,
|
||||
title=title,
|
||||
body=body,
|
||||
status=status,
|
||||
author_id=author_id,
|
||||
published_at=datetime.now(UTC) if status == "published" else None,
|
||||
)
|
||||
db.add(page)
|
||||
db.flush()
|
||||
return _detach(db, page)
|
||||
|
||||
|
||||
def update_page(
|
||||
page_id: str,
|
||||
title: str | None,
|
||||
body: str | None,
|
||||
status: str | None,
|
||||
) -> ContentPage:
|
||||
with session_scope() as db:
|
||||
page = db.get(ContentPage, page_id)
|
||||
if not page:
|
||||
raise KeyError(page_id)
|
||||
if title:
|
||||
page.title = title
|
||||
if body:
|
||||
page.body = body
|
||||
if status:
|
||||
if status not in ALLOWED_PAGE_STATUSES:
|
||||
raise ValueError("INVALID_STATUS")
|
||||
page.status = status
|
||||
if status == "published" and page.published_at is None:
|
||||
page.published_at = datetime.now(UTC)
|
||||
page.updated_at = datetime.now(UTC)
|
||||
db.flush()
|
||||
return _detach(db, page)
|
||||
|
||||
|
||||
def delete_page(page_id: str) -> None:
|
||||
with session_scope() as db:
|
||||
page = db.get(ContentPage, page_id)
|
||||
if page:
|
||||
db.delete(page)
|
||||
@@ -0,0 +1,56 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.core.dependencies import require_admin
|
||||
from app.modules.content.schemas import ContentPageIn, ContentPagePatchIn
|
||||
from app.modules.content.service import (
|
||||
create_page,
|
||||
delete_page,
|
||||
get_page_by_slug,
|
||||
list_all_pages,
|
||||
list_published_pages,
|
||||
page_exists,
|
||||
page_to_dict,
|
||||
update_page,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/pages")
|
||||
async def list_pages_route():
|
||||
pages = list_published_pages()
|
||||
return {"data": [page_to_dict(page) for page in pages]}
|
||||
|
||||
|
||||
@router.get("/pages/manage/all")
|
||||
async def list_all_pages_route(_admin=Depends(require_admin)):
|
||||
pages = list_all_pages()
|
||||
return {"data": [page_to_dict(page) for page in pages]}
|
||||
|
||||
|
||||
@router.get("/pages/{slug}")
|
||||
async def get_page_route(slug: str):
|
||||
page = get_page_by_slug(slug)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND")
|
||||
return page_to_dict(page)
|
||||
|
||||
|
||||
@router.post("/pages")
|
||||
async def create_page_route(payload: ContentPageIn, admin=Depends(require_admin)):
|
||||
page = create_page(payload.slug, payload.title, payload.body, payload.status, admin.id)
|
||||
return page_to_dict(page)
|
||||
|
||||
|
||||
@router.patch("/pages/{page_id}")
|
||||
async def update_page_route(page_id: str, payload: ContentPagePatchIn, _admin=Depends(require_admin)):
|
||||
if not page_exists(page_id):
|
||||
raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND")
|
||||
page = update_page(page_id, payload.title, payload.body, payload.status)
|
||||
return page_to_dict(page)
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}")
|
||||
async def delete_page_route(page_id: str, _admin=Depends(require_admin)):
|
||||
delete_page(page_id)
|
||||
return {"message": "deleted"}
|
||||
@@ -0,0 +1,24 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ContentPageIn(BaseModel):
|
||||
slug: str = Field(min_length=2, max_length=120)
|
||||
title: str = Field(min_length=2, max_length=200)
|
||||
body: str
|
||||
status: Literal["draft", "published"] = "draft"
|
||||
|
||||
|
||||
class ContentPagePatchIn(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=2, max_length=200)
|
||||
body: str | None = None
|
||||
status: Literal["draft", "published"] | None = None
|
||||
|
||||
|
||||
class ContentPageOut(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
title: str
|
||||
body: str
|
||||
status: str
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import bleach
|
||||
|
||||
from app.modules.content import repository
|
||||
from app.modules.content.models import ContentPage
|
||||
|
||||
ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"]
|
||||
|
||||
|
||||
def sanitize_html(raw_html: str) -> str:
|
||||
return bleach.clean(
|
||||
raw_html,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes={"a": ["href"], "img": ["src", "alt"]},
|
||||
protocols=["http", "https", "mailto"],
|
||||
strip=True,
|
||||
)
|
||||
|
||||
|
||||
def page_to_dict(page: ContentPage) -> dict:
|
||||
return {
|
||||
"id": page.id,
|
||||
"slug": page.slug,
|
||||
"title": page.title,
|
||||
"body": page.body,
|
||||
"status": page.status,
|
||||
"author_id": page.author_id,
|
||||
"published_at": page.published_at.isoformat() if page.published_at else None,
|
||||
"updated_at": page.updated_at.isoformat() if page.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def list_published_pages() -> list[ContentPage]:
|
||||
return repository.list_published_pages()
|
||||
|
||||
|
||||
def list_all_pages() -> list[ContentPage]:
|
||||
return repository.list_all_pages()
|
||||
|
||||
|
||||
def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None:
|
||||
return repository.get_page_by_slug(slug, include_draft=include_draft)
|
||||
|
||||
|
||||
def create_page(slug: str, title: str, body: str, status: str, author_id: str) -> ContentPage:
|
||||
return repository.create_page(
|
||||
slug=slug,
|
||||
title=title,
|
||||
body=sanitize_html(body),
|
||||
status=status,
|
||||
author_id=author_id,
|
||||
)
|
||||
|
||||
|
||||
def update_page(page_id: str, title: str | None, body: str | None, status: str | None) -> ContentPage:
|
||||
return repository.update_page(
|
||||
page_id,
|
||||
title,
|
||||
sanitize_html(body) if body else None,
|
||||
status,
|
||||
)
|
||||
|
||||
|
||||
def delete_page(page_id: str) -> None:
|
||||
repository.delete_page(page_id)
|
||||
|
||||
|
||||
def page_exists(page_id: str) -> bool:
|
||||
return repository.get_page_by_id(page_id) is not None
|
||||
Reference in New Issue
Block a user