Skip to content

Commit 9539dac

Browse files
authored
Hotfix projects home when pin_order is unavailable
1 parent 9c1c343 commit 9539dac

File tree

2 files changed

+139
-19
lines changed

2 files changed

+139
-19
lines changed

echo/server/dembrane/api/project.py

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,14 @@ class BffProjectsHomeResponse(BaseModel):
5959

6060

6161
_HOME_FIELDS = [
62-
"id", "name", "updated_at", "language", "pin_order", "count(conversations)",
62+
"id",
63+
"name",
64+
"updated_at",
65+
"language",
66+
"pin_order",
67+
"count(conversations)",
6368
]
69+
_HOME_FIELDS_WITHOUT_PIN_ORDER = [field for field in _HOME_FIELDS if field != "pin_order"]
6470

6571

6672
def _build_project_summary(raw: dict) -> BffProjectSummary:
@@ -100,13 +106,17 @@ async def get_projects_home(
100106
fields = list(_HOME_FIELDS)
101107
if auth.is_admin:
102108
fields.extend(["directus_user_id.first_name", "directus_user_id.email"])
109+
fallback_fields = list(_HOME_FIELDS_WITHOUT_PIN_ORDER)
110+
if auth.is_admin:
111+
fallback_fields.extend(["directus_user_id.first_name", "directus_user_id.email"])
103112

104113
# Fetch pinned projects (always, regardless of search)
105114
# Admins see only their own pins; non-admins see all (Directus permissions handle scoping)
106115
pin_filter: dict[str, Any] = {"pin_order": {"_nnull": True}}
107116
if auth.is_admin:
108117
pin_filter["directus_user_id"] = {"_eq": auth.user_id}
109118

119+
supports_pin_order = True
110120
pinned_raw = await run_in_thread_pool(
111121
client.get_items,
112122
"project",
@@ -122,10 +132,12 @@ async def get_projects_home(
122132
if not isinstance(pinned_raw, list):
123133
logger.warning("get_items returned non-list for pinned projects: %s", pinned_raw)
124134
pinned_raw = []
135+
supports_pin_order = False
125136
pinned = [_build_project_summary(p) for p in pinned_raw]
126137

127138
# Parse owner: prefix from search string (admin only)
128139
import re
140+
129141
owner_term: Optional[str] = None
130142
text_search: Optional[str] = search
131143
if search and auth.is_admin:
@@ -144,8 +156,9 @@ async def get_projects_home(
144156
}
145157

146158
# Build query for paginated project list
159+
list_fields = fields if supports_pin_order else fallback_fields
147160
query: dict = {
148-
"fields": fields,
161+
"fields": list_fields,
149162
"sort": ["-updated_at"],
150163
"limit": limit + 1,
151164
"offset": offset,
@@ -162,7 +175,17 @@ async def get_projects_home(
162175
)
163176
if not isinstance(projects_raw, list):
164177
logger.warning("get_items returned non-list for projects: %s", projects_raw)
165-
projects_raw = []
178+
if supports_pin_order:
179+
supports_pin_order = False
180+
query["fields"] = fallback_fields
181+
projects_raw = await run_in_thread_pool(
182+
client.get_items,
183+
"project",
184+
{"query": query},
185+
)
186+
if not isinstance(projects_raw, list):
187+
logger.warning("fallback get_items returned non-list for projects: %s", projects_raw)
188+
projects_raw = []
166189

167190
has_more = len(projects_raw) > limit
168191
projects = [_build_project_summary(p) for p in projects_raw[:limit]]
@@ -609,9 +632,13 @@ async def create_report(
609632
if not is_scheduled:
610633
# Dispatch background task immediately
611634
task_create_report.send(project_id, report["id"], language, body.user_instructions or "")
612-
logger.info(f"Report generation task dispatched for project {project_id}, report {report['id']}")
635+
logger.info(
636+
f"Report generation task dispatched for project {project_id}, report {report['id']}"
637+
)
613638
else:
614-
logger.info(f"Report {report['id']} scheduled for {body.scheduled_at} for project {project_id}")
639+
logger.info(
640+
f"Report {report['id']} scheduled for {body.scheduled_at} for project {project_id}"
641+
)
615642

616643
return report
617644

@@ -621,6 +648,7 @@ def _extract_report_title(content: Optional[str]) -> Optional[str]:
621648
if not content:
622649
return None
623650
import re
651+
624652
match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
625653
return match.group(1).strip() if match else None
626654

@@ -642,22 +670,32 @@ async def list_project_reports(
642670
"project_id": {"_eq": project_id},
643671
"status": {"_in": ["archived", "published", "scheduled", "draft"]},
644672
},
645-
"fields": ["id", "status", "date_created", "language", "user_instructions", "content", "scheduled_at"],
673+
"fields": [
674+
"id",
675+
"status",
676+
"date_created",
677+
"language",
678+
"user_instructions",
679+
"content",
680+
"scheduled_at",
681+
],
646682
"sort": ["-date_created"],
647683
}
648684
},
649685
)
650686
result = []
651-
for r in (reports or []):
652-
result.append({
653-
"id": r["id"],
654-
"status": r.get("status"),
655-
"date_created": r.get("date_created"),
656-
"language": r.get("language"),
657-
"user_instructions": r.get("user_instructions"),
658-
"scheduled_at": r.get("scheduled_at"),
659-
"title": _extract_report_title(r.get("content")),
660-
})
687+
for r in reports or []:
688+
result.append(
689+
{
690+
"id": r["id"],
691+
"status": r.get("status"),
692+
"date_created": r.get("date_created"),
693+
"language": r.get("language"),
694+
"user_instructions": r.get("user_instructions"),
695+
"scheduled_at": r.get("scheduled_at"),
696+
"title": _extract_report_title(r.get("content")),
697+
}
698+
)
661699
return result
662700

663701

@@ -856,6 +894,7 @@ async def get_report_views(
856894

857895
# Recent views (last 10 minutes)
858896
from datetime import datetime, timezone, timedelta
897+
859898
ten_mins_ago = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat()
860899
recent_metrics = await run_in_thread_pool(
861900
directus.get_items,
@@ -976,9 +1015,7 @@ async def _generate_events() -> AsyncIterator[str]:
9761015
# Check if report is already done before subscribing
9771016
from dembrane.directus import directus
9781017

979-
report = await run_in_thread_pool(
980-
directus.get_item, "project_report", str(report_id)
981-
)
1018+
report = await run_in_thread_pool(directus.get_item, "project_report", str(report_id))
9821019
if not report or str(report.get("project_id")) != project_id:
9831020
yield f"event: progress\ndata: {json.dumps({'type': 'failed', 'message': 'Report not found'})}\n\n"
9841021
return
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import os
2+
3+
import pytest
4+
5+
os.environ.setdefault("DIRECTUS_SECRET", "test-secret")
6+
os.environ.setdefault("DIRECTUS_TOKEN", "test-token")
7+
os.environ.setdefault("DATABASE_URL", "postgresql://localhost/test")
8+
os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0")
9+
os.environ.setdefault("STORAGE_S3_BUCKET", "test-bucket")
10+
os.environ.setdefault("STORAGE_S3_ENDPOINT", "https://example.com")
11+
os.environ.setdefault("STORAGE_S3_KEY", "test-key")
12+
os.environ.setdefault("STORAGE_S3_SECRET", "test-secret")
13+
14+
import dembrane.api.project as project_api
15+
from dembrane.api.dependency_auth import DirectusSession
16+
17+
18+
def _auth(client) -> DirectusSession:
19+
return DirectusSession(
20+
user_id="user-1",
21+
is_admin=True,
22+
access_token="token-1",
23+
client=client,
24+
)
25+
26+
27+
@pytest.mark.asyncio
28+
async def test_get_projects_home_falls_back_when_pin_order_is_unavailable(monkeypatch) -> None:
29+
async def _fake_run_in_thread_pool(func, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
30+
return func(*args, **kwargs)
31+
32+
class _FakeClient:
33+
def __init__(self) -> None:
34+
self.calls: list[dict] = []
35+
36+
def get_items(self, collection_name: str, payload: dict) -> list[dict] | dict[str, str]:
37+
assert collection_name == "project"
38+
self.calls.append(payload)
39+
query = payload["query"]
40+
41+
if "aggregate" in query:
42+
return [{"count": {"id": "21"}}]
43+
44+
if "pin_order" in query.get("fields", []):
45+
return {"error": 'You don\'t have permission to access field "pin_order"'}
46+
47+
return [
48+
{
49+
"id": "project-1",
50+
"name": "Visible project",
51+
"updated_at": "2026-03-19T17:00:00Z",
52+
"language": "en",
53+
"conversations_count": "2",
54+
"directus_user_id": {
55+
"first_name": "Admin",
56+
"email": "admin@dembrane.com",
57+
},
58+
}
59+
]
60+
61+
client = _FakeClient()
62+
monkeypatch.setattr(project_api, "run_in_thread_pool", _fake_run_in_thread_pool)
63+
64+
response = await project_api.get_projects_home(
65+
auth=_auth(client),
66+
search=None,
67+
offset=0,
68+
limit=15,
69+
)
70+
71+
assert response.is_admin is True
72+
assert response.total_count == 21
73+
assert response.has_more is False
74+
assert response.pinned == []
75+
assert len(response.projects) == 1
76+
assert response.projects[0].id == "project-1"
77+
assert response.projects[0].pin_order is None
78+
assert any("pin_order" in call["query"].get("fields", []) for call in client.calls)
79+
assert any(
80+
"pin_order" not in call["query"].get("fields", [])
81+
for call in client.calls
82+
if "aggregate" not in call["query"]
83+
)

0 commit comments

Comments
 (0)