Update admin theme/layout and refresh README details.
Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
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