feat(child): auto create parent-child conversations
This commit is contained in:
@@ -26,6 +26,7 @@ class ChildDAO(BaseDAO):
|
||||
child_name: str,
|
||||
child_gender: int = 2,
|
||||
child_birthday: Optional[date] = None,
|
||||
auto_commit: bool = True,
|
||||
) -> int:
|
||||
result = self.db.execute(
|
||||
text(
|
||||
@@ -38,7 +39,8 @@ class ChildDAO(BaseDAO):
|
||||
)
|
||||
child_id = inserted_primary_key(result)
|
||||
self._create_relation(user_id, child_id)
|
||||
self.commit()
|
||||
if auto_commit:
|
||||
self.commit()
|
||||
return child_id
|
||||
|
||||
def get_by_id(self, child_id: int) -> Optional[Mapping]:
|
||||
|
||||
@@ -3,10 +3,12 @@ from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from app.dao.child import ChildDAO
|
||||
from app.service.im import ensure_parent_child_conversation
|
||||
|
||||
|
||||
class ChildService:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
self.dao = ChildDAO(db)
|
||||
|
||||
def create(
|
||||
@@ -16,8 +18,24 @@ class ChildService:
|
||||
child_gender: int = 2,
|
||||
child_birthday: Optional[date] = None,
|
||||
) -> Mapping:
|
||||
child_id = self.dao.create(user_id, child_name, child_gender, child_birthday)
|
||||
return self.dao.get_by_id(child_id)
|
||||
try:
|
||||
child_id = self.dao.create(
|
||||
user_id,
|
||||
child_name,
|
||||
child_gender,
|
||||
child_birthday,
|
||||
auto_commit=False,
|
||||
)
|
||||
ensure_parent_child_conversation(
|
||||
self.db,
|
||||
parent_user_id=user_id,
|
||||
child_id=child_id,
|
||||
)
|
||||
self.db.commit()
|
||||
return self.dao.get_by_id(child_id)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def list_children(self, user_id: int, limit: int = 20, cursor: int = None) -> tuple[list, bool]:
|
||||
rows = self.dao.list_by_parent(user_id, limit, cursor)
|
||||
@@ -39,4 +57,4 @@ class ChildService:
|
||||
if not self.dao.has_access(child_id, user_id):
|
||||
raise PermissionError("No access to this child")
|
||||
self.dao.update(child_id, child_name, child_gender, child_birthday)
|
||||
return self.dao.get_by_id(child_id)
|
||||
return self.dao.get_by_id(child_id)
|
||||
|
||||
@@ -217,6 +217,28 @@ def create_parent_child_message(
|
||||
)
|
||||
|
||||
|
||||
def ensure_parent_child_conversation(
|
||||
db: Session,
|
||||
*,
|
||||
parent_user_id: int,
|
||||
child_id: int,
|
||||
) -> int:
|
||||
child_row = assert_parent_child_access(db, user_id=parent_user_id, child_id=child_id)
|
||||
parent_row = _get_parent_row(db, parent_user_id)
|
||||
if not parent_row:
|
||||
raise HTTPException(status_code=404, detail="parent not found")
|
||||
|
||||
return _get_or_create_conversation(
|
||||
db=db,
|
||||
conversation_type=PARENT_CHILD_CONVERSATION_TYPE,
|
||||
participant_a_type=CHILD_PARTICIPANT_TYPE,
|
||||
participant_a_id=str(child_id),
|
||||
participant_b_type=PARENT_PARTICIPANT_TYPE,
|
||||
participant_b_id=str(parent_user_id),
|
||||
pair_key=f"{child_id}:{parent_user_id}",
|
||||
)
|
||||
|
||||
|
||||
def create_device_message(
|
||||
db: Session,
|
||||
*,
|
||||
|
||||
@@ -60,6 +60,158 @@ def test_child_dao():
|
||||
assert child["child_name"] == "Test Child"
|
||||
|
||||
|
||||
def test_child_service_create_creates_parent_child_conversation():
|
||||
"""Creating a child should also create a parent-child conversation."""
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models import Base
|
||||
from app.service.child import ChildService
|
||||
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
|
||||
db.execute(text("INSERT INTO parents (openid, nickname, status) VALUES ('p_child_service', 'Parent A', 1)"))
|
||||
db.commit()
|
||||
user_id = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_child_service'")).scalar_one())
|
||||
|
||||
service = ChildService(db)
|
||||
child = service.create(user_id, "Kid Service")
|
||||
|
||||
conversation = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
conversation_type,
|
||||
participant_a_type,
|
||||
participant_a_id,
|
||||
participant_b_type,
|
||||
participant_b_id,
|
||||
pair_key,
|
||||
status,
|
||||
last_seq,
|
||||
message_count
|
||||
FROM im_conversations
|
||||
WHERE conversation_type = 2
|
||||
AND pair_key = :pair_key
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"pair_key": f"{child['child_id']}:{user_id}"},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
assert conversation is not None
|
||||
assert int(conversation["conversation_type"]) == 2
|
||||
assert int(conversation["participant_a_type"]) == 2
|
||||
assert str(conversation["participant_a_id"]) == str(child["child_id"])
|
||||
assert int(conversation["participant_b_type"]) == 1
|
||||
assert str(conversation["participant_b_id"]) == str(user_id)
|
||||
assert int(conversation["status"]) == 1
|
||||
assert int(conversation["last_seq"]) == 0
|
||||
assert int(conversation["message_count"]) == 0
|
||||
|
||||
|
||||
def test_parent_child_message_reuses_precreated_conversation():
|
||||
"""Parent message creation should reuse the conversation created with the child."""
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.models import Base
|
||||
from app.schemas.im import ParentChildMessageCreateRequest
|
||||
from app.service.child import ChildService
|
||||
from app.service.im import create_parent_child_message
|
||||
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
|
||||
db.execute(text("INSERT INTO parents (openid, nickname, status) VALUES ('p_msg_reuse', 'Parent B', 1)"))
|
||||
db.commit()
|
||||
user_id = int(db.execute(text("SELECT user_id FROM parents WHERE openid = 'p_msg_reuse'")).scalar_one())
|
||||
|
||||
service = ChildService(db)
|
||||
child = service.create(user_id, "Kid Reuse")
|
||||
child_id = int(child["child_id"])
|
||||
|
||||
precreated_conversation_id = int(
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id
|
||||
FROM im_conversations
|
||||
WHERE conversation_type = 2
|
||||
AND pair_key = :pair_key
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"pair_key": f"{child_id}:{user_id}"},
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
payload = ParentChildMessageCreateRequest(
|
||||
content_type=1,
|
||||
content_text="hello child",
|
||||
client_msg_id="parent-msg-reuse-001",
|
||||
)
|
||||
result = create_parent_child_message(
|
||||
db,
|
||||
parent_user_id=user_id,
|
||||
child_id=child_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
assert result.idempotent is False
|
||||
assert result.conversation_id == precreated_conversation_id
|
||||
|
||||
duplicate = create_parent_child_message(
|
||||
db,
|
||||
parent_user_id=user_id,
|
||||
child_id=child_id,
|
||||
payload=payload,
|
||||
)
|
||||
assert duplicate.idempotent is True
|
||||
assert duplicate.conversation_id == precreated_conversation_id
|
||||
|
||||
conversation = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT last_seq, message_count, last_message_preview
|
||||
FROM im_conversations
|
||||
WHERE id = :conversation_id
|
||||
"""
|
||||
),
|
||||
{"conversation_id": precreated_conversation_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
assert conversation is not None
|
||||
assert int(conversation["last_seq"]) == 1
|
||||
assert int(conversation["message_count"]) == 1
|
||||
assert conversation["last_message_preview"] == "hello child"
|
||||
|
||||
conversation_count = int(
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM im_conversations
|
||||
WHERE conversation_type = 2
|
||||
AND pair_key = :pair_key
|
||||
"""
|
||||
),
|
||||
{"pair_key": f"{child_id}:{user_id}"},
|
||||
).scalar_one()
|
||||
)
|
||||
assert conversation_count == 1
|
||||
|
||||
|
||||
def test_binding_dao():
|
||||
"""Test BindingDAO."""
|
||||
from sqlalchemy import create_engine
|
||||
@@ -285,6 +437,8 @@ if __name__ == "__main__":
|
||||
test_sqlite_connection()
|
||||
test_parent_dao()
|
||||
test_child_dao()
|
||||
test_child_service_create_creates_parent_child_conversation()
|
||||
test_parent_child_message_reuses_precreated_conversation()
|
||||
test_binding_dao()
|
||||
test_confirm_bind_upserts_parent_child_relation()
|
||||
test_direct_bind_reactivates_parent_child_relation()
|
||||
|
||||
@@ -24,6 +24,7 @@ class ChildDAO(BaseDAO):
|
||||
child_name: str,
|
||||
child_gender: int = 2,
|
||||
child_birthday: Optional[date] = None,
|
||||
auto_commit: bool = True,
|
||||
) -> int:
|
||||
result = await self.execute(
|
||||
"""
|
||||
@@ -34,7 +35,8 @@ class ChildDAO(BaseDAO):
|
||||
)
|
||||
child_id = inserted_primary_key(result)
|
||||
await self._create_relation(user_id, child_id)
|
||||
await self.commit()
|
||||
if auto_commit:
|
||||
await self.commit()
|
||||
return child_id
|
||||
|
||||
async def get_by_id(self, child_id: int) -> Optional[Mapping]:
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from banban.dao.child import ChildDAO
|
||||
from banban.dao.im import ImDAO
|
||||
from services.database_service_base import DatabaseServiceBase
|
||||
|
||||
|
||||
@@ -19,10 +20,24 @@ class ChildService(DatabaseServiceBase):
|
||||
) -> Mapping:
|
||||
db_session = await self.get_session()
|
||||
try:
|
||||
dao = ChildDAO(db_session)
|
||||
child_id = await dao.create(user_id, child_name, child_gender, child_birthday)
|
||||
child_dao = ChildDAO(db_session)
|
||||
im_dao = ImDAO(db_session)
|
||||
child_id = await child_dao.create(
|
||||
user_id,
|
||||
child_name,
|
||||
child_gender,
|
||||
child_birthday,
|
||||
auto_commit=False,
|
||||
)
|
||||
await im_dao.ensure_parent_child_conversation(
|
||||
parent_user_id=user_id,
|
||||
child_id=child_id,
|
||||
)
|
||||
await db_session.commit()
|
||||
return await self.get(child_id)
|
||||
return await child_dao.get_by_id(child_id)
|
||||
except Exception:
|
||||
await db_session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await db_session.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user