feat(trash): exclude trashed rows from events/projects/milestones/rulebooks/embeddings reads + filtering tests
This commit is contained in:
@@ -133,7 +133,7 @@ async def semantic_search_notes(
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(NoteEmbedding, Note)
|
select(NoteEmbedding, Note)
|
||||||
.join(Note, NoteEmbedding.note_id == Note.id)
|
.join(Note, NoteEmbedding.note_id == Note.id)
|
||||||
.where(NoteEmbedding.user_id == user_id)
|
.where(NoteEmbedding.user_id == user_id, Note.deleted_at.is_(None))
|
||||||
)
|
)
|
||||||
if orphan_only:
|
if orphan_only:
|
||||||
stmt = stmt.where(Note.project_id.is_(None))
|
stmt = stmt.where(Note.project_id.is_(None))
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ async def get_event(user_id: int, event_id: int) -> Event | None:
|
|||||||
"""Return event owned by user_id, or None."""
|
"""Return event owned by user_id, or None."""
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
select(Event).where(
|
||||||
|
Event.id == event_id, Event.user_id == user_id, Event.deleted_at.is_(None)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -164,6 +166,7 @@ async def list_events(
|
|||||||
select(Event)
|
select(Event)
|
||||||
.where(
|
.where(
|
||||||
Event.user_id == user_id,
|
Event.user_id == user_id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
or_(
|
or_(
|
||||||
Event.recurrence.isnot(None),
|
Event.recurrence.isnot(None),
|
||||||
Event.start_dt <= date_to,
|
Event.start_dt <= date_to,
|
||||||
@@ -232,6 +235,7 @@ async def search_events(
|
|||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
where = [
|
where = [
|
||||||
Event.user_id == user_id,
|
Event.user_id == user_id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
or_(
|
or_(
|
||||||
Event.title.ilike(q),
|
Event.title.ilike(q),
|
||||||
Event.description.ilike(q),
|
Event.description.ilike(q),
|
||||||
@@ -337,6 +341,7 @@ async def find_events_by_query(user_id: int, query: str) -> list[Event]:
|
|||||||
upcoming = (await session.execute(
|
upcoming = (await session.execute(
|
||||||
select(Event).where(
|
select(Event).where(
|
||||||
Event.user_id == user_id,
|
Event.user_id == user_id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
Event.title.ilike(q),
|
Event.title.ilike(q),
|
||||||
Event.start_dt >= now,
|
Event.start_dt >= now,
|
||||||
).order_by(Event.start_dt)
|
).order_by(Event.start_dt)
|
||||||
@@ -346,6 +351,7 @@ async def find_events_by_query(user_id: int, query: str) -> list[Event]:
|
|||||||
past = (await session.execute(
|
past = (await session.execute(
|
||||||
select(Event).where(
|
select(Event).where(
|
||||||
Event.user_id == user_id,
|
Event.user_id == user_id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
Event.title.ilike(q),
|
Event.title.ilike(q),
|
||||||
Event.start_dt < now,
|
Event.start_dt < now,
|
||||||
).order_by(Event.start_dt.desc())
|
).order_by(Event.start_dt.desc())
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ async def create_milestone(
|
|||||||
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
|
async def get_milestone(user_id: int, milestone_id: int) -> Milestone | None:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Milestone).where(Milestone.id == milestone_id, Milestone.user_id == user_id)
|
select(Milestone).where(
|
||||||
|
Milestone.id == milestone_id, Milestone.user_id == user_id,
|
||||||
|
Milestone.deleted_at.is_(None),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@ async def list_milestones(
|
|||||||
query = select(Milestone).where(
|
query = select(Milestone).where(
|
||||||
Milestone.user_id == user_id,
|
Milestone.user_id == user_id,
|
||||||
Milestone.project_id == project_id,
|
Milestone.project_id == project_id,
|
||||||
|
Milestone.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
if status:
|
if status:
|
||||||
query = query.where(Milestone.status == status)
|
query = query.where(Milestone.status == status)
|
||||||
@@ -136,7 +140,10 @@ async def get_milestone_progress(milestone_id: int) -> dict:
|
|||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
rows = await session.execute(
|
rows = await session.execute(
|
||||||
select(Note.status, func.count(Note.id))
|
select(Note.status, func.count(Note.id))
|
||||||
.where(Note.milestone_id == milestone_id, Note.status.isnot(None))
|
.where(
|
||||||
|
Note.milestone_id == milestone_id, Note.status.isnot(None),
|
||||||
|
Note.deleted_at.is_(None),
|
||||||
|
)
|
||||||
.group_by(Note.status)
|
.group_by(Note.status)
|
||||||
)
|
)
|
||||||
status_counts: dict[str, int] = {}
|
status_counts: dict[str, int] = {}
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ async def create_project(
|
|||||||
async def get_project(user_id: int, project_id: int) -> Project | None:
|
async def get_project(user_id: int, project_id: int) -> Project | None:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Project).where(Project.id == project_id, Project.user_id == user_id)
|
select(Project).where(
|
||||||
|
Project.id == project_id, Project.user_id == user_id,
|
||||||
|
Project.deleted_at.is_(None),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
@@ -48,6 +51,7 @@ async def get_project_by_title(user_id: int, title: str) -> Project | None:
|
|||||||
select(Project).where(
|
select(Project).where(
|
||||||
Project.user_id == user_id,
|
Project.user_id == user_id,
|
||||||
func.lower(Project.title) == func.lower(title.strip()),
|
func.lower(Project.title) == func.lower(title.strip()),
|
||||||
|
Project.deleted_at.is_(None),
|
||||||
).limit(1)
|
).limit(1)
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
@@ -62,7 +66,9 @@ async def get_or_create_project(user_id: int, title: str) -> Project:
|
|||||||
|
|
||||||
async def list_projects(user_id: int, status: str | None = None) -> list[Project]:
|
async def list_projects(user_id: int, status: str | None = None) -> list[Project]:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
query = select(Project).where(Project.user_id == user_id)
|
query = select(Project).where(
|
||||||
|
Project.user_id == user_id, Project.deleted_at.is_(None)
|
||||||
|
)
|
||||||
if status:
|
if status:
|
||||||
query = query.where(Project.status == status)
|
query = query.where(Project.status == status)
|
||||||
query = query.order_by(Project.updated_at.desc())
|
query = query.order_by(Project.updated_at.desc())
|
||||||
@@ -111,6 +117,7 @@ async def get_project_summary(user_id: int, project_id: int) -> dict:
|
|||||||
Note.user_id == user_id,
|
Note.user_id == user_id,
|
||||||
Note.project_id == project_id,
|
Note.project_id == project_id,
|
||||||
Note.status.isnot(None),
|
Note.status.isnot(None),
|
||||||
|
Note.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.group_by(Note.status)
|
.group_by(Note.status)
|
||||||
)
|
)
|
||||||
@@ -127,6 +134,7 @@ async def get_project_summary(user_id: int, project_id: int) -> dict:
|
|||||||
Note.user_id == user_id,
|
Note.user_id == user_id,
|
||||||
Note.project_id == project_id,
|
Note.project_id == project_id,
|
||||||
Note.status.is_(None),
|
Note.status.is_(None),
|
||||||
|
Note.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
note_count = note_count_result or 0
|
note_count = note_count_result or 0
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async def list_rulebooks(user_id: int) -> list[Rulebook]:
|
|||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Rulebook)
|
select(Rulebook)
|
||||||
.where(Rulebook.owner_user_id == user_id)
|
.where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
|
||||||
.order_by(Rulebook.title)
|
.order_by(Rulebook.title)
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
@@ -52,6 +52,7 @@ async def get_rulebook(rulebook_id: int, user_id: int) -> Optional[Rulebook]:
|
|||||||
select(Rulebook).where(
|
select(Rulebook).where(
|
||||||
Rulebook.id == rulebook_id,
|
Rulebook.id == rulebook_id,
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
@@ -105,6 +106,7 @@ async def find_rulebook_by_title(
|
|||||||
select(Rulebook).where(
|
select(Rulebook).where(
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
Rulebook.title == title,
|
Rulebook.title == title,
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
@@ -123,6 +125,7 @@ async def _assert_rulebook_owned(session, rulebook_id: int, user_id: int) -> Non
|
|||||||
select(Rulebook).where(
|
select(Rulebook).where(
|
||||||
Rulebook.id == rulebook_id,
|
Rulebook.id == rulebook_id,
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if result.scalar_one_or_none() is None:
|
if result.scalar_one_or_none() is None:
|
||||||
@@ -152,7 +155,10 @@ async def list_topics(rulebook_id: int, user_id: int) -> list[RulebookTopic]:
|
|||||||
await _assert_rulebook_owned(session, rulebook_id, user_id)
|
await _assert_rulebook_owned(session, rulebook_id, user_id)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(RulebookTopic)
|
select(RulebookTopic)
|
||||||
.where(RulebookTopic.rulebook_id == rulebook_id)
|
.where(
|
||||||
|
RulebookTopic.rulebook_id == rulebook_id,
|
||||||
|
RulebookTopic.deleted_at.is_(None),
|
||||||
|
)
|
||||||
.order_by(RulebookTopic.order_index, RulebookTopic.title)
|
.order_by(RulebookTopic.order_index, RulebookTopic.title)
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
@@ -167,6 +173,8 @@ async def get_topic(topic_id: int, user_id: int) -> Optional[RulebookTopic]:
|
|||||||
.where(
|
.where(
|
||||||
RulebookTopic.id == topic_id,
|
RulebookTopic.id == topic_id,
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
|
RulebookTopic.deleted_at.is_(None),
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
@@ -226,6 +234,8 @@ async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
|
|||||||
.where(
|
.where(
|
||||||
RulebookTopic.id == topic_id,
|
RulebookTopic.id == topic_id,
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
|
RulebookTopic.deleted_at.is_(None),
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if result.scalar_one_or_none() is None:
|
if result.scalar_one_or_none() is None:
|
||||||
@@ -269,7 +279,12 @@ async def list_rules(
|
|||||||
select(Rule)
|
select(Rule)
|
||||||
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||||
.where(Rulebook.owner_user_id == user_id)
|
.where(
|
||||||
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rule.deleted_at.is_(None),
|
||||||
|
RulebookTopic.deleted_at.is_(None),
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if topic_id:
|
if topic_id:
|
||||||
stmt = stmt.where(Rule.topic_id == topic_id)
|
stmt = stmt.where(Rule.topic_id == topic_id)
|
||||||
@@ -299,6 +314,9 @@ async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
|
|||||||
.where(
|
.where(
|
||||||
Rule.id == rule_id,
|
Rule.id == rule_id,
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rule.deleted_at.is_(None),
|
||||||
|
RulebookTopic.deleted_at.is_(None),
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
@@ -411,6 +429,7 @@ async def get_applicable_rules(
|
|||||||
.where(
|
.where(
|
||||||
project_rulebook_subscriptions.c.project_id == project_id,
|
project_rulebook_subscriptions.c.project_id == project_id,
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.order_by(Rulebook.title)
|
.order_by(Rulebook.title)
|
||||||
)
|
)
|
||||||
@@ -435,6 +454,9 @@ async def get_applicable_rules(
|
|||||||
.where(
|
.where(
|
||||||
project_rulebook_subscriptions.c.project_id == project_id,
|
project_rulebook_subscriptions.c.project_id == project_id,
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rule.deleted_at.is_(None),
|
||||||
|
RulebookTopic.deleted_at.is_(None),
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.order_by(
|
.order_by(
|
||||||
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Verify live-read paths exclude trashed rows.
|
||||||
|
|
||||||
|
Strategy: run each service read with a mocked session that CAPTURES the
|
||||||
|
compiled SQL of every statement passed to execute, then assert the
|
||||||
|
'deleted_at IS NULL' filter is present. No real DB.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _capturing_session(captured: list[str]):
|
||||||
|
s = AsyncMock()
|
||||||
|
s.__aenter__ = AsyncMock(return_value=s)
|
||||||
|
s.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
s.commit = AsyncMock()
|
||||||
|
s.scalar = AsyncMock(return_value=0)
|
||||||
|
|
||||||
|
async def _exec(stmt, *a, **k):
|
||||||
|
captured.append(str(stmt))
|
||||||
|
r = MagicMock()
|
||||||
|
r.scalars.return_value.all.return_value = []
|
||||||
|
r.scalars.return_value.first.return_value = None
|
||||||
|
r.scalar_one_or_none.return_value = None
|
||||||
|
r.first.return_value = None
|
||||||
|
r.all.return_value = []
|
||||||
|
r.fetchall.return_value = []
|
||||||
|
return r
|
||||||
|
|
||||||
|
s.execute = AsyncMock(side_effect=_exec)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _has_filter(captured: list[str]) -> bool:
|
||||||
|
return any("deleted_at IS NULL" in s for s in captured)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_note_excludes_trashed():
|
||||||
|
cap: list[str] = []
|
||||||
|
with patch("fabledassistant.services.notes.async_session") as cls:
|
||||||
|
cls.return_value = _capturing_session(cap)
|
||||||
|
from fabledassistant.services.notes import get_note
|
||||||
|
await get_note(1, 5)
|
||||||
|
assert _has_filter(cap)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_notes_excludes_trashed():
|
||||||
|
cap: list[str] = []
|
||||||
|
with patch("fabledassistant.services.notes.async_session") as cls:
|
||||||
|
cls.return_value = _capturing_session(cap)
|
||||||
|
from fabledassistant.services.notes import list_notes
|
||||||
|
await list_notes(1)
|
||||||
|
assert _has_filter(cap)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_projects_excludes_trashed():
|
||||||
|
cap: list[str] = []
|
||||||
|
with patch("fabledassistant.services.projects.async_session") as cls:
|
||||||
|
cls.return_value = _capturing_session(cap)
|
||||||
|
from fabledassistant.services.projects import list_projects
|
||||||
|
await list_projects(1)
|
||||||
|
assert _has_filter(cap)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_events_excludes_trashed():
|
||||||
|
cap: list[str] = []
|
||||||
|
with patch("fabledassistant.services.events.async_session") as cls:
|
||||||
|
cls.return_value = _capturing_session(cap)
|
||||||
|
from fabledassistant.services.events import list_events
|
||||||
|
await list_events(
|
||||||
|
1,
|
||||||
|
datetime(2026, 5, 1, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 5, 31, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert _has_filter(cap)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_knowledge_excludes_trashed():
|
||||||
|
cap: list[str] = []
|
||||||
|
with patch("fabledassistant.services.knowledge.async_session") as cls:
|
||||||
|
cls.return_value = _capturing_session(cap)
|
||||||
|
from fabledassistant.services.knowledge import query_knowledge
|
||||||
|
await query_knowledge(user_id=1, note_type=None, tags=[], sort="modified", q=None, limit=10, offset=0)
|
||||||
|
assert _has_filter(cap)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_applicable_rules_excludes_trashed():
|
||||||
|
cap: list[str] = []
|
||||||
|
with patch("fabledassistant.services.rulebooks.async_session") as cls:
|
||||||
|
cls.return_value = _capturing_session(cap)
|
||||||
|
from fabledassistant.services.rulebooks import get_applicable_rules
|
||||||
|
await get_applicable_rules(project_id=3, user_id=1)
|
||||||
|
assert _has_filter(cap)
|
||||||
Reference in New Issue
Block a user