feat: initialize blog app with blueprints and templates
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Extract blog-specific code from the coop monolith into a standalone repository. Includes auth, blog, post, admin, menu_items, snippets blueprints, associated templates, Dockerfile (APP_MODULE=app:app), entrypoint, and Gitea CI workflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
143
bp/post/services/entry_associations.py
Normal file
143
bp/post/services/entry_associations.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from models.calendars import CalendarEntry, CalendarEntryPost, Calendar
|
||||
from models.ghost_content import Post
|
||||
|
||||
|
||||
async def toggle_entry_association(
|
||||
session: AsyncSession,
|
||||
post_id: int,
|
||||
entry_id: int
|
||||
) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Toggle association between a post and calendar entry.
|
||||
Returns (is_now_associated, error_message).
|
||||
"""
|
||||
# Check if entry exists (don't filter by deleted_at - allow associating with any entry)
|
||||
entry = await session.scalar(
|
||||
select(CalendarEntry).where(CalendarEntry.id == entry_id)
|
||||
)
|
||||
if not entry:
|
||||
return False, f"Calendar entry {entry_id} not found in database"
|
||||
|
||||
# Check if post exists
|
||||
post = await session.scalar(
|
||||
select(Post).where(Post.id == post_id)
|
||||
)
|
||||
if not post:
|
||||
return False, "Post not found"
|
||||
|
||||
# Check if association already exists
|
||||
existing = await session.scalar(
|
||||
select(CalendarEntryPost).where(
|
||||
CalendarEntryPost.entry_id == entry_id,
|
||||
CalendarEntryPost.post_id == post_id,
|
||||
CalendarEntryPost.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Remove association (soft delete)
|
||||
existing.deleted_at = func.now()
|
||||
await session.flush()
|
||||
return False, None
|
||||
else:
|
||||
# Create association
|
||||
association = CalendarEntryPost(
|
||||
entry_id=entry_id,
|
||||
post_id=post_id
|
||||
)
|
||||
session.add(association)
|
||||
await session.flush()
|
||||
return True, None
|
||||
|
||||
|
||||
async def get_post_entry_ids(
|
||||
session: AsyncSession,
|
||||
post_id: int
|
||||
) -> set[int]:
|
||||
"""
|
||||
Get all entry IDs associated with this post.
|
||||
Returns a set of entry IDs.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(CalendarEntryPost.entry_id)
|
||||
.where(
|
||||
CalendarEntryPost.post_id == post_id,
|
||||
CalendarEntryPost.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
return set(result.scalars().all())
|
||||
|
||||
|
||||
async def get_associated_entries(
|
||||
session: AsyncSession,
|
||||
post_id: int,
|
||||
page: int = 1,
|
||||
per_page: int = 10
|
||||
) -> dict:
|
||||
"""
|
||||
Get paginated associated entries for this post.
|
||||
Returns dict with entries, total_count, and has_more.
|
||||
"""
|
||||
# Get all associated entry IDs
|
||||
entry_ids_result = await session.execute(
|
||||
select(CalendarEntryPost.entry_id)
|
||||
.where(
|
||||
CalendarEntryPost.post_id == post_id,
|
||||
CalendarEntryPost.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
entry_ids = set(entry_ids_result.scalars().all())
|
||||
|
||||
if not entry_ids:
|
||||
return {
|
||||
"entries": [],
|
||||
"total_count": 0,
|
||||
"has_more": False,
|
||||
"page": page,
|
||||
}
|
||||
|
||||
# Get total count
|
||||
from sqlalchemy import func
|
||||
total_count = len(entry_ids)
|
||||
|
||||
# Get paginated entries ordered by start_at desc
|
||||
# Only include confirmed entries
|
||||
offset = (page - 1) * per_page
|
||||
result = await session.execute(
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.id.in_(entry_ids),
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state == "confirmed" # Only confirmed entries in nav
|
||||
)
|
||||
.order_by(CalendarEntry.start_at.desc())
|
||||
.limit(per_page)
|
||||
.offset(offset)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
|
||||
# Recalculate total_count based on confirmed entries only
|
||||
total_count = len(entries) + offset # Rough estimate
|
||||
if len(entries) < per_page:
|
||||
total_count = offset + len(entries)
|
||||
|
||||
# Load calendar relationship for each entry
|
||||
for entry in entries:
|
||||
await session.refresh(entry, ["calendar"])
|
||||
if entry.calendar:
|
||||
await session.refresh(entry.calendar, ["post"])
|
||||
|
||||
has_more = len(entries) == per_page # More accurate check
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"total_count": total_count,
|
||||
"has_more": has_more,
|
||||
"page": page,
|
||||
}
|
||||
42
bp/post/services/post_data.py
Normal file
42
bp/post/services/post_data.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from ...blog.ghost_db import DBClient # adjust import path
|
||||
from sqlalchemy import select
|
||||
from models.ghost_content import PostLike
|
||||
from quart import g
|
||||
|
||||
async def post_data(slug, session, include_drafts=False):
|
||||
client = DBClient(session)
|
||||
posts = (await client.posts_by_slug(slug, include_drafts=include_drafts))
|
||||
|
||||
if not posts:
|
||||
# 404 page (you can make a template for this if you want)
|
||||
return None
|
||||
post, original_post = posts[0]
|
||||
|
||||
# Check if current user has liked this post
|
||||
is_liked = False
|
||||
if g.user:
|
||||
liked_record = await session.scalar(
|
||||
select(PostLike).where(
|
||||
PostLike.user_id == g.user.id,
|
||||
PostLike.post_id == post["id"],
|
||||
PostLike.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
is_liked = liked_record is not None
|
||||
|
||||
# Add is_liked to post dict
|
||||
post["is_liked"] = is_liked
|
||||
|
||||
tags=await client.list_tags(
|
||||
limit=50000
|
||||
) # <-- new
|
||||
authors=await client.list_authors(
|
||||
limit=50000
|
||||
)
|
||||
|
||||
return {
|
||||
"post": post,
|
||||
"original_post": original_post,
|
||||
"tags": tags,
|
||||
"authors": authors,
|
||||
}
|
||||
58
bp/post/services/post_operations.py
Normal file
58
bp/post/services/post_operations.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.ghost_content import Post, PostLike
|
||||
|
||||
|
||||
async def toggle_post_like(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
post_id: int,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Toggle a post like for a given user using soft deletes.
|
||||
Returns (liked_state, error_message).
|
||||
- If error_message is not None, an error occurred.
|
||||
- liked_state indicates whether post is now liked (True) or unliked (False).
|
||||
"""
|
||||
|
||||
# Verify post exists
|
||||
post_exists = await session.scalar(
|
||||
select(Post.id).where(Post.id == post_id, Post.deleted_at.is_(None))
|
||||
)
|
||||
if not post_exists:
|
||||
return False, "Post not found"
|
||||
|
||||
# Check if like exists (not deleted)
|
||||
existing = await session.scalar(
|
||||
select(PostLike).where(
|
||||
PostLike.user_id == user_id,
|
||||
PostLike.post_id == post_id,
|
||||
PostLike.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Unlike: soft delete the like
|
||||
await session.execute(
|
||||
update(PostLike)
|
||||
.where(
|
||||
PostLike.user_id == user_id,
|
||||
PostLike.post_id == post_id,
|
||||
PostLike.deleted_at.is_(None),
|
||||
)
|
||||
.values(deleted_at=func.now())
|
||||
)
|
||||
return False, None
|
||||
else:
|
||||
# Like: add a new like
|
||||
new_like = PostLike(
|
||||
user_id=user_id,
|
||||
post_id=post_id,
|
||||
)
|
||||
session.add(new_like)
|
||||
return True, None
|
||||
Reference in New Issue
Block a user