Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions echo/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,9 @@ Which group powers which feature is non-obvious — don't downgrade silently.
**Never hand-write Directus sync/snapshot JSON files.** To create or modify collections:

1. Write an idempotent Python script that uses the Directus REST API (`POST /collections`, `POST /fields`, `POST /relations`) with the admin token. Check `collection_exists()` / `field_exists()` before creating
2. Run it step-by-step to verify each change
2. Run it step-by-step to verify each change against a local Directus
3. Pull the schema: `cd directus && bash sync.sh -u http://directus:8055 -e admin@dembrane.com -p admin pull`
4. Commit the snapshot JSON

See `scripts/create_schema.py` for the established pattern.
4. Commit the snapshot JSON under `directus/sync/snapshot/` — that is the source of truth; the one-shot migration script does not need to be committed

### Python DirectusClient

Expand Down
48 changes: 34 additions & 14 deletions echo/frontend/src/features/sidebar/views/user/UserSettingsView.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
Buildings,
Palette,
Scales,
ShieldStar,
SignOut,
BuildingsIcon,
PaletteIcon,
ScalesIcon,
ShieldStarIcon,
SignOutIcon,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useLocation } from "react-router";
import { useLogoutMutation } from "@/components/auth/hooks";
import { useTransitionCurtain } from "@/components/layout/TransitionCurtainProvider";
import { API_BASE_URL } from "@/config";
import { BackButton } from "../../primitives/BackButton";
import { NavButton } from "../../primitives/NavButton";
import { NavItem } from "../../primitives/NavItem";
Expand All @@ -19,6 +21,22 @@ export const UserSettingsView = () => {
const location = useLocation();
const { runTransition } = useTransitionCurtain();

const { data: accessData } = useQuery<{
organisations: Array<{ id: string }>;
} | null>({
queryFn: async () => {
const res = await fetch(`${API_BASE_URL}/v2/workspaces`, {
credentials: "include",
});
if (!res.ok) return null;
return res.json();
},
queryKey: ["v2", "workspaces"],
staleTime: 60_000,
});
const isExternalOnly =
accessData != null ? accessData.organisations.length === 0 : false;

const handleLogout = async () => {
if (logoutMutation.isPending) return;
await runTransition({ description: null, message: t`See you soon` });
Expand All @@ -35,26 +53,28 @@ export const UserSettingsView = () => {
<NavItem
to="/settings/account"
label={<Trans>Account & security</Trans>}
icon={ShieldStar}
icon={ShieldStarIcon}
/>
<NavItem
to="/settings/access"
label={<Trans>My access</Trans>}
icon={Buildings}
icon={BuildingsIcon}
/>
<NavItem
to="/settings/appearance"
label={<Trans>Appearance</Trans>}
icon={Palette}
/>
<NavItem
to="/settings/project-defaults"
label={<Trans>Project defaults</Trans>}
icon={Scales}
icon={PaletteIcon}
/>
{!isExternalOnly && (
<NavItem
to="/settings/project-defaults"
label={<Trans>Project defaults</Trans>}
icon={ScalesIcon}
/>
)}
<NavButton
label={<Trans>Log out</Trans>}
icon={SignOut}
icon={SignOutIcon}
onClick={handleLogout}
disabled={logoutMutation.isPending}
destructive
Expand Down
19 changes: 0 additions & 19 deletions echo/server/dembrane/api/v2/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,25 +123,6 @@ async def list_projects(ctx: WorkspaceContext = Depends(get_workspace_context)):
from dembrane.policies import _normalize_legacy_role

normalized_role = _normalize_legacy_role(role) or role
if source == "direct" and normalized_role != "external":
org_id = workspace.get("org_id")
if org_id:
org_rows = await async_directus.get_items(
"org_membership",
{
"query": {
"filter": {
"org_id": {"_eq": org_id},
"user_id": {"_eq": app_user_id},
"deleted_at": {"_null": True},
},
"fields": ["id"],
"limit": 1,
}
},
)
if not isinstance(org_rows, list) or len(org_rows) == 0:
normalized_role = "external"

return WorkspaceContext(
workspace_id=workspace_id,
Expand Down
1 change: 1 addition & 0 deletions echo/server/dembrane/api/v2/project_sharing.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ async def change_project_share_role(
message=f"You're now a **{body.role}** on this project.",
action="NAVIGATE_PROJECT",
ref_project_id=project_id,
ref_workspace_id=project.get("workspace_id"),
)

return {"status": "updated", "role": body.role}
Expand Down
3 changes: 3 additions & 0 deletions echo/server/dembrane/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,7 @@ def progress_callback(event_type: str, message: str, detail: Optional[dict] = No
action="NAVIGATE_REPORT",
ref_project_id=project_id,
ref_report_id=report_id_str,
ref_workspace_id=(project_row or {}).get("workspace_id"),
)
except Exception as e:
logger.warning(f"Failed to emit REPORT_READY notification: {e}")
Expand Down Expand Up @@ -1467,6 +1468,7 @@ def progress_callback(event_type: str, message: str, detail: Optional[dict] = No
from dembrane.notifications import emit_sync
with directus_client_context() as client:
report_row = client.get_item("project_report", report_id_str)
project_row = client.get_item("project", project_id) if project_id else None
report_data = (report_row or {}).get("data") or report_row or {}
creator_directus_id = report_data.get("user_created")
if creator_directus_id:
Expand All @@ -1480,6 +1482,7 @@ def progress_callback(event_type: str, message: str, detail: Optional[dict] = No
action="NAVIGATE_REPORT",
ref_project_id=project_id,
ref_report_id=report_id_str,
ref_workspace_id=(project_row or {}).get("workspace_id"),
)
except Exception as notif_err:
logger.warning(f"Failed to emit REPORT_FAILED: {notif_err}")
Expand Down
36 changes: 0 additions & 36 deletions echo/server/tests/test_discount_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,9 @@
- _create_workspace_for_request writes discount fields (already covered in Slice 10,
but verified structurally here)
- _upgrade_workspace_for_request writes discount fields (same)
- create_schema step_21 is registered and callable
- No code path computes a price using discount fields (grep guard)
"""

import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand Down Expand Up @@ -456,39 +453,6 @@ async def test_query_includes_discount_fields(self):
assert "percent_discount" in fields


# ── Schema step_21 registration ──


class TestSchemaStep21:
@pytest.fixture(autouse=True)
def _add_scripts_to_path(self):
scripts_dir = os.path.join(os.path.dirname(__file__), "..", "..", "scripts")
scripts_dir = os.path.abspath(scripts_dir)
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
yield
if scripts_dir in sys.path:
sys.path.remove(scripts_dir)

def test_step_21_in_steps(self):
from create_schema import STEPS

assert "21" in STEPS
name, fn = STEPS["21"]
assert "discount" in name.lower()

def test_step_21_callable(self):
from create_schema import STEPS

_, fn = STEPS["21"]
assert callable(fn)

def test_step_21_function_name(self):
from create_schema import step_21_workspace_discount_fields

assert step_21_workspace_discount_fields.__name__ == "step_21_workspace_discount_fields"


# ── Approve helpers write discount fields (structural) ──


Expand Down
27 changes: 0 additions & 27 deletions echo/server/tests/test_tier_expiry.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,33 +483,6 @@ def test_expire_tier_job_runs_hourly(self):
assert str(minute_field) == "0"


# ── Schema step 19 ──────────────────────────────────────────────────


class TestSchemaStep19:
"""Structural check that step_19 is wired in STEPS and is callable."""

def test_step_19_in_source(self):
src_path = os.path.join(
os.path.dirname(__file__), "..", "..", "scripts", "create_schema.py"
)
with open(src_path) as f:
source = f.read()

assert "step_19_workspace_tier_expires_at" in source
assert '"19"' in source

def test_step_19_adds_tier_expires_at(self):
src_path = os.path.join(
os.path.dirname(__file__), "..", "..", "scripts", "create_schema.py"
)
with open(src_path) as f:
source = f.read()

assert '"tier_expires_at"' in source
assert '"timestamp"' in source


# ── Email template existence ─────────────────────────────────────────


Expand Down
34 changes: 0 additions & 34 deletions echo/server/tests/test_tier_expiry_prewarning.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from __future__ import annotations

import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand Down Expand Up @@ -595,36 +594,3 @@ def test_prewarning_job_target(self):
assert "task_send_tier_expiry_prewarning" in str(job.func_ref)


# ── Schema step 20 ───────────────────────────────────────────────────


class TestSchemaStep20:
"""Structural check for the pre_warning_sent field definition."""

@pytest.fixture(autouse=True)
def _add_scripts_to_path(self):
scripts_dir = os.path.join(os.path.dirname(__file__), "..", "..", "scripts")
scripts_dir = os.path.abspath(scripts_dir)
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
yield
if scripts_dir in sys.path:
sys.path.remove(scripts_dir)

def test_step_20_in_steps(self):
from create_schema import STEPS

assert "20" in STEPS
name, fn = STEPS["20"]
assert "pre_warning_sent" in name

def test_step_20_callable(self):
from create_schema import STEPS

_, fn = STEPS["20"]
assert callable(fn)

def test_step_20_function_name(self):
from create_schema import step_20_workspace_pre_warning_sent

assert step_20_workspace_pre_warning_sent.__name__ == "step_20_workspace_pre_warning_sent"
27 changes: 0 additions & 27 deletions echo/server/tests/test_workspace_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,33 +389,6 @@ async def test_pioneer_with_cadence_accepted(self, cadence: str):
assert row["proposed_billing_period"] == cadence


# ── Schema script step_18 ────────────────────────────────────────────


class TestSchemaStep18:
"""Structural checks that the schema step function exists and has the right shape."""

def _load_schema_module(self):
import os
import importlib.util
script_path = os.path.join(
os.path.dirname(__file__), "..", "..", "scripts", "create_schema.py"
)
spec = importlib.util.spec_from_file_location("create_schema", script_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod

def test_step_registered(self):
mod = self._load_schema_module()
assert "18" in mod.STEPS
assert mod.STEPS["18"][0] == "workspace_request collection"

def test_step_function_callable(self):
mod = self._load_schema_module()
assert callable(mod.step_18_workspace_request)


# ── Row creation payload ─────────────────────────────────────────────


Expand Down
Loading