Skip to content

Commit 7278b03

Browse files
committed
🤣 CMS table and API for jokes, questions etc
1 parent e12aeef commit 7278b03

File tree

9 files changed

+340
-1
lines changed

9 files changed

+340
-1
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""add cms content table
2+
3+
Revision ID: 281723ba07be
4+
Revises: 156d8781d7b8
5+
Create Date: 2024-06-23 12:00:32.297761
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from sqlalchemy.dialects import postgresql
11+
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "281723ba07be"
16+
down_revision = "156d8781d7b8"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade():
22+
op.create_table(
23+
"cms_content",
24+
sa.Column(
25+
"id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False
26+
),
27+
sa.Column(
28+
"type",
29+
sa.Enum("JOKE", "QUESTION", "FACT", "QUOTE", name="enum_cms_content_type"),
30+
nullable=False,
31+
),
32+
sa.Column("content", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
33+
sa.Column(
34+
"created_at",
35+
sa.DateTime(),
36+
server_default=sa.text("CURRENT_TIMESTAMP"),
37+
nullable=False,
38+
),
39+
sa.Column(
40+
"updated_at",
41+
sa.DateTime(),
42+
server_default=sa.text("CURRENT_TIMESTAMP"),
43+
nullable=False,
44+
),
45+
sa.Column("user_id", sa.UUID(), nullable=True),
46+
sa.ForeignKeyConstraint(
47+
["user_id"], ["users.id"], name="fk_content_user", ondelete="CASCADE"
48+
),
49+
sa.PrimaryKeyConstraint("id"),
50+
)
51+
op.create_index(op.f("ix_cms_content_id"), "cms_content", ["id"], unique=True)
52+
op.create_index(op.f("ix_cms_content_type"), "cms_content", ["type"], unique=False)
53+
54+
55+
def downgrade():
56+
op.drop_index(op.f("ix_cms_content_type"), table_name="cms_content")
57+
op.drop_index(op.f("ix_cms_content_id"), table_name="cms_content")
58+
op.drop_table("cms_content")

app/api/cms.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Security
2+
from starlette import status
3+
from structlog import get_logger
4+
5+
from app import crud
6+
from app.api.common.pagination import PaginatedQueryParams
7+
from app.api.dependencies.async_db_dep import DBSessionDep
8+
from app.api.dependencies.security import (
9+
get_current_active_superuser_or_backend_service_account,
10+
)
11+
from app.models import ContentType
12+
from app.schemas.cms_content import CMSContentResponse
13+
from app.schemas.pagination import Pagination
14+
15+
logger = get_logger()
16+
17+
router = APIRouter(
18+
tags=["Digital Content Management System"],
19+
dependencies=[Security(get_current_active_superuser_or_backend_service_account)],
20+
)
21+
22+
23+
@router.get("/content/{content_type}", response_model=CMSContentResponse)
24+
async def get_cms_content(
25+
session: DBSessionDep,
26+
content_type: ContentType = Path(
27+
description="What type of content to return",
28+
),
29+
query: str | None = Query(
30+
None,
31+
description="A query string to match against content",
32+
),
33+
# user_id: UUID = Query(
34+
# None, description="Filter content that are associated with or created by a user"
35+
# ),
36+
jsonpath_match: str = Query(
37+
None,
38+
description="Filter using a JSONPath over the content. The resulting value must be a boolean expression.",
39+
),
40+
pagination: PaginatedQueryParams = Depends(),
41+
):
42+
"""
43+
Get a filtered and paginated list of content by content type.
44+
"""
45+
try:
46+
data = await crud.content.aget_all_with_optional_filters(
47+
session,
48+
content_type=content_type,
49+
query_string=query,
50+
# user=user,
51+
jsonpath_match=jsonpath_match,
52+
skip=pagination.skip,
53+
limit=pagination.limit,
54+
)
55+
except ValueError as e:
56+
raise HTTPException(
57+
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
58+
) from e
59+
60+
return CMSContentResponse(
61+
pagination=Pagination(**pagination.to_dict(), total=None), data=data
62+
)

app/api/external_api_router.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from app.api.booklists import public_router as booklist_router_public
66
from app.api.booklists import router as booklist_router
77
from app.api.classes import router as class_group_router
8+
from app.api.cms import router as cms_content_router
89
from app.api.collections import router as collections_router
910
from app.api.commerce import router as commerce_router
1011
from app.api.dashboards import router as dashboard_router
@@ -25,13 +26,13 @@
2526

2627
api_router = APIRouter()
2728

28-
2929
api_router.include_router(auth_router)
3030
api_router.include_router(user_router)
3131
api_router.include_router(author_router)
3232
api_router.include_router(booklist_router)
3333
api_router.include_router(booklist_router_public)
3434
api_router.include_router(class_group_router)
35+
api_router.include_router(cms_content_router)
3536
api_router.include_router(collections_router)
3637
api_router.include_router(commerce_router)
3738
api_router.include_router(dashboard_router)

app/crud/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
CRUDCollectionItemActivity,
1111
collection_item_activity,
1212
)
13+
from app.crud.content import CRUDContent, content
1314
from app.crud.edition import CRUDEdition, edition
1415
from app.crud.event import CRUDEvent, event
1516
from app.crud.illustrator import CRUDIllustrator, illustrator

app/crud/content.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from typing import Any
2+
3+
from sqlalchemy import cast, func
4+
from sqlalchemy.dialects.postgresql import JSONB
5+
from sqlalchemy.exc import DataError, ProgrammingError
6+
from sqlalchemy.ext.asyncio import AsyncSession
7+
from sqlalchemy.orm import Session
8+
from structlog import get_logger
9+
10+
from app.crud import CRUDBase
11+
from app.models import CMSContent, ContentType, User
12+
13+
logger = get_logger()
14+
15+
16+
class CRUDContent(CRUDBase[CMSContent, Any, Any]):
17+
def get_all_with_optional_filters_query(
18+
self,
19+
db: Session,
20+
content_type: ContentType | None = None,
21+
query_string: str | None = None,
22+
user: User | None = None,
23+
jsonpath_match: str = None,
24+
):
25+
query = self.get_all_query(db=db)
26+
27+
if content_type is not None:
28+
query = query.where(CMSContent.type == content_type)
29+
30+
if user is not None:
31+
query = query.where(CMSContent.user == user)
32+
33+
if jsonpath_match is not None:
34+
# Apply the jsonpath filter to the content field
35+
query = query.where(
36+
func.jsonb_path_match(
37+
cast(CMSContent.content, JSONB), jsonpath_match
38+
).is_(True)
39+
)
40+
41+
return query
42+
43+
async def aget_all_with_optional_filters(
44+
self,
45+
db: AsyncSession,
46+
content_type: ContentType | None = None,
47+
query_string: str | None = None,
48+
user: User | None = None,
49+
jsonpath_match: str | None = None,
50+
skip: int = 0,
51+
limit: int = 100,
52+
):
53+
optional_filters = {
54+
"query_string": query_string,
55+
"content_type": content_type,
56+
"user": user,
57+
"jsonpath_match": jsonpath_match,
58+
}
59+
logger.debug("Querying digital content", **optional_filters)
60+
61+
query = self.apply_pagination(
62+
self.get_all_with_optional_filters_query(db=db, **optional_filters),
63+
skip=skip,
64+
limit=limit,
65+
)
66+
try:
67+
return (await db.scalars(query)).all()
68+
except (ProgrammingError, DataError) as e:
69+
logger.error("Error querying events", error=e, **optional_filters)
70+
raise ValueError("Problem filtering content")
71+
72+
73+
content = CRUDContent(CMSContent)

app/json_schema/joke.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "Content",
4+
"type": "object",
5+
"properties": {
6+
"nodes": {
7+
"type": "array",
8+
"items": {
9+
"type": "object",
10+
"properties": {
11+
"id": {
12+
"type": "string",
13+
"description": "A unique identifier for the node."
14+
},
15+
"text": {
16+
"type": "string",
17+
"description": "The text of this node."
18+
},
19+
"image": {
20+
"type": "string",
21+
"format": "uri",
22+
"description": "URL to an image supporting this node."
23+
},
24+
"options": {
25+
"type": "array",
26+
"items": {
27+
"type": "object",
28+
"properties": {
29+
"optionText": {
30+
"type": "string",
31+
"description": "Text of an option presented to the user."
32+
},
33+
"optionImage": {
34+
"type": "string",
35+
"format": "uri",
36+
"description": "URL to an image supporting this option"
37+
},
38+
"nextNodeId": {
39+
"type": "string",
40+
"description": "ID of the next node if this option is chosen."
41+
}
42+
},
43+
"required": ["optionText", "nextNodeId"]
44+
},
45+
"description": "Options presented to the user at this node."
46+
}
47+
},
48+
"required": ["text"]
49+
},
50+
"description": "The content nodes"
51+
},
52+
"tags": {
53+
"type": "array",
54+
"items": {
55+
"type": "string"
56+
},
57+
"description": "Tags related to the content."
58+
}
59+
},
60+
"required": ["nodes"]
61+
}

app/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .booklist import BookList
33
from .booklist_work_association import BookListItem
44
from .class_group import ClassGroup
5+
from .cms_content import CMSContent, ContentType
56
from .collection import Collection
67
from .collection_item import CollectionItem
78
from .collection_item_activity import CollectionItemActivity

app/models/cms_content.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import uuid
2+
from datetime import datetime
3+
from typing import Optional
4+
5+
from fastapi_permissions import All, Allow
6+
from sqlalchemy import DateTime, Enum, ForeignKey, func, text
7+
from sqlalchemy.dialects.postgresql import JSONB, UUID
8+
from sqlalchemy.ext.mutable import MutableDict
9+
from sqlalchemy.orm import Mapped, mapped_column, relationship
10+
11+
from app.db import Base
12+
from app.schemas import CaseInsensitiveStringEnum
13+
14+
15+
class ContentType(CaseInsensitiveStringEnum):
16+
JOKE = "joke"
17+
QUESTION = "question"
18+
FACT = "fact"
19+
QUOTE = "quote"
20+
21+
22+
class CMSContent(Base):
23+
__tablename__ = "cms_content"
24+
25+
id: Mapped[uuid.UUID] = mapped_column(
26+
UUID(as_uuid=True),
27+
default=uuid.uuid4,
28+
server_default=text("gen_random_uuid()"),
29+
unique=True,
30+
primary_key=True,
31+
index=True,
32+
nullable=False,
33+
)
34+
35+
type: Mapped[ContentType] = mapped_column(
36+
Enum(ContentType, name="enum_cms_content_type"), nullable=False, index=True
37+
)
38+
39+
content: Mapped[Optional[dict]] = mapped_column(MutableDict.as_mutable(JSONB))
40+
41+
created_at: Mapped[datetime] = mapped_column(
42+
DateTime, nullable=False, server_default=func.current_timestamp()
43+
)
44+
updated_at: Mapped[datetime] = mapped_column(
45+
DateTime,
46+
server_default=func.current_timestamp(),
47+
default=datetime.utcnow,
48+
onupdate=datetime.utcnow,
49+
nullable=False,
50+
)
51+
52+
user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
53+
ForeignKey("users.id", name="fk_content_user", ondelete="CASCADE"),
54+
nullable=True,
55+
)
56+
user: Mapped[Optional["User"]] = relationship(
57+
"User", foreign_keys=[user_id], lazy="joined"
58+
)
59+
60+
def __repr__(self):
61+
return f"<CMSContent-{self.type} id={self.id}>"
62+
63+
def __acl__(self):
64+
"""
65+
Defines who can do what to the content
66+
"""
67+
68+
policies = [
69+
(Allow, "role:admin", All),
70+
(Allow, "role:user", "read"),
71+
]
72+
73+
return policies

app/schemas/cms_content.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from app.schemas.pagination import PaginatedResponse
2+
3+
4+
class CMSTypesResponse(PaginatedResponse):
5+
data: list[str]
6+
7+
8+
class CMSContentResponse(PaginatedResponse):
9+
data: list[str]

0 commit comments

Comments
 (0)