feat(community): add SharedStore base class with typed pg read/write methods
Implements SharedStore with get_post_by_slug, list_posts (with JSONB filter support), insert_post, and delete_post. _cursor_to_dict handles both real psycopg2 tuple rows and mock dict rows for clean unit tests. Also promotes community __init__.py imports from try/except guards to unconditional now that db.py and store.py both exist.
This commit is contained in:
parent
f74457d11f
commit
ffb95a5a30
3 changed files with 325 additions and 10 deletions
|
|
@ -2,15 +2,7 @@
|
||||||
# MIT License
|
# MIT License
|
||||||
|
|
||||||
from .models import CommunityPost
|
from .models import CommunityPost
|
||||||
|
|
||||||
try:
|
|
||||||
from .db import CommunityDB
|
from .db import CommunityDB
|
||||||
except ImportError:
|
|
||||||
CommunityDB = None # type: ignore[assignment,misc]
|
|
||||||
|
|
||||||
try:
|
|
||||||
from .store import SharedStore
|
from .store import SharedStore
|
||||||
except ImportError:
|
|
||||||
SharedStore = None # type: ignore[assignment,misc]
|
|
||||||
|
|
||||||
__all__ = ["CommunityDB", "CommunityPost", "SharedStore"]
|
__all__ = ["CommunityDB", "CommunityPost", "SharedStore"]
|
||||||
|
|
|
||||||
208
circuitforge_core/community/store.py
Normal file
208
circuitforge_core/community/store.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
# circuitforge_core/community/store.py
|
||||||
|
# MIT License
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from .models import CommunityPost
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .db import CommunityDB
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_post(row: dict) -> CommunityPost:
|
||||||
|
"""Convert a psycopg2 row dict to a CommunityPost.
|
||||||
|
|
||||||
|
JSONB columns (slots, dietary_tags, allergen_flags, flavor_molecules) come
|
||||||
|
back from psycopg2 as Python lists already — no json.loads() needed.
|
||||||
|
"""
|
||||||
|
return CommunityPost(
|
||||||
|
slug=row["slug"],
|
||||||
|
pseudonym=row["pseudonym"],
|
||||||
|
post_type=row["post_type"],
|
||||||
|
published=row["published"],
|
||||||
|
title=row["title"],
|
||||||
|
description=row.get("description"),
|
||||||
|
photo_url=row.get("photo_url"),
|
||||||
|
slots=row.get("slots") or [],
|
||||||
|
recipe_id=row.get("recipe_id"),
|
||||||
|
recipe_name=row.get("recipe_name"),
|
||||||
|
level=row.get("level"),
|
||||||
|
outcome_notes=row.get("outcome_notes"),
|
||||||
|
seasoning_score=row["seasoning_score"] or 0.0,
|
||||||
|
richness_score=row["richness_score"] or 0.0,
|
||||||
|
brightness_score=row["brightness_score"] or 0.0,
|
||||||
|
depth_score=row["depth_score"] or 0.0,
|
||||||
|
aroma_score=row["aroma_score"] or 0.0,
|
||||||
|
structure_score=row["structure_score"] or 0.0,
|
||||||
|
texture_profile=row.get("texture_profile") or "",
|
||||||
|
dietary_tags=row.get("dietary_tags") or [],
|
||||||
|
allergen_flags=row.get("allergen_flags") or [],
|
||||||
|
flavor_molecules=row.get("flavor_molecules") or [],
|
||||||
|
fat_pct=row.get("fat_pct"),
|
||||||
|
protein_pct=row.get("protein_pct"),
|
||||||
|
moisture_pct=row.get("moisture_pct"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_to_dict(cur, row) -> dict:
|
||||||
|
"""Convert a psycopg2 row tuple to a dict using cursor.description."""
|
||||||
|
if isinstance(row, dict):
|
||||||
|
return row
|
||||||
|
return {desc[0]: val for desc, val in zip(cur.description, row)}
|
||||||
|
|
||||||
|
|
||||||
|
class SharedStore:
|
||||||
|
"""Base class for product community stores.
|
||||||
|
|
||||||
|
Subclass this in each product:
|
||||||
|
class KiwiCommunityStore(SharedStore):
|
||||||
|
def list_posts_for_week(self, week_start: str) -> list[CommunityPost]: ...
|
||||||
|
|
||||||
|
All methods return new objects (immutable pattern). Never mutate rows in-place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: "CommunityDB") -> None:
|
||||||
|
self._db = db
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Reads
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_post_by_slug(self, slug: str) -> CommunityPost | None:
|
||||||
|
conn = self._db.getconn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM community_posts WHERE slug = %s LIMIT 1",
|
||||||
|
(slug,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _row_to_post(_cursor_to_dict(cur, row))
|
||||||
|
finally:
|
||||||
|
self._db.putconn(conn)
|
||||||
|
|
||||||
|
def list_posts(
|
||||||
|
self,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
post_type: str | None = None,
|
||||||
|
dietary_tags: list[str] | None = None,
|
||||||
|
allergen_exclude: list[str] | None = None,
|
||||||
|
source_product: str | None = None,
|
||||||
|
) -> list[CommunityPost]:
|
||||||
|
"""Paginated post list with optional filters.
|
||||||
|
|
||||||
|
dietary_tags: JSONB containment — posts must include ALL listed tags.
|
||||||
|
allergen_exclude: JSONB overlap exclusion — posts must NOT include any listed flag.
|
||||||
|
"""
|
||||||
|
conn = self._db.getconn()
|
||||||
|
try:
|
||||||
|
conditions = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if post_type:
|
||||||
|
conditions.append("post_type = %s")
|
||||||
|
params.append(post_type)
|
||||||
|
if dietary_tags:
|
||||||
|
import json
|
||||||
|
conditions.append("dietary_tags @> %s::jsonb")
|
||||||
|
params.append(json.dumps(dietary_tags))
|
||||||
|
if allergen_exclude:
|
||||||
|
import json
|
||||||
|
conditions.append("NOT (allergen_flags && %s::jsonb)")
|
||||||
|
params.append(json.dumps(allergen_exclude))
|
||||||
|
if source_product:
|
||||||
|
conditions.append("source_product = %s")
|
||||||
|
params.append(source_product)
|
||||||
|
|
||||||
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT * FROM community_posts {where} "
|
||||||
|
"ORDER BY published DESC LIMIT %s OFFSET %s",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [_row_to_post(_cursor_to_dict(cur, r)) for r in rows]
|
||||||
|
finally:
|
||||||
|
self._db.putconn(conn)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Writes
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def insert_post(self, post: CommunityPost) -> CommunityPost:
|
||||||
|
"""Insert a new community post. Returns the inserted post (unchanged — slug is the key)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
conn = self._db.getconn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO community_posts (
|
||||||
|
slug, pseudonym, post_type, published, title, description, photo_url,
|
||||||
|
slots, recipe_id, recipe_name, level, outcome_notes,
|
||||||
|
seasoning_score, richness_score, brightness_score,
|
||||||
|
depth_score, aroma_score, structure_score, texture_profile,
|
||||||
|
dietary_tags, allergen_flags, flavor_molecules,
|
||||||
|
fat_pct, protein_pct, moisture_pct, source_product
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s, %s, %s,
|
||||||
|
%s::jsonb, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s, %s, %s,
|
||||||
|
%s::jsonb, %s::jsonb, %s::jsonb,
|
||||||
|
%s, %s, %s, %s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
post.slug, post.pseudonym, post.post_type,
|
||||||
|
post.published, post.title, post.description, post.photo_url,
|
||||||
|
json.dumps(list(post.slots)),
|
||||||
|
post.recipe_id, post.recipe_name, post.level, post.outcome_notes,
|
||||||
|
post.seasoning_score, post.richness_score, post.brightness_score,
|
||||||
|
post.depth_score, post.aroma_score, post.structure_score,
|
||||||
|
post.texture_profile,
|
||||||
|
json.dumps(list(post.dietary_tags)),
|
||||||
|
json.dumps(list(post.allergen_flags)),
|
||||||
|
json.dumps(list(post.flavor_molecules)),
|
||||||
|
post.fat_pct, post.protein_pct, post.moisture_pct,
|
||||||
|
"kiwi",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return post
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._db.putconn(conn)
|
||||||
|
|
||||||
|
def delete_post(self, slug: str, pseudonym: str) -> bool:
|
||||||
|
"""Hard-delete a post. Only succeeds if pseudonym matches the author.
|
||||||
|
|
||||||
|
Returns True if a row was deleted, False if no matching row found.
|
||||||
|
"""
|
||||||
|
conn = self._db.getconn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM community_posts WHERE slug = %s AND pseudonym = %s",
|
||||||
|
(slug, pseudonym),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._db.putconn(conn)
|
||||||
115
tests/community/test_store.py
Normal file
115
tests/community/test_store.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# tests/community/test_store.py
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from circuitforge_core.community.store import SharedStore
|
||||||
|
from circuitforge_core.community.models import CommunityPost
|
||||||
|
|
||||||
|
|
||||||
|
def make_post_row() -> dict:
|
||||||
|
return {
|
||||||
|
"id": 1,
|
||||||
|
"slug": "kiwi-plan-test-pasta-week",
|
||||||
|
"pseudonym": "PastaWitch",
|
||||||
|
"post_type": "plan",
|
||||||
|
"published": datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
"title": "Pasta Week",
|
||||||
|
"description": None,
|
||||||
|
"photo_url": None,
|
||||||
|
"slots": [{"day": 0, "meal_type": "dinner", "recipe_id": 1, "recipe_name": "Spaghetti"}],
|
||||||
|
"recipe_id": None,
|
||||||
|
"recipe_name": None,
|
||||||
|
"level": None,
|
||||||
|
"outcome_notes": None,
|
||||||
|
"seasoning_score": 0.7,
|
||||||
|
"richness_score": 0.6,
|
||||||
|
"brightness_score": 0.3,
|
||||||
|
"depth_score": 0.5,
|
||||||
|
"aroma_score": 0.4,
|
||||||
|
"structure_score": 0.8,
|
||||||
|
"texture_profile": "chewy",
|
||||||
|
"dietary_tags": ["vegetarian"],
|
||||||
|
"allergen_flags": ["gluten"],
|
||||||
|
"flavor_molecules": [1234],
|
||||||
|
"fat_pct": 12.5,
|
||||||
|
"protein_pct": 10.0,
|
||||||
|
"moisture_pct": 55.0,
|
||||||
|
"source_product": "kiwi",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db():
|
||||||
|
db = MagicMock()
|
||||||
|
conn = MagicMock()
|
||||||
|
cur = MagicMock()
|
||||||
|
db.getconn.return_value = conn
|
||||||
|
conn.cursor.return_value.__enter__.return_value = cur
|
||||||
|
return db, conn, cur
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_store_get_post_by_slug(mock_db):
|
||||||
|
db, conn, cur = mock_db
|
||||||
|
cur.fetchone.return_value = make_post_row()
|
||||||
|
cur.description = [(col,) for col in make_post_row().keys()]
|
||||||
|
|
||||||
|
store = SharedStore(db)
|
||||||
|
post = store.get_post_by_slug("kiwi-plan-test-pasta-week")
|
||||||
|
|
||||||
|
assert post is not None
|
||||||
|
assert isinstance(post, CommunityPost)
|
||||||
|
assert post.slug == "kiwi-plan-test-pasta-week"
|
||||||
|
assert post.pseudonym == "PastaWitch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_store_get_post_by_slug_not_found(mock_db):
|
||||||
|
db, conn, cur = mock_db
|
||||||
|
cur.fetchone.return_value = None
|
||||||
|
|
||||||
|
store = SharedStore(db)
|
||||||
|
post = store.get_post_by_slug("does-not-exist")
|
||||||
|
assert post is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_store_list_posts_returns_list(mock_db):
|
||||||
|
db, conn, cur = mock_db
|
||||||
|
row = make_post_row()
|
||||||
|
cur.fetchall.return_value = [row]
|
||||||
|
cur.description = [(col,) for col in row.keys()]
|
||||||
|
|
||||||
|
store = SharedStore(db)
|
||||||
|
posts = store.list_posts(limit=10, offset=0)
|
||||||
|
|
||||||
|
assert isinstance(posts, list)
|
||||||
|
assert len(posts) == 1
|
||||||
|
assert posts[0].slug == "kiwi-plan-test-pasta-week"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_store_delete_post(mock_db):
|
||||||
|
db, conn, cur = mock_db
|
||||||
|
cur.rowcount = 1
|
||||||
|
|
||||||
|
store = SharedStore(db)
|
||||||
|
deleted = store.delete_post(slug="kiwi-plan-test-pasta-week", pseudonym="PastaWitch")
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_store_delete_post_wrong_owner(mock_db):
|
||||||
|
db, conn, cur = mock_db
|
||||||
|
cur.rowcount = 0
|
||||||
|
|
||||||
|
store = SharedStore(db)
|
||||||
|
deleted = store.delete_post(slug="kiwi-plan-test-pasta-week", pseudonym="WrongUser")
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_store_returns_connection_on_error(mock_db):
|
||||||
|
db, conn, cur = mock_db
|
||||||
|
cur.fetchone.side_effect = Exception("DB error")
|
||||||
|
|
||||||
|
store = SharedStore(db)
|
||||||
|
with pytest.raises(Exception, match="DB error"):
|
||||||
|
store.get_post_by_slug("any-slug")
|
||||||
|
|
||||||
|
# Connection must be returned to pool even on error
|
||||||
|
db.putconn.assert_called_once_with(conn)
|
||||||
Loading…
Reference in a new issue