Monorepo: consolidate 7 repos into one
Combines shared, blog, market, cart, events, federation, and account into a single repository. Eliminates submodule sync, sibling model copying at build time, and per-app CI orchestration. Changes: - Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs - Remove stale sibling model copies from each app - Update all 6 Dockerfiles for monorepo build context (root = .) - Add build directives to docker-compose.yml - Add single .gitea/workflows/ci.yml with change detection - Add .dockerignore for monorepo build context - Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
60
blog/bp/post/services/entry_associations.py
Normal file
60
blog/bp/post/services/entry_associations.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
post = await services.blog.get_post_by_id(session, post_id)
|
||||
if not post:
|
||||
return False, "Post not found"
|
||||
|
||||
is_associated = await services.calendar.toggle_entry_post(
|
||||
session, entry_id, "post", post_id,
|
||||
)
|
||||
return is_associated, 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.
|
||||
"""
|
||||
return await services.calendar.entry_ids_for_content(session, "post", post_id)
|
||||
|
||||
|
||||
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 (CalendarEntryDTOs), total_count, and has_more.
|
||||
"""
|
||||
entries, has_more = await services.calendar.associated_entries(
|
||||
session, "post", post_id, page,
|
||||
)
|
||||
total_count = len(entries) + (page - 1) * per_page
|
||||
if has_more:
|
||||
total_count += 1 # at least one more
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"total_count": total_count,
|
||||
"has_more": has_more,
|
||||
"page": page,
|
||||
}
|
||||
61
blog/bp/post/services/markets.py
Normal file
61
blog/bp/post/services/markets.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.page_config import PageConfig
|
||||
from shared.contracts.dtos import MarketPlaceDTO
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
class MarketError(ValueError):
|
||||
"""Base error for market service operations."""
|
||||
|
||||
|
||||
def slugify(value: str, max_len: int = 255) -> str:
|
||||
if value is None:
|
||||
value = ""
|
||||
value = unicodedata.normalize("NFKD", value)
|
||||
value = value.encode("ascii", "ignore").decode("ascii")
|
||||
value = value.lower()
|
||||
value = value.replace("/", "-")
|
||||
value = re.sub(r"[^a-z0-9]+", "-", value)
|
||||
value = re.sub(r"-{2,}", "-", value)
|
||||
value = value.strip("-")[:max_len].strip("-")
|
||||
return value or "market"
|
||||
|
||||
|
||||
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlaceDTO:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise MarketError("Market name must not be empty.")
|
||||
slug = slugify(name)
|
||||
|
||||
post = await services.blog.get_post_by_id(sess, post_id)
|
||||
if not post:
|
||||
raise MarketError(f"Post {post_id} does not exist.")
|
||||
|
||||
if not post.is_page:
|
||||
raise MarketError("Markets can only be created on pages, not posts.")
|
||||
|
||||
pc = (await sess.execute(
|
||||
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
|
||||
)).scalar_one_or_none()
|
||||
if pc is None or not (pc.features or {}).get("market"):
|
||||
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
|
||||
|
||||
try:
|
||||
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
|
||||
except ValueError as e:
|
||||
raise MarketError(str(e)) from e
|
||||
|
||||
|
||||
async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
||||
post = await services.blog.get_post_by_slug(sess, post_slug)
|
||||
if not post:
|
||||
return False
|
||||
|
||||
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)
|
||||
42
blog/bp/post/services/post_data.py
Normal file
42
blog/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
blog/bp/post/services/post_operations.py
Normal file
58
blog/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