various enhancements for new tabs and bug fixes
This commit is contained in:
@@ -161,6 +161,7 @@ class BaseRepository:
|
||||
"category", "instructions", "expected_output", "estimated_days",
|
||||
"contact_id", "started_at",
|
||||
"weekly_hours", "effective_from",
|
||||
"task_id", "meeting_id",
|
||||
}
|
||||
clean_data = {}
|
||||
for k, v in data.items():
|
||||
|
||||
337
lifeos-claude-code-prompts_R1_1.md
Normal file
337
lifeos-claude-code-prompts_R1_1.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Life OS - Claude Code Prompts for DEV Enhancements
|
||||
|
||||
**Target Environment:** DEV only (lifeos-dev.invixiom.com)
|
||||
**Codebase Location:** `/opt/lifeos/dev/`
|
||||
**Hot Reload:** Changes to files in `/opt/lifeos/dev/` are picked up automatically by uvicorn `--reload`. No container rebuild needed.
|
||||
**Database:** `lifeos_dev` on container `lifeos-db` (PostgreSQL 16)
|
||||
|
||||
---
|
||||
|
||||
## PROMPT 1: Task Detail Page - Add Tabs
|
||||
|
||||
**Context:** The task detail page at `/tasks/{task_id}` currently shows task metadata but does NOT use the tabbed layout. The project detail page at `/projects/{project_id}` (template: `project_detail.html`) IS the reference implementation for tabbed detail views. It uses a `?tab=` query parameter to switch between tabs, with tab content rendered server-side via Jinja2 conditionals.
|
||||
|
||||
**Codebase facts:**
|
||||
- Stack: Python 3.12, FastAPI, SQLAlchemy async, Jinja2 templates, vanilla JS/CSS
|
||||
- Template to modify: `templates/task_detail.html`
|
||||
- Router to modify: `routers/tasks.py` (the `task_detail` GET handler at `/tasks/{task_id}`)
|
||||
- Reference template: `templates/project_detail.html` - copy the tab-strip HTML structure and `?tab=` query param pattern exactly
|
||||
- CSS: `static/style.css` already has `.tab-strip` and `.tab-content` classes from the project detail implementation
|
||||
- BaseRepository pattern: `core/base_repository.py` provides generic CRUD. All queries use `is_deleted = false` filtering automatically.
|
||||
|
||||
**Existing DB tables/junctions (already in schema, may not have app code):**
|
||||
- `notes` - has `domain_id`, `project_id` columns. Notes don't have a `task_id` FK directly. You'll need to either add a `task_id` FK column to notes OR use a polymorphic junction (e.g., `note_tasks` or use `file_mappings` pattern with `context_type='task'`). Prefer adding a nullable `task_id UUID REFERENCES tasks(id)` column to the `notes` table for simplicity.
|
||||
- `weblinks` - has `domain_id`, `project_id`. Same situation as notes - add nullable `task_id UUID REFERENCES tasks(id)`.
|
||||
- `file_mappings` - polymorphic junction: `file_id, context_type, context_id`. Already supports `context_type='task'`. Files tab should query `file_mappings WHERE context_type='task' AND context_id={task_id}` joined to `files`.
|
||||
- `lists` - has `domain_id`, `project_id`. Add nullable `task_id UUID REFERENCES tasks(id)`.
|
||||
- `decisions` - has `domain_id`, `project_id`. Add nullable `task_id UUID REFERENCES tasks(id)`.
|
||||
- `processes` / `process_runs` - These tables exist in the R1 schema but process CRUD is NOT yet built. The Processes tab should show: (a) a way to apply a process template to this task (create a process_run with context), and (b) list existing process_runs linked to this task. For now, since process CRUD doesn't exist yet, create the tab structure with an empty state placeholder that says "Process management coming soon" and a TODO comment in the code.
|
||||
- `contact_tasks` - junction table already exists with columns: `contact_id, task_id, role, created_at`. This is the Contacts + Roles tab data source.
|
||||
|
||||
**What to build:**
|
||||
|
||||
1. **Migration SQL** - Create a migration file at `/opt/lifeos/migrations/` that adds:
|
||||
- `ALTER TABLE notes ADD COLUMN task_id UUID REFERENCES tasks(id);`
|
||||
- `ALTER TABLE weblinks ADD COLUMN task_id UUID REFERENCES tasks(id);`
|
||||
- `ALTER TABLE lists ADD COLUMN task_id UUID REFERENCES tasks(id);`
|
||||
- `ALTER TABLE decisions ADD COLUMN task_id UUID REFERENCES tasks(id);`
|
||||
- Create indexes on these new FK columns.
|
||||
- Apply to `lifeos_dev` database via: `docker exec -i lifeos-db psql -U postgres -d lifeos_dev < /opt/lifeos/migrations/NNNN_task_detail_tabs.sql`
|
||||
|
||||
2. **Router** (`routers/tasks.py`) - Modify the task detail GET handler:
|
||||
- Accept `tab` query parameter (default: "overview")
|
||||
- Tabs: overview, notes, weblinks, files, lists, decisions, processes, contacts
|
||||
- For each tab, query the relevant data and pass to template context
|
||||
- For contacts tab: query `contact_tasks` joined to `contacts` WHERE `task_id = {id}`
|
||||
- For files tab: query `file_mappings` WHERE `context_type='task' AND context_id={id}` joined to `files`
|
||||
- For notes/weblinks/lists/decisions: query WHERE `task_id = {id} AND is_deleted = false`
|
||||
|
||||
3. **Template** (`templates/task_detail.html`) - Restructure to match `project_detail.html` tab pattern:
|
||||
- Keep existing task header (title, metadata, action buttons including timer)
|
||||
- Add tab-strip below header with all tab names
|
||||
- Active tab highlighted via CSS class
|
||||
- Tab links use `?tab=tabname` pattern
|
||||
- Each tab section wrapped in Jinja2 `{% if tab == 'notes' %}...{% endif %}` blocks
|
||||
- Overview tab contains what's currently shown (description, subtasks, metadata)
|
||||
- Each entity tab shows a list of related items with an "+ Add" button
|
||||
- Contacts tab shows contact name + role with ability to add/remove
|
||||
|
||||
4. **Contact + Role management on tasks:**
|
||||
- Add endpoint `POST /tasks/{task_id}/contacts/add` accepting `contact_id` and `role` form fields. Insert into `contact_tasks`.
|
||||
- Add endpoint `POST /tasks/{task_id}/contacts/{contact_id}/remove` to delete from `contact_tasks`.
|
||||
- The contacts tab should show a form with a contact dropdown (all non-deleted contacts) and a role text input, plus a list of currently linked contacts with remove buttons.
|
||||
- Role should be a free-text field (not a fixed set yet - we'll define role sets later).
|
||||
|
||||
5. **Update note/weblink/list/decision create forms** to accept optional `task_id` and pre-fill it when creating from within a task detail page. The "+ Add" buttons on each tab should link to the respective create form with `?task_id={task_id}` pre-filled.
|
||||
|
||||
**Do NOT:**
|
||||
- Touch production database or files
|
||||
- Build full process CRUD (just placeholder tab)
|
||||
- Change the existing task list page (`tasks.html`)
|
||||
- Add any npm/frontend build tooling
|
||||
|
||||
**Test:** After implementation, verify at `https://lifeos-dev.invixiom.com/tasks/{any_task_id}` that all tabs render, switching works via URL param, and the contacts add/remove flow works.
|
||||
|
||||
---
|
||||
|
||||
## PROMPT 2: Project Detail Page - Add Missing Tabs
|
||||
|
||||
**Context:** The project detail page at `/projects/{project_id}` already has a working tabbed layout with tabs for Tasks, Notes, Links (weblinks). It needs additional tabs for: Files, Lists, Processes, Contacts + Roles.
|
||||
|
||||
**Codebase facts:**
|
||||
- Template: `templates/project_detail.html` (already has tab-strip pattern)
|
||||
- Router: `routers/projects.py` (the detail GET handler already accepts `?tab=` param)
|
||||
- Existing tabs: tasks, notes, links/weblinks
|
||||
- `file_mappings` supports `context_type='project'` already
|
||||
- `contact_projects` junction table exists: `contact_id, project_id, role, created_at`
|
||||
- `lists` table has `project_id` FK already
|
||||
- Process CRUD not yet built
|
||||
|
||||
**What to build:**
|
||||
|
||||
1. **Router** (`routers/projects.py`) - Extend the detail handler:
|
||||
- Add tab cases for: files, lists, processes, contacts
|
||||
- Files: query `file_mappings WHERE context_type='project' AND context_id={project_id}` joined to `files`
|
||||
- Lists: query `lists WHERE project_id={id} AND is_deleted=false`
|
||||
- Processes: placeholder (empty state)
|
||||
- Contacts: query `contact_projects` joined to `contacts` WHERE `project_id={id}`
|
||||
|
||||
2. **Template** (`templates/project_detail.html`) - Add new tabs to existing tab-strip:
|
||||
- Files tab: list of attached files with upload link (`/files/upload?context_type=project&context_id={project_id}`)
|
||||
- Lists tab: list of project lists with link to create (`/lists/create?project_id={project_id}`)
|
||||
- Processes tab: empty state placeholder "Process management coming soon"
|
||||
- Contacts tab: same pattern as Task contacts - dropdown + role input + list with remove
|
||||
|
||||
3. **Contact + Role management on projects:**
|
||||
- `POST /projects/{project_id}/contacts/add` - insert into `contact_projects`
|
||||
- `POST /projects/{project_id}/contacts/{contact_id}/remove` - delete from `contact_projects`
|
||||
- Same UI pattern as task contacts tab
|
||||
|
||||
**Do NOT:**
|
||||
- Modify existing working tabs (tasks, notes, links)
|
||||
- Build process CRUD
|
||||
|
||||
**Test:** Verify at `https://lifeos-dev.invixiom.com/projects/{any_project_id}` that new tabs appear and function.
|
||||
|
||||
---
|
||||
|
||||
## PROMPT 3: Meeting Detail Page - Add Tabs
|
||||
|
||||
**Context:** The meeting detail page at `/meetings/{meeting_id}` exists but may not have the full tabbed layout. It needs tabs for: Notes, Weblinks, Files, Lists, Processes, Contacts + Roles. Meetings already have an action items / tasks relationship via the `meeting_tasks` junction table.
|
||||
|
||||
**Codebase facts:**
|
||||
- Template: `templates/meeting_detail.html`
|
||||
- Router: `routers/meetings.py`
|
||||
- `contact_meetings` junction exists: `contact_id, meeting_id, role, created_at` (role values: organizer|attendee|optional)
|
||||
- `meeting_tasks` junction exists: `meeting_id, task_id, source` (source: discussed|action_item)
|
||||
- `meetings` table has `notes_body` column for inline meeting notes
|
||||
- Notes, weblinks, lists don't have a `meeting_id` FK column
|
||||
|
||||
**What to build:**
|
||||
|
||||
1. **Migration SQL** - Add to same or new migration file:
|
||||
- `ALTER TABLE notes ADD COLUMN meeting_id UUID REFERENCES meetings(id);`
|
||||
- `ALTER TABLE weblinks ADD COLUMN meeting_id UUID REFERENCES meetings(id);`
|
||||
- `ALTER TABLE lists ADD COLUMN meeting_id UUID REFERENCES meetings(id);`
|
||||
- Indexes on new columns.
|
||||
- Apply to `lifeos_dev`.
|
||||
|
||||
2. **Router** (`routers/meetings.py`) - Add/extend detail handler with `?tab=` param:
|
||||
- Tabs: overview (existing content: agenda, notes_body, action items), notes, weblinks, files, lists, processes, contacts
|
||||
- Query patterns same as tasks/projects for each tab type
|
||||
- Files via `file_mappings WHERE context_type='meeting'`
|
||||
- Contacts via `contact_meetings` joined to `contacts`
|
||||
|
||||
3. **Template** (`templates/meeting_detail.html`) - Restructure to tab layout matching project_detail.html pattern:
|
||||
- Overview tab: existing meeting content (agenda, meeting notes, action items list)
|
||||
- All other tabs: same list + add pattern as task/project detail
|
||||
|
||||
4. **Contact + Role management on meetings:**
|
||||
- `POST /meetings/{meeting_id}/contacts/add` - insert into `contact_meetings`, role defaults to "attendee"
|
||||
- `POST /meetings/{meeting_id}/contacts/{contact_id}/remove` - delete from `contact_meetings`
|
||||
- Role dropdown with predefined values: organizer, attendee, optional
|
||||
|
||||
**Test:** Verify at `https://lifeos-dev.invixiom.com/meetings/{any_meeting_id}`.
|
||||
|
||||
---
|
||||
|
||||
## PROMPT 4: Lists Page - Dynamic Domain/Project Filter + Contact Roles
|
||||
|
||||
**Context:** The lists page at `/lists/` has domain and project dropdown filters. Currently the project dropdown shows ALL projects regardless of which domain is selected. It should filter dynamically.
|
||||
|
||||
**Codebase facts:**
|
||||
- Template: `templates/lists.html`
|
||||
- Router: `routers/lists.py` (or wherever list CRUD lives)
|
||||
- The filter uses HTML `<select>` elements that auto-submit on change (pattern from `app.js`)
|
||||
- `projects` table has `domain_id` FK (nullable - some projects may have domain_id = NULL via unassigned area)
|
||||
- `areas` table has `domain_id` FK
|
||||
- `contact_lists` junction exists: `contact_id, list_id, role, created_at`
|
||||
|
||||
**What to build:**
|
||||
|
||||
1. **Dynamic project filtering by domain** - Two approaches, pick the simpler one:
|
||||
- **Option A (recommended - vanilla JS):** Add a small JS block to `lists.html` (or `app.js`) that intercepts the domain dropdown `change` event. When domain changes, fetch projects for that domain via a new lightweight API endpoint `GET /api/projects?domain_id={id}` that returns JSON `[{id, name}]`. Rebuild the project `<select>` options dynamically. Include projects where `domain_id` matches OR where `domain_id IS NULL` (unassigned). If no domain selected, show all projects.
|
||||
- **Option B (server-side only):** On domain change, the form auto-submits (existing behavior). The router filters the project dropdown options server-side based on the selected domain_id and passes filtered projects to the template. This is simpler but causes a page reload.
|
||||
- Go with Option A for better UX. Create the API endpoint in the lists router or a shared API router.
|
||||
|
||||
2. **Contact + Role management on lists:**
|
||||
- On the list detail page (`list_detail.html`), add a Contacts section (or tab if it has tabs).
|
||||
- `POST /lists/{list_id}/contacts/add` - insert into `contact_lists`
|
||||
- `POST /lists/{list_id}/contacts/{contact_id}/remove` - delete from `contact_lists`
|
||||
- Same UI pattern: contact dropdown + role text input + list with remove buttons.
|
||||
|
||||
**Test:** Go to `https://lifeos-dev.invixiom.com/lists/`, select a domain, verify the project dropdown updates to only show projects in that domain plus unassigned ones. Test adding/removing contacts on a list detail page.
|
||||
|
||||
---
|
||||
|
||||
## PROMPT 5: Eisenhower Page - Dynamic Height + Filters
|
||||
|
||||
**Context:** The Eisenhower matrix page at `/eisenhower/` displays tasks in a 2x2 grid (Important+Urgent, Important+Not Urgent, Not Important+Urgent, Not Important+Not Urgent). Currently the quadrant cards have a fixed minimum height which wastes space or clips content.
|
||||
|
||||
**Codebase facts:**
|
||||
- Template: `templates/eisenhower.html` (if it exists) or it may be part of another template
|
||||
- Router: `routers/eisenhower.py` or may be in `routers/tasks.py`
|
||||
- CSS in `static/style.css`
|
||||
- Current quadrant logic: priority 1-2 = Important, 3-4 = Not Important. Due date <= 7 days = Urgent, > 7 days or null = Not Urgent.
|
||||
- Tasks are filtered: only `status IN ('open', 'in_progress', 'blocked')` and `is_deleted = false`
|
||||
|
||||
**What to build:**
|
||||
|
||||
1. **Dynamic height for quadrant cards:**
|
||||
- Remove any `min-height` or fixed `height` CSS on the Eisenhower quadrant containers.
|
||||
- Use CSS that allows quadrants to grow with content: `min-height: 200px` (reasonable minimum) but no `max-height` restriction. Or use CSS Grid with `grid-template-rows: auto auto` so rows size to content.
|
||||
- Each card within a quadrant should be compact (task title, priority dot, due date - single line or two lines max).
|
||||
- If a quadrant has many tasks, it grows. If empty, it shows a small empty state.
|
||||
|
||||
2. **Add filters:**
|
||||
- Add a filter bar above the 2x2 grid with dropdowns for:
|
||||
- **Domain** - filter tasks by domain_id
|
||||
- **Project** - filter tasks by project_id (dynamic based on domain selection, same pattern as Prompt 4)
|
||||
- **Status** - filter by status (open, in_progress, blocked)
|
||||
- **Context** - filter by context (GTD execution context from context_types table)
|
||||
- Filters use query parameters: `/eisenhower/?domain_id=X&project_id=Y&status=Z&context=W`
|
||||
- Router applies these filters to the task query before distributing into quadrants.
|
||||
- Use the same auto-submit-on-change pattern used elsewhere in the app.
|
||||
|
||||
**Test:** Verify at `https://lifeos-dev.invixiom.com/eisenhower/` that quadrants resize with content and filters narrow the displayed tasks.
|
||||
|
||||
---
|
||||
|
||||
## PROMPT 6: Focus Page - Add Domain/Area/Project Filters
|
||||
|
||||
**Context:** The focus page at `/focus/` shows the daily focus list (tasks selected for today). Currently there are no filters to narrow which available tasks are shown for adding to focus.
|
||||
|
||||
**Codebase facts:**
|
||||
- Template: `templates/focus.html`
|
||||
- Router: `routers/focus.py`
|
||||
- The page has two sections: (1) Today's focus items (tasks already added), (2) Available tasks to add
|
||||
- `daily_focus` table: `id, task_id, focus_date, completed, sort_order, is_deleted, created_at`
|
||||
- Tasks have `domain_id`, `project_id` FKs. Tasks also have `area_id` FK.
|
||||
|
||||
**What to build:**
|
||||
|
||||
1. **Filter bar for available tasks section:**
|
||||
- Add dropdowns above the "Available Tasks" section for: Domain, Area (filtered by selected domain), Project (filtered by selected domain/area)
|
||||
- Same dynamic filtering pattern as Prompts 4 and 5
|
||||
- Filters apply only to the "available tasks to add" list, NOT to the already-focused items
|
||||
- Query params: `/focus/?domain_id=X&area_id=Y&project_id=Z&focus_date=YYYY-MM-DD`
|
||||
|
||||
2. **Router changes:**
|
||||
- Accept domain_id, area_id, project_id query params
|
||||
- Apply as WHERE clauses when querying available (unfocused) tasks
|
||||
- Pass filter values + dropdown options to template context
|
||||
|
||||
**Test:** Verify at `https://lifeos-dev.invixiom.com/focus/` that filter dropdowns appear and narrow the available tasks list.
|
||||
|
||||
---
|
||||
|
||||
## PROMPT 7: Change History (Recently Changed Items)
|
||||
|
||||
**Context:** We need a "Change History" view so the user can see recently modified items across the entire system. Every table in Life OS has `created_at` and `updated_at` TIMESTAMPTZ columns (except `time_entries` which is missing `updated_at`). This gives us a simple approach: query `updated_at` across all entity tables and show a reverse-chronological feed.
|
||||
|
||||
**Codebase facts:**
|
||||
- All main entity tables have `updated_at`: domains, areas, projects, tasks, notes, contacts, meetings, decisions, lists, weblinks, appointments, links, files, processes, capture
|
||||
- `time_entries` does NOT have `updated_at` - exclude from change history
|
||||
- BaseRepository handles all CRUD and sets `updated_at = now()` on every update
|
||||
- Sidebar navigation is built in `core/sidebar.py`
|
||||
|
||||
**What to build:**
|
||||
|
||||
1. **New router:** Create `routers/history.py` with prefix `/history`
|
||||
- `GET /history/` - renders the change history page
|
||||
- Query: For each entity table, `SELECT id, 'entity_type' as type, title/name as label, updated_at, created_at FROM {table} WHERE is_deleted = false ORDER BY updated_at DESC LIMIT 20`
|
||||
- Union all results, sort by `updated_at DESC`, take top 50 (or paginate)
|
||||
- For each item, determine if it was "created" (updated_at = created_at or within 1 second) or "modified"
|
||||
- Pass to template as a list of `{type, id, label, updated_at, action}` dicts
|
||||
|
||||
2. **Template:** Create `templates/history.html`
|
||||
- Page title: "Change History" or "Recent Changes"
|
||||
- Filter by: entity type dropdown, date range
|
||||
- Each row shows: timestamp, action icon (created/modified), entity type badge, item name as clickable link to detail page, relative time ("2 minutes ago", "yesterday")
|
||||
- Use the standard list-row styling
|
||||
|
||||
3. **Sidebar link:** Add "History" to the sidebar navigation in `core/sidebar.py` (or `base.html` directly), in the utility section near Search/Capture/Admin.
|
||||
|
||||
4. **Register router** in `main.py`: `app.include_router(history.router)`
|
||||
|
||||
**Architecture note:** This is NOT a full audit log with field-level diffs. It's a simple "what changed recently" view derived from `updated_at` timestamps. A full audit log (with before/after values) would require triggers or middleware - defer that to a later phase.
|
||||
|
||||
**Test:** Verify at `https://lifeos-dev.invixiom.com/history/` that recently created/modified items appear in reverse chronological order with working links.
|
||||
|
||||
---
|
||||
|
||||
## PROMPT 8: Fix Search - Partial Word Matching
|
||||
|
||||
**Context:** The global search currently uses PostgreSQL `tsvector/tsquery` full-text search. This is great for natural language search but does NOT support partial word matching. Searching for "Sys" does not return items containing "System" because tsquery requires complete lexemes.
|
||||
|
||||
**Codebase facts:**
|
||||
- Search router: `routers/search.py`
|
||||
- API endpoint: `GET /search/api?q=X&entity_type=Y&limit=Z`
|
||||
- Page endpoint: `GET /search/?q=X`
|
||||
- Every searchable table has a `search_vector TSVECTOR` column with GIN index
|
||||
- Search triggers maintain the tsvector automatically
|
||||
- Current query pattern likely uses `plainto_tsquery('english', query)` or `to_tsquery`
|
||||
|
||||
**The fix:**
|
||||
|
||||
The search needs to support partial prefix matching. PostgreSQL supports this via `:*` suffix on tsquery terms.
|
||||
|
||||
1. **Modify the search query building** in `routers/search.py`:
|
||||
- Instead of `plainto_tsquery('english', query)`, build a prefix query
|
||||
- For a search term like "Sys", construct: `to_tsquery('english', 'Sys:*')`
|
||||
- For multi-word queries like "Sys Admin", construct: `to_tsquery('english', 'Sys:* & Admin:*')` (AND all terms with prefix matching)
|
||||
- Implementation: split the query string on whitespace, strip non-alphanumeric chars from each term, append `:*` to each, join with ` & `
|
||||
- Handle edge cases: empty query, single character (skip search for < 2 chars), special characters
|
||||
|
||||
2. **Also add an ILIKE fallback** for when tsvector doesn't match (tsvector strips stop words and stems, so very short or unusual terms might not match):
|
||||
- After the tsvector query, if results are sparse (< 3 results), do a supplemental `WHERE title ILIKE '%{query}%' OR name ILIKE '%{query}%'` query
|
||||
- Deduplicate results (tsvector results first, then ILIKE additions)
|
||||
- This ensures "Sys" matches "System" even if the stemmer does something unexpected
|
||||
|
||||
3. **Update the search query for EACH entity** that's searched. The pattern should be consistent - build a helper function like:
|
||||
```python
|
||||
def build_search_condition(query: str):
|
||||
terms = query.strip().split()
|
||||
if not terms:
|
||||
return None
|
||||
tsquery_str = ' & '.join(f"{term}:*" for term in terms if len(term) >= 2)
|
||||
return tsquery_str
|
||||
```
|
||||
|
||||
**Do NOT:**
|
||||
- Change the search_vector triggers or GIN indexes (they're fine)
|
||||
- Add any new database tables
|
||||
- Change the search UI/template (just the backend query logic)
|
||||
|
||||
**Test:** Go to `https://lifeos-dev.invixiom.com/search/?q=Sys` and verify it returns items containing "System", "Systematic", "Sys" etc. Test with "Inv" to match "Invoice", "Investment" etc.
|
||||
|
||||
---
|
||||
|
||||
## General Notes for All Prompts
|
||||
|
||||
- **Git:** After each prompt's changes are verified working, run: `cd /opt/lifeos/dev && git add . && git commit -m "descriptive message" && git push origin main`
|
||||
- **Database migrations:** Always apply to `lifeos_dev` only. Command pattern: `docker exec -i lifeos-db psql -U postgres -d lifeos_dev < migration_file.sql`
|
||||
- **Restart if needed:** `docker restart lifeos-dev` (though hot reload should handle most changes)
|
||||
- **Logs:** `docker logs lifeos-dev --tail 30` to debug errors
|
||||
- **Contact role pattern:** For now, roles are free-text on tasks/projects/lists. On meetings, roles are constrained to: organizer, attendee, optional. This will be standardized later with a defined role set.
|
||||
- **Empty states:** Every tab/section that can be empty should show a clean empty state message with a CTA button to add the first item.
|
||||
2
main.py
2
main.py
@@ -43,6 +43,7 @@ from routers import (
|
||||
calendar as calendar_router,
|
||||
time_budgets as time_budgets_router,
|
||||
eisenhower as eisenhower_router,
|
||||
history as history_router,
|
||||
)
|
||||
|
||||
|
||||
@@ -201,3 +202,4 @@ app.include_router(processes_router.router)
|
||||
app.include_router(calendar_router.router)
|
||||
app.include_router(time_budgets_router.router)
|
||||
app.include_router(eisenhower_router.router)
|
||||
app.include_router(history_router.router)
|
||||
|
||||
2068
project-docs/lifeos-system-design-document.md
Normal file
2068
project-docs/lifeos-system-design-document.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,7 @@ async def list_decisions(
|
||||
async def create_form(
|
||||
request: Request,
|
||||
meeting_id: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
@@ -72,6 +73,7 @@ async def create_form(
|
||||
"page_title": "New Decision", "active_nav": "decisions",
|
||||
"item": None,
|
||||
"prefill_meeting_id": meeting_id or "",
|
||||
"prefill_task_id": task_id or "",
|
||||
})
|
||||
|
||||
|
||||
@@ -84,6 +86,7 @@ async def create_decision(
|
||||
impact: str = Form("medium"),
|
||||
decided_at: Optional[str] = Form(None),
|
||||
meeting_id: Optional[str] = Form(None),
|
||||
task_id: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -96,10 +99,14 @@ async def create_decision(
|
||||
data["decided_at"] = decided_at
|
||||
if meeting_id and meeting_id.strip():
|
||||
data["meeting_id"] = meeting_id
|
||||
if task_id and task_id.strip():
|
||||
data["task_id"] = task_id
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
decision = await repo.create(data)
|
||||
if task_id and task_id.strip():
|
||||
return RedirectResponse(url=f"/tasks/{task_id}?tab=decisions", status_code=303)
|
||||
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
|
||||
from core.database import get_db
|
||||
from core.base_repository import BaseRepository
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/eisenhower", tags=["eisenhower"])
|
||||
@@ -15,11 +17,36 @@ templates = Jinja2Templates(directory="templates")
|
||||
@router.get("/")
|
||||
async def eisenhower_matrix(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
context: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
|
||||
result = await db.execute(text("""
|
||||
where_clauses = [
|
||||
"t.is_deleted = false",
|
||||
"t.status IN ('open', 'in_progress', 'blocked')",
|
||||
]
|
||||
params = {}
|
||||
|
||||
if domain_id:
|
||||
where_clauses.append("t.domain_id = :domain_id")
|
||||
params["domain_id"] = domain_id
|
||||
if project_id:
|
||||
where_clauses.append("t.project_id = :project_id")
|
||||
params["project_id"] = project_id
|
||||
if status:
|
||||
where_clauses.append("t.status = :status")
|
||||
params["status"] = status
|
||||
if context:
|
||||
where_clauses.append("t.context = :context")
|
||||
params["context"] = context
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT t.id, t.title, t.priority, t.status, t.due_date,
|
||||
t.context, t.estimated_minutes,
|
||||
p.name as project_name,
|
||||
@@ -27,10 +54,9 @@ async def eisenhower_matrix(
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE t.is_deleted = false
|
||||
AND t.status IN ('open', 'in_progress', 'blocked')
|
||||
WHERE {where_sql}
|
||||
ORDER BY t.priority, t.due_date NULLS LAST, t.title
|
||||
"""))
|
||||
"""), params)
|
||||
tasks = [dict(r._mapping) for r in result]
|
||||
|
||||
# Classify into quadrants
|
||||
@@ -39,10 +65,10 @@ async def eisenhower_matrix(
|
||||
urgent_cutoff = today + timedelta(days=7)
|
||||
|
||||
quadrants = {
|
||||
"do_first": [], # Urgent + Important
|
||||
"schedule": [], # Not Urgent + Important
|
||||
"delegate": [], # Urgent + Not Important
|
||||
"eliminate": [], # Not Urgent + Not Important
|
||||
"do_first": [],
|
||||
"schedule": [],
|
||||
"delegate": [],
|
||||
"eliminate": [],
|
||||
}
|
||||
|
||||
for t in tasks:
|
||||
@@ -64,6 +90,17 @@ async def eisenhower_matrix(
|
||||
counts = {k: len(v) for k, v in quadrants.items()}
|
||||
total = sum(counts.values())
|
||||
|
||||
# Filter options
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
|
||||
result = await db.execute(text(
|
||||
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
|
||||
))
|
||||
context_types = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("eisenhower.html", {
|
||||
"request": request,
|
||||
"sidebar": sidebar,
|
||||
@@ -71,6 +108,13 @@ async def eisenhower_matrix(
|
||||
"counts": counts,
|
||||
"total": total,
|
||||
"today": today,
|
||||
"domains": domains,
|
||||
"projects": projects,
|
||||
"context_types": context_types,
|
||||
"current_domain_id": domain_id or "",
|
||||
"current_project_id": project_id or "",
|
||||
"current_status": status or "",
|
||||
"current_context": context or "",
|
||||
"page_title": "Eisenhower Matrix",
|
||||
"active_nav": "eisenhower",
|
||||
})
|
||||
|
||||
@@ -17,7 +17,14 @@ templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def focus_view(request: Request, focus_date: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
||||
async def focus_view(
|
||||
request: Request,
|
||||
focus_date: Optional[str] = None,
|
||||
domain_id: Optional[str] = None,
|
||||
area_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
target_date = date.fromisoformat(focus_date) if focus_date else date.today()
|
||||
|
||||
@@ -36,30 +43,57 @@ async def focus_view(request: Request, focus_date: Optional[str] = None, db: Asy
|
||||
items = [dict(r._mapping) for r in result]
|
||||
|
||||
# Available tasks to add (open, not already in today's focus)
|
||||
result = await db.execute(text("""
|
||||
avail_where = [
|
||||
"t.is_deleted = false",
|
||||
"t.status NOT IN ('done', 'cancelled')",
|
||||
"t.id NOT IN (SELECT task_id FROM daily_focus WHERE focus_date = :target_date AND is_deleted = false)",
|
||||
]
|
||||
avail_params = {"target_date": target_date}
|
||||
|
||||
if domain_id:
|
||||
avail_where.append("t.domain_id = :domain_id")
|
||||
avail_params["domain_id"] = domain_id
|
||||
if area_id:
|
||||
avail_where.append("t.area_id = :area_id")
|
||||
avail_params["area_id"] = area_id
|
||||
if project_id:
|
||||
avail_where.append("t.project_id = :project_id")
|
||||
avail_params["project_id"] = project_id
|
||||
|
||||
avail_sql = " AND ".join(avail_where)
|
||||
|
||||
result = await db.execute(text(f"""
|
||||
SELECT t.id, t.title, t.priority, t.due_date,
|
||||
p.name as project_name, d.name as domain_name
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
WHERE t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
|
||||
AND t.id NOT IN (
|
||||
SELECT task_id FROM daily_focus
|
||||
WHERE focus_date = :target_date AND is_deleted = false
|
||||
)
|
||||
WHERE {avail_sql}
|
||||
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
|
||||
LIMIT 50
|
||||
"""), {"target_date": target_date})
|
||||
"""), avail_params)
|
||||
available_tasks = [dict(r._mapping) for r in result]
|
||||
|
||||
# Estimated total minutes
|
||||
total_est = sum(i.get("estimated_minutes") or 0 for i in items)
|
||||
|
||||
# Filter options
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
areas_repo = BaseRepository("areas", db)
|
||||
areas = await areas_repo.list()
|
||||
projects_repo = BaseRepository("projects", db)
|
||||
projects = await projects_repo.list()
|
||||
|
||||
return templates.TemplateResponse("focus.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"items": items, "available_tasks": available_tasks,
|
||||
"focus_date": target_date,
|
||||
"total_estimated": total_est,
|
||||
"domains": domains, "areas": areas, "projects": projects,
|
||||
"current_domain_id": domain_id or "",
|
||||
"current_area_id": area_id or "",
|
||||
"current_project_id": project_id or "",
|
||||
"page_title": "Daily Focus", "active_nav": "focus",
|
||||
})
|
||||
|
||||
|
||||
91
routers/history.py
Normal file
91
routers/history.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Change History: reverse-chronological feed of recently modified items."""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from core.database import get_db
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/history", tags=["history"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Entity configs: (table, label_column, type_label, url_prefix)
|
||||
HISTORY_ENTITIES = [
|
||||
("domains", "name", "Domain", "/domains"),
|
||||
("areas", "name", "Area", "/areas"),
|
||||
("projects", "name", "Project", "/projects"),
|
||||
("tasks", "title", "Task", "/tasks"),
|
||||
("notes", "title", "Note", "/notes"),
|
||||
("contacts", "first_name", "Contact", "/contacts"),
|
||||
("meetings", "title", "Meeting", "/meetings"),
|
||||
("decisions", "title", "Decision", "/decisions"),
|
||||
("lists", "name", "List", "/lists"),
|
||||
("weblinks", "label", "Weblink", "/weblinks"),
|
||||
("appointments", "title", "Appointment", "/appointments"),
|
||||
("links", "label", "Link", "/links"),
|
||||
("files", "original_filename", "File", "/files"),
|
||||
("capture", "content", "Capture", "/capture"),
|
||||
]
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def history_view(
|
||||
request: Request,
|
||||
entity_type: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
|
||||
all_items = []
|
||||
|
||||
for table, label_col, type_label, url_prefix in HISTORY_ENTITIES:
|
||||
if entity_type and entity_type != table:
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await db.execute(text(f"""
|
||||
SELECT id, {label_col} as label, updated_at, created_at
|
||||
FROM {table}
|
||||
WHERE is_deleted = false
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 20
|
||||
"""))
|
||||
for r in result:
|
||||
row = dict(r._mapping)
|
||||
# Determine action
|
||||
action = "created"
|
||||
if row["updated_at"] and row["created_at"]:
|
||||
diff = abs((row["updated_at"] - row["created_at"]).total_seconds())
|
||||
if diff > 1:
|
||||
action = "modified"
|
||||
|
||||
all_items.append({
|
||||
"type": table,
|
||||
"type_label": type_label,
|
||||
"id": str(row["id"]),
|
||||
"label": str(row["label"] or "Untitled")[:80],
|
||||
"url": f"{url_prefix}/{row['id']}",
|
||||
"updated_at": row["updated_at"],
|
||||
"action": action,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Sort by updated_at descending, take top 50
|
||||
all_items.sort(key=lambda x: x["updated_at"] or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||||
all_items = all_items[:50]
|
||||
|
||||
# Build entity type options for filter
|
||||
type_options = [{"value": t[0], "label": t[2]} for t in HISTORY_ENTITIES]
|
||||
|
||||
return templates.TemplateResponse("history.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
"items": all_items,
|
||||
"type_options": type_options,
|
||||
"current_type": entity_type or "",
|
||||
"page_title": "Change History", "active_nav": "history",
|
||||
})
|
||||
@@ -70,6 +70,8 @@ async def create_form(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
meeting_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
@@ -87,6 +89,8 @@ async def create_form(
|
||||
"item": None,
|
||||
"prefill_domain_id": domain_id or "",
|
||||
"prefill_project_id": project_id or "",
|
||||
"prefill_task_id": task_id or "",
|
||||
"prefill_meeting_id": meeting_id or "",
|
||||
})
|
||||
|
||||
|
||||
@@ -97,6 +101,8 @@ async def create_list(
|
||||
domain_id: str = Form(...),
|
||||
area_id: Optional[str] = Form(None),
|
||||
project_id: Optional[str] = Form(None),
|
||||
task_id: Optional[str] = Form(None),
|
||||
meeting_id: Optional[str] = Form(None),
|
||||
list_type: str = Form("checklist"),
|
||||
description: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
@@ -112,10 +118,18 @@ async def create_list(
|
||||
data["area_id"] = area_id
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
if task_id and task_id.strip():
|
||||
data["task_id"] = task_id
|
||||
if meeting_id and meeting_id.strip():
|
||||
data["meeting_id"] = meeting_id
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
new_list = await repo.create(data)
|
||||
if task_id and task_id.strip():
|
||||
return RedirectResponse(url=f"/tasks/{task_id}?tab=lists", status_code=303)
|
||||
if meeting_id and meeting_id.strip():
|
||||
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=lists", status_code=303)
|
||||
return RedirectResponse(url=f"/lists/{new_list['id']}", status_code=303)
|
||||
|
||||
|
||||
@@ -156,10 +170,28 @@ async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends
|
||||
if pid:
|
||||
child_map.setdefault(str(pid), []).append(i)
|
||||
|
||||
# Contacts linked to this list
|
||||
result = await db.execute(text("""
|
||||
SELECT c.*, cl.role, cl.created_at as linked_at
|
||||
FROM contacts c
|
||||
JOIN contact_lists cl ON cl.contact_id = c.id
|
||||
WHERE cl.list_id = :lid AND c.is_deleted = false
|
||||
ORDER BY c.first_name
|
||||
"""), {"lid": list_id})
|
||||
contacts = [dict(r._mapping) for r in result]
|
||||
|
||||
# All contacts for add dropdown
|
||||
result = await db.execute(text("""
|
||||
SELECT id, first_name, last_name FROM contacts
|
||||
WHERE is_deleted = false ORDER BY first_name
|
||||
"""))
|
||||
all_contacts = [dict(r._mapping) for r in result]
|
||||
|
||||
return templates.TemplateResponse("list_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"domain": domain, "project": project,
|
||||
"list_items": top_items, "child_map": child_map,
|
||||
"contacts": contacts, "all_contacts": all_contacts,
|
||||
"page_title": item["name"], "active_nav": "lists",
|
||||
})
|
||||
|
||||
@@ -273,3 +305,30 @@ async def edit_item(
|
||||
repo = BaseRepository("list_items", db)
|
||||
await repo.update(item_id, {"content": content})
|
||||
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
||||
|
||||
|
||||
# ---- Contact linking ----
|
||||
|
||||
@router.post("/{list_id}/contacts/add")
|
||||
async def add_contact(
|
||||
list_id: str,
|
||||
contact_id: str = Form(...),
|
||||
role: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(text("""
|
||||
INSERT INTO contact_lists (contact_id, list_id, role)
|
||||
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
|
||||
"""), {"cid": contact_id, "lid": list_id, "role": role if role and role.strip() else None})
|
||||
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{list_id}/contacts/{contact_id}/remove")
|
||||
async def remove_contact(
|
||||
list_id: str, contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(text(
|
||||
"DELETE FROM contact_lists WHERE contact_id = :cid AND list_id = :lid"
|
||||
), {"cid": contact_id, "lid": list_id})
|
||||
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
||||
|
||||
@@ -107,14 +107,26 @@ async def create_meeting(
|
||||
|
||||
|
||||
@router.get("/{meeting_id}")
|
||||
async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
async def meeting_detail(
|
||||
meeting_id: str, request: Request,
|
||||
tab: str = "overview",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("meetings", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(meeting_id)
|
||||
if not item:
|
||||
return RedirectResponse(url="/meetings", status_code=303)
|
||||
|
||||
# Action items (tasks linked to this meeting)
|
||||
# Overview data (always needed for overview tab)
|
||||
action_items = []
|
||||
decisions = []
|
||||
domains = []
|
||||
tab_data = []
|
||||
all_contacts = []
|
||||
|
||||
if tab == "overview":
|
||||
# Action items
|
||||
result = await db.execute(text("""
|
||||
SELECT t.*, mt.source,
|
||||
d.name as domain_name, d.color as domain_color,
|
||||
@@ -128,14 +140,6 @@ async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = D
|
||||
"""), {"mid": meeting_id})
|
||||
action_items = [dict(r._mapping) for r in result]
|
||||
|
||||
# Notes linked to this meeting
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM notes
|
||||
WHERE meeting_id = :mid AND is_deleted = false
|
||||
ORDER BY created_at
|
||||
"""), {"mid": meeting_id})
|
||||
meeting_notes = [dict(r._mapping) for r in result]
|
||||
|
||||
# Decisions from this meeting
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM decisions
|
||||
@@ -144,24 +148,77 @@ async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = D
|
||||
"""), {"mid": meeting_id})
|
||||
decisions = [dict(r._mapping) for r in result]
|
||||
|
||||
# Attendees
|
||||
result = await db.execute(text("""
|
||||
SELECT c.*, cm.role FROM contact_meetings cm
|
||||
JOIN contacts c ON cm.contact_id = c.id
|
||||
WHERE cm.meeting_id = :mid AND c.is_deleted = false
|
||||
ORDER BY c.first_name
|
||||
"""), {"mid": meeting_id})
|
||||
attendees = [dict(r._mapping) for r in result]
|
||||
|
||||
# Domains for action item creation
|
||||
domains_repo = BaseRepository("domains", db)
|
||||
domains = await domains_repo.list()
|
||||
|
||||
elif tab == "notes":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM notes
|
||||
WHERE meeting_id = :mid AND is_deleted = false
|
||||
ORDER BY updated_at DESC
|
||||
"""), {"mid": meeting_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "weblinks":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM weblinks
|
||||
WHERE meeting_id = :mid AND is_deleted = false
|
||||
ORDER BY sort_order, label
|
||||
"""), {"mid": meeting_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "files":
|
||||
result = await db.execute(text("""
|
||||
SELECT f.* FROM files f
|
||||
JOIN file_mappings fm ON fm.file_id = f.id
|
||||
WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false
|
||||
ORDER BY f.created_at DESC
|
||||
"""), {"mid": meeting_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "lists":
|
||||
result = await db.execute(text("""
|
||||
SELECT l.*,
|
||||
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
|
||||
FROM lists l
|
||||
WHERE l.meeting_id = :mid AND l.is_deleted = false
|
||||
ORDER BY l.sort_order, l.created_at DESC
|
||||
"""), {"mid": meeting_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "contacts":
|
||||
result = await db.execute(text("""
|
||||
SELECT c.*, cm.role, cm.created_at as linked_at
|
||||
FROM contacts c
|
||||
JOIN contact_meetings cm ON cm.contact_id = c.id
|
||||
WHERE cm.meeting_id = :mid AND c.is_deleted = false
|
||||
ORDER BY c.first_name
|
||||
"""), {"mid": meeting_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
result = await db.execute(text("""
|
||||
SELECT id, first_name, last_name FROM contacts
|
||||
WHERE is_deleted = false ORDER BY first_name
|
||||
"""))
|
||||
all_contacts = [dict(r._mapping) for r in result]
|
||||
|
||||
# Tab counts
|
||||
counts = {}
|
||||
for count_tab, count_sql in [
|
||||
("notes", "SELECT count(*) FROM notes WHERE meeting_id = :mid AND is_deleted = false"),
|
||||
("weblinks", "SELECT count(*) FROM weblinks WHERE meeting_id = :mid AND is_deleted = false"),
|
||||
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false"),
|
||||
("lists", "SELECT count(*) FROM lists WHERE meeting_id = :mid AND is_deleted = false"),
|
||||
("contacts", "SELECT count(*) FROM contacts c JOIN contact_meetings cm ON cm.contact_id = c.id WHERE cm.meeting_id = :mid AND c.is_deleted = false"),
|
||||
]:
|
||||
result = await db.execute(text(count_sql), {"mid": meeting_id})
|
||||
counts[count_tab] = result.scalar() or 0
|
||||
|
||||
return templates.TemplateResponse("meeting_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"action_items": action_items, "meeting_notes": meeting_notes,
|
||||
"decisions": decisions, "attendees": attendees,
|
||||
"domains": domains,
|
||||
"action_items": action_items, "decisions": decisions,
|
||||
"domains": domains, "tab": tab, "tab_data": tab_data,
|
||||
"all_contacts": all_contacts, "counts": counts,
|
||||
"page_title": item["title"], "active_nav": "meetings",
|
||||
})
|
||||
|
||||
@@ -266,3 +323,30 @@ async def create_action_item(
|
||||
"""), {"mid": meeting_id, "tid": task["id"]})
|
||||
|
||||
return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303)
|
||||
|
||||
|
||||
# ---- Contact linking ----
|
||||
|
||||
@router.post("/{meeting_id}/contacts/add")
|
||||
async def add_contact(
|
||||
meeting_id: str,
|
||||
contact_id: str = Form(...),
|
||||
role: str = Form("attendee"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(text("""
|
||||
INSERT INTO contact_meetings (contact_id, meeting_id, role)
|
||||
VALUES (:cid, :mid, :role) ON CONFLICT DO NOTHING
|
||||
"""), {"cid": contact_id, "mid": meeting_id, "role": role if role and role.strip() else "attendee"})
|
||||
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{meeting_id}/contacts/{contact_id}/remove")
|
||||
async def remove_contact(
|
||||
meeting_id: str, contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(text(
|
||||
"DELETE FROM contact_meetings WHERE contact_id = :cid AND meeting_id = :mid"
|
||||
), {"cid": contact_id, "mid": meeting_id})
|
||||
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
|
||||
|
||||
@@ -61,6 +61,8 @@ async def create_form(
|
||||
request: Request,
|
||||
domain_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
meeting_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
@@ -75,6 +77,8 @@ async def create_form(
|
||||
"item": None,
|
||||
"prefill_domain_id": domain_id or "",
|
||||
"prefill_project_id": project_id or "",
|
||||
"prefill_task_id": task_id or "",
|
||||
"prefill_meeting_id": meeting_id or "",
|
||||
})
|
||||
|
||||
|
||||
@@ -84,6 +88,8 @@ async def create_note(
|
||||
title: str = Form(...),
|
||||
domain_id: str = Form(...),
|
||||
project_id: Optional[str] = Form(None),
|
||||
task_id: Optional[str] = Form(None),
|
||||
meeting_id: Optional[str] = Form(None),
|
||||
body: Optional[str] = Form(None),
|
||||
content_format: str = Form("rich"),
|
||||
tags: Optional[str] = Form(None),
|
||||
@@ -96,9 +102,17 @@ async def create_note(
|
||||
}
|
||||
if project_id and project_id.strip():
|
||||
data["project_id"] = project_id
|
||||
if task_id and task_id.strip():
|
||||
data["task_id"] = task_id
|
||||
if meeting_id and meeting_id.strip():
|
||||
data["meeting_id"] = meeting_id
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
note = await repo.create(data)
|
||||
if task_id and task_id.strip():
|
||||
return RedirectResponse(url=f"/tasks/{task_id}?tab=notes", status_code=303)
|
||||
if meeting_id and meeting_id.strip():
|
||||
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=notes", status_code=303)
|
||||
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter, Request, Form, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from typing import Optional
|
||||
@@ -15,6 +15,27 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/api/by-domain")
|
||||
async def api_projects_by_domain(
|
||||
domain_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""JSON API: return projects filtered by domain_id for dynamic dropdowns."""
|
||||
if domain_id:
|
||||
result = await db.execute(text("""
|
||||
SELECT id, name FROM projects
|
||||
WHERE is_deleted = false AND (domain_id = :did OR domain_id IS NULL)
|
||||
ORDER BY name
|
||||
"""), {"did": domain_id})
|
||||
else:
|
||||
result = await db.execute(text("""
|
||||
SELECT id, name FROM projects
|
||||
WHERE is_deleted = false ORDER BY name
|
||||
"""))
|
||||
projects = [{"id": str(r.id), "name": r.name} for r in result]
|
||||
return JSONResponse(content=projects)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_projects(
|
||||
request: Request,
|
||||
@@ -151,7 +172,7 @@ async def project_detail(
|
||||
row = result.first()
|
||||
area = dict(row._mapping) if row else None
|
||||
|
||||
# Tasks for this project
|
||||
# Tasks for this project (always needed for progress bar)
|
||||
result = await db.execute(text("""
|
||||
SELECT t.*, d.name as domain_name, d.color as domain_color
|
||||
FROM tasks t
|
||||
@@ -161,29 +182,92 @@ async def project_detail(
|
||||
"""), {"pid": project_id})
|
||||
tasks = [dict(r._mapping) for r in result]
|
||||
|
||||
# Notes
|
||||
# Progress
|
||||
total = len(tasks)
|
||||
done = len([t for t in tasks if t["status"] == "done"])
|
||||
progress = round((done / total * 100) if total > 0 else 0)
|
||||
|
||||
# Tab-specific data
|
||||
notes = []
|
||||
links = []
|
||||
tab_data = []
|
||||
all_contacts = []
|
||||
|
||||
if tab == "notes":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
|
||||
ORDER BY sort_order, created_at DESC
|
||||
"""), {"pid": project_id})
|
||||
notes = [dict(r._mapping) for r in result]
|
||||
|
||||
# Links
|
||||
elif tab == "links":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
|
||||
ORDER BY sort_order, created_at
|
||||
"""), {"pid": project_id})
|
||||
links = [dict(r._mapping) for r in result]
|
||||
|
||||
# Progress
|
||||
total = len(tasks)
|
||||
done = len([t for t in tasks if t["status"] == "done"])
|
||||
progress = round((done / total * 100) if total > 0 else 0)
|
||||
elif tab == "files":
|
||||
result = await db.execute(text("""
|
||||
SELECT f.* FROM files f
|
||||
JOIN file_mappings fm ON fm.file_id = f.id
|
||||
WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false
|
||||
ORDER BY f.created_at DESC
|
||||
"""), {"pid": project_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "lists":
|
||||
result = await db.execute(text("""
|
||||
SELECT l.*,
|
||||
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
|
||||
FROM lists l
|
||||
WHERE l.project_id = :pid AND l.is_deleted = false
|
||||
ORDER BY l.sort_order, l.created_at DESC
|
||||
"""), {"pid": project_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "decisions":
|
||||
result = await db.execute(text("""
|
||||
SELECT d.* FROM decisions d
|
||||
JOIN decision_projects dp ON dp.decision_id = d.id
|
||||
WHERE dp.project_id = :pid AND d.is_deleted = false
|
||||
ORDER BY d.created_at DESC
|
||||
"""), {"pid": project_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "contacts":
|
||||
result = await db.execute(text("""
|
||||
SELECT c.*, cp.role, cp.created_at as linked_at
|
||||
FROM contacts c
|
||||
JOIN contact_projects cp ON cp.contact_id = c.id
|
||||
WHERE cp.project_id = :pid AND c.is_deleted = false
|
||||
ORDER BY c.first_name
|
||||
"""), {"pid": project_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
result = await db.execute(text("""
|
||||
SELECT id, first_name, last_name FROM contacts
|
||||
WHERE is_deleted = false ORDER BY first_name
|
||||
"""))
|
||||
all_contacts = [dict(r._mapping) for r in result]
|
||||
|
||||
# Tab counts
|
||||
counts = {}
|
||||
for count_tab, count_sql in [
|
||||
("notes", "SELECT count(*) FROM notes WHERE project_id = :pid AND is_deleted = false"),
|
||||
("links", "SELECT count(*) FROM links WHERE project_id = :pid AND is_deleted = false"),
|
||||
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false"),
|
||||
("lists", "SELECT count(*) FROM lists WHERE project_id = :pid AND is_deleted = false"),
|
||||
("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"),
|
||||
("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"),
|
||||
]:
|
||||
result = await db.execute(text(count_sql), {"pid": project_id})
|
||||
counts[count_tab] = result.scalar() or 0
|
||||
|
||||
return templates.TemplateResponse("project_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"domain": domain, "area": area,
|
||||
"tasks": tasks, "notes": notes, "links": links,
|
||||
"tab_data": tab_data, "all_contacts": all_contacts, "counts": counts,
|
||||
"progress": progress, "task_count": total, "done_count": done,
|
||||
"tab": tab,
|
||||
"page_title": item["name"], "active_nav": "projects",
|
||||
@@ -246,3 +330,30 @@ async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
||||
repo = BaseRepository("projects", db)
|
||||
await repo.soft_delete(project_id)
|
||||
return RedirectResponse(url="/projects", status_code=303)
|
||||
|
||||
|
||||
# ---- Contact linking ----
|
||||
|
||||
@router.post("/{project_id}/contacts/add")
|
||||
async def add_contact(
|
||||
project_id: str,
|
||||
contact_id: str = Form(...),
|
||||
role: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(text("""
|
||||
INSERT INTO contact_projects (contact_id, project_id, role)
|
||||
VALUES (:cid, :pid, :role) ON CONFLICT DO NOTHING
|
||||
"""), {"cid": contact_id, "pid": project_id, "role": role if role and role.strip() else None})
|
||||
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{project_id}/contacts/{contact_id}/remove")
|
||||
async def remove_contact(
|
||||
project_id: str, contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(text(
|
||||
"DELETE FROM contact_projects WHERE contact_id = :cid AND project_id = :pid"
|
||||
), {"cid": contact_id, "pid": project_id})
|
||||
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Global search: Cmd/K modal, tsvector full-text search across all entities."""
|
||||
|
||||
import re
|
||||
from fastapi import APIRouter, Request, Depends, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -14,174 +15,162 @@ router = APIRouter(prefix="/search", tags=["search"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# Entity search configs: (table, title_col, subtitle_query, url_pattern, icon)
|
||||
def build_prefix_tsquery(query: str) -> Optional[str]:
|
||||
"""Build a prefix-matching tsquery string from user input.
|
||||
'Sys Admin' -> 'Sys:* & Admin:*'
|
||||
Returns None if no valid terms.
|
||||
"""
|
||||
terms = query.strip().split()
|
||||
# Keep only alphanumeric terms >= 2 chars
|
||||
clean = [re.sub(r'[^\w]', '', t) for t in terms]
|
||||
clean = [t for t in clean if len(t) >= 2]
|
||||
if not clean:
|
||||
return None
|
||||
return " & ".join(f"{t}:*" for t in clean)
|
||||
|
||||
|
||||
# Entity search configs
|
||||
# Each has: type, label, table, name_col, joins, extra_cols, url, icon
|
||||
SEARCH_ENTITIES = [
|
||||
{
|
||||
"type": "tasks",
|
||||
"label": "Tasks",
|
||||
"query": """
|
||||
SELECT t.id, t.title as name, t.status,
|
||||
d.name as domain_name, p.name as project_name,
|
||||
ts_rank(t.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM tasks t
|
||||
LEFT JOIN domains d ON t.domain_id = d.id
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
WHERE t.is_deleted = false AND t.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/tasks/{id}",
|
||||
"icon": "task",
|
||||
"type": "tasks", "label": "Tasks", "table": "tasks", "alias": "t",
|
||||
"name_col": "t.title", "status_col": "t.status",
|
||||
"joins": "LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN projects p ON t.project_id = p.id",
|
||||
"domain_col": "d.name", "project_col": "p.name",
|
||||
"url": "/tasks/{id}", "icon": "task",
|
||||
},
|
||||
{
|
||||
"type": "projects",
|
||||
"label": "Projects",
|
||||
"query": """
|
||||
SELECT p.id, p.name, p.status,
|
||||
d.name as domain_name, NULL as project_name,
|
||||
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM projects p
|
||||
LEFT JOIN domains d ON p.domain_id = d.id
|
||||
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/projects/{id}",
|
||||
"icon": "project",
|
||||
"type": "projects", "label": "Projects", "table": "projects", "alias": "p",
|
||||
"name_col": "p.name", "status_col": "p.status",
|
||||
"joins": "LEFT JOIN domains d ON p.domain_id = d.id",
|
||||
"domain_col": "d.name", "project_col": "NULL",
|
||||
"url": "/projects/{id}", "icon": "project",
|
||||
},
|
||||
{
|
||||
"type": "notes",
|
||||
"label": "Notes",
|
||||
"query": """
|
||||
SELECT n.id, n.title as name, NULL as status,
|
||||
d.name as domain_name, p.name as project_name,
|
||||
ts_rank(n.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM notes n
|
||||
LEFT JOIN domains d ON n.domain_id = d.id
|
||||
LEFT JOIN projects p ON n.project_id = p.id
|
||||
WHERE n.is_deleted = false AND n.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/notes/{id}",
|
||||
"icon": "note",
|
||||
"type": "notes", "label": "Notes", "table": "notes", "alias": "n",
|
||||
"name_col": "n.title", "status_col": "NULL",
|
||||
"joins": "LEFT JOIN domains d ON n.domain_id = d.id LEFT JOIN projects p ON n.project_id = p.id",
|
||||
"domain_col": "d.name", "project_col": "p.name",
|
||||
"url": "/notes/{id}", "icon": "note",
|
||||
},
|
||||
{
|
||||
"type": "contacts",
|
||||
"label": "Contacts",
|
||||
"query": """
|
||||
SELECT c.id, (c.first_name || ' ' || coalesce(c.last_name, '')) as name,
|
||||
NULL as status, c.company as domain_name, NULL as project_name,
|
||||
ts_rank(c.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM contacts c
|
||||
WHERE c.is_deleted = false AND c.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/contacts/{id}",
|
||||
"icon": "contact",
|
||||
"type": "contacts", "label": "Contacts", "table": "contacts", "alias": "c",
|
||||
"name_col": "(c.first_name || ' ' || coalesce(c.last_name, ''))", "status_col": "NULL",
|
||||
"joins": "",
|
||||
"domain_col": "c.company", "project_col": "NULL",
|
||||
"url": "/contacts/{id}", "icon": "contact",
|
||||
},
|
||||
{
|
||||
"type": "links",
|
||||
"label": "Links",
|
||||
"query": """
|
||||
SELECT l.id, l.label as name, NULL as status,
|
||||
d.name as domain_name, p.name as project_name,
|
||||
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM links l
|
||||
LEFT JOIN domains d ON l.domain_id = d.id
|
||||
LEFT JOIN projects p ON l.project_id = p.id
|
||||
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/links",
|
||||
"icon": "link",
|
||||
"type": "links", "label": "Links", "table": "links", "alias": "l",
|
||||
"name_col": "l.label", "status_col": "NULL",
|
||||
"joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
|
||||
"domain_col": "d.name", "project_col": "p.name",
|
||||
"url": "/links", "icon": "link",
|
||||
},
|
||||
{
|
||||
"type": "lists",
|
||||
"label": "Lists",
|
||||
"query": """
|
||||
SELECT l.id, l.name, NULL as status,
|
||||
d.name as domain_name, p.name as project_name,
|
||||
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM lists l
|
||||
LEFT JOIN domains d ON l.domain_id = d.id
|
||||
LEFT JOIN projects p ON l.project_id = p.id
|
||||
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/lists/{id}",
|
||||
"icon": "list",
|
||||
"type": "lists", "label": "Lists", "table": "lists", "alias": "l",
|
||||
"name_col": "l.name", "status_col": "NULL",
|
||||
"joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
|
||||
"domain_col": "d.name", "project_col": "p.name",
|
||||
"url": "/lists/{id}", "icon": "list",
|
||||
},
|
||||
{
|
||||
"type": "meetings",
|
||||
"label": "Meetings",
|
||||
"query": """
|
||||
SELECT m.id, m.title as name, m.status,
|
||||
NULL as domain_name, NULL as project_name,
|
||||
ts_rank(m.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM meetings m
|
||||
WHERE m.is_deleted = false AND m.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/meetings/{id}",
|
||||
"icon": "meeting",
|
||||
"type": "meetings", "label": "Meetings", "table": "meetings", "alias": "m",
|
||||
"name_col": "m.title", "status_col": "m.status",
|
||||
"joins": "",
|
||||
"domain_col": "NULL", "project_col": "NULL",
|
||||
"url": "/meetings/{id}", "icon": "meeting",
|
||||
},
|
||||
{
|
||||
"type": "decisions",
|
||||
"label": "Decisions",
|
||||
"query": """
|
||||
SELECT d.id, d.title as name, d.status,
|
||||
NULL as domain_name, NULL as project_name,
|
||||
ts_rank(d.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM decisions d
|
||||
WHERE d.is_deleted = false AND d.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/decisions/{id}",
|
||||
"icon": "decision",
|
||||
"type": "decisions", "label": "Decisions", "table": "decisions", "alias": "d",
|
||||
"name_col": "d.title", "status_col": "d.status",
|
||||
"joins": "",
|
||||
"domain_col": "NULL", "project_col": "NULL",
|
||||
"url": "/decisions/{id}", "icon": "decision",
|
||||
},
|
||||
{
|
||||
"type": "weblinks",
|
||||
"label": "Weblinks",
|
||||
"query": """
|
||||
SELECT w.id, w.label as name, NULL as status,
|
||||
NULL as domain_name, NULL as project_name,
|
||||
ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM weblinks w
|
||||
WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/weblinks",
|
||||
"icon": "weblink",
|
||||
"type": "weblinks", "label": "Weblinks", "table": "weblinks", "alias": "w",
|
||||
"name_col": "w.label", "status_col": "NULL",
|
||||
"joins": "",
|
||||
"domain_col": "NULL", "project_col": "NULL",
|
||||
"url": "/weblinks", "icon": "weblink",
|
||||
},
|
||||
{
|
||||
"type": "processes",
|
||||
"label": "Processes",
|
||||
"query": """
|
||||
SELECT p.id, p.name, p.status,
|
||||
p.category as domain_name, NULL as project_name,
|
||||
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM processes p
|
||||
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/processes/{id}",
|
||||
"icon": "process",
|
||||
"type": "processes", "label": "Processes", "table": "processes", "alias": "p",
|
||||
"name_col": "p.name", "status_col": "p.status",
|
||||
"joins": "",
|
||||
"domain_col": "p.category", "project_col": "NULL",
|
||||
"url": "/processes/{id}", "icon": "process",
|
||||
},
|
||||
{
|
||||
"type": "appointments",
|
||||
"label": "Appointments",
|
||||
"query": """
|
||||
SELECT a.id, a.title as name, NULL as status,
|
||||
a.location as domain_name, NULL as project_name,
|
||||
ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank
|
||||
FROM appointments a
|
||||
WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
""",
|
||||
"url": "/appointments/{id}",
|
||||
"icon": "appointment",
|
||||
"type": "appointments", "label": "Appointments", "table": "appointments", "alias": "a",
|
||||
"name_col": "a.title", "status_col": "NULL",
|
||||
"joins": "",
|
||||
"domain_col": "a.location", "project_col": "NULL",
|
||||
"url": "/appointments/{id}", "icon": "appointment",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _search_entity(entity: dict, q: str, tsquery_str: str, limit: int, db: AsyncSession) -> list[dict]:
|
||||
"""Search a single entity using prefix tsquery, with ILIKE fallback."""
|
||||
a = entity["alias"]
|
||||
results = []
|
||||
seen_ids = set()
|
||||
|
||||
# 1. tsvector prefix search
|
||||
if tsquery_str:
|
||||
sql = f"""
|
||||
SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status,
|
||||
{entity['domain_col']} as domain_name, {entity['project_col']} as project_name,
|
||||
ts_rank({a}.search_vector, to_tsquery('english', :tsq)) as rank
|
||||
FROM {entity['table']} {a}
|
||||
{entity['joins']}
|
||||
WHERE {a}.is_deleted = false AND {a}.search_vector @@ to_tsquery('english', :tsq)
|
||||
ORDER BY rank DESC LIMIT :lim
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(text(sql), {"tsq": tsquery_str, "lim": limit})
|
||||
for r in result:
|
||||
row = dict(r._mapping)
|
||||
results.append(row)
|
||||
seen_ids.add(str(row["id"]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. ILIKE fallback if < 3 tsvector results
|
||||
if len(results) < 3:
|
||||
ilike_param = f"%{q.strip()}%"
|
||||
remaining = limit - len(results)
|
||||
if remaining > 0:
|
||||
# Build exclusion for already-found IDs
|
||||
exclude_sql = ""
|
||||
params = {"ilike_q": ilike_param, "lim2": remaining}
|
||||
if seen_ids:
|
||||
id_placeholders = ", ".join(f":ex_{i}" for i in range(len(seen_ids)))
|
||||
exclude_sql = f"AND {a}.id NOT IN ({id_placeholders})"
|
||||
for i, sid in enumerate(seen_ids):
|
||||
params[f"ex_{i}"] = sid
|
||||
|
||||
sql2 = f"""
|
||||
SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status,
|
||||
{entity['domain_col']} as domain_name, {entity['project_col']} as project_name,
|
||||
0.0 as rank
|
||||
FROM {entity['table']} {a}
|
||||
{entity['joins']}
|
||||
WHERE {a}.is_deleted = false AND {entity['name_col']} ILIKE :ilike_q {exclude_sql}
|
||||
ORDER BY {entity['name_col']} LIMIT :lim2
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(text(sql2), params)
|
||||
for r in result:
|
||||
results.append(dict(r._mapping))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
async def search_api(
|
||||
q: str = Query("", min_length=1),
|
||||
@@ -190,18 +179,18 @@ async def search_api(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""JSON search endpoint for the Cmd/K modal."""
|
||||
if not q or not q.strip():
|
||||
if not q or not q.strip() or len(q.strip()) < 2:
|
||||
return JSONResponse({"results": [], "query": q})
|
||||
|
||||
tsquery_str = build_prefix_tsquery(q)
|
||||
|
||||
results = []
|
||||
entities = SEARCH_ENTITIES
|
||||
if entity_type:
|
||||
entities = [e for e in entities if e["type"] == entity_type]
|
||||
|
||||
for entity in entities:
|
||||
try:
|
||||
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": limit})
|
||||
rows = [dict(r._mapping) for r in result]
|
||||
rows = await _search_entity(entity, q, tsquery_str, limit, db)
|
||||
for row in rows:
|
||||
results.append({
|
||||
"type": entity["type"],
|
||||
@@ -214,9 +203,6 @@ async def search_api(
|
||||
"rank": float(row.get("rank", 0)),
|
||||
"icon": entity["icon"],
|
||||
})
|
||||
except Exception:
|
||||
# Table might not have search_vector yet, skip silently
|
||||
continue
|
||||
|
||||
# Sort all results by rank descending
|
||||
results.sort(key=lambda r: r["rank"], reverse=True)
|
||||
@@ -234,11 +220,11 @@ async def search_page(
|
||||
sidebar = await get_sidebar_data(db)
|
||||
results = []
|
||||
|
||||
if q and q.strip():
|
||||
if q and q.strip() and len(q.strip()) >= 2:
|
||||
tsquery_str = build_prefix_tsquery(q)
|
||||
|
||||
for entity in SEARCH_ENTITIES:
|
||||
try:
|
||||
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": 10})
|
||||
rows = [dict(r._mapping) for r in result]
|
||||
rows = await _search_entity(entity, q, tsquery_str, 10, db)
|
||||
for row in rows:
|
||||
results.append({
|
||||
"type": entity["type"],
|
||||
@@ -250,8 +236,6 @@ async def search_page(
|
||||
"url": entity["url"].format(id=row["id"]),
|
||||
"icon": entity["icon"],
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return templates.TemplateResponse("search.html", {
|
||||
"request": request, "sidebar": sidebar,
|
||||
|
||||
119
routers/tasks.py
119
routers/tasks.py
@@ -186,7 +186,11 @@ async def create_task(
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
async def task_detail(
|
||||
task_id: str, request: Request,
|
||||
tab: str = "overview",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("tasks", db)
|
||||
sidebar = await get_sidebar_data(db)
|
||||
item = await repo.get(task_id)
|
||||
@@ -212,7 +216,9 @@ async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends
|
||||
row = result.first()
|
||||
parent = dict(row._mapping) if row else None
|
||||
|
||||
# Subtasks
|
||||
# Subtasks (always needed for overview tab)
|
||||
subtasks = []
|
||||
if tab == "overview":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
|
||||
ORDER BY sort_order, created_at
|
||||
@@ -221,10 +227,90 @@ async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends
|
||||
|
||||
running_task_id = await get_running_task_id(db)
|
||||
|
||||
# Tab-specific data
|
||||
tab_data = []
|
||||
all_contacts = []
|
||||
|
||||
if tab == "notes":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM notes WHERE task_id = :tid AND is_deleted = false
|
||||
ORDER BY updated_at DESC
|
||||
"""), {"tid": task_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "weblinks":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM weblinks WHERE task_id = :tid AND is_deleted = false
|
||||
ORDER BY sort_order, label
|
||||
"""), {"tid": task_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "files":
|
||||
result = await db.execute(text("""
|
||||
SELECT f.* FROM files f
|
||||
JOIN file_mappings fm ON fm.file_id = f.id
|
||||
WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false
|
||||
ORDER BY f.created_at DESC
|
||||
"""), {"tid": task_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "lists":
|
||||
result = await db.execute(text("""
|
||||
SELECT l.*,
|
||||
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
|
||||
FROM lists l
|
||||
WHERE l.task_id = :tid AND l.is_deleted = false
|
||||
ORDER BY l.sort_order, l.created_at DESC
|
||||
"""), {"tid": task_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "decisions":
|
||||
result = await db.execute(text("""
|
||||
SELECT * FROM decisions WHERE task_id = :tid AND is_deleted = false
|
||||
ORDER BY created_at DESC
|
||||
"""), {"tid": task_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
|
||||
elif tab == "contacts":
|
||||
result = await db.execute(text("""
|
||||
SELECT c.*, ct.role, ct.created_at as linked_at
|
||||
FROM contacts c
|
||||
JOIN contact_tasks ct ON ct.contact_id = c.id
|
||||
WHERE ct.task_id = :tid AND c.is_deleted = false
|
||||
ORDER BY c.first_name
|
||||
"""), {"tid": task_id})
|
||||
tab_data = [dict(r._mapping) for r in result]
|
||||
# All contacts for add dropdown
|
||||
result = await db.execute(text("""
|
||||
SELECT id, first_name, last_name FROM contacts
|
||||
WHERE is_deleted = false ORDER BY first_name
|
||||
"""))
|
||||
all_contacts = [dict(r._mapping) for r in result]
|
||||
|
||||
# Tab counts for badges
|
||||
counts = {}
|
||||
for count_tab, count_sql in [
|
||||
("notes", "SELECT count(*) FROM notes WHERE task_id = :tid AND is_deleted = false"),
|
||||
("weblinks", "SELECT count(*) FROM weblinks WHERE task_id = :tid AND is_deleted = false"),
|
||||
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false"),
|
||||
("lists", "SELECT count(*) FROM lists WHERE task_id = :tid AND is_deleted = false"),
|
||||
("decisions", "SELECT count(*) FROM decisions WHERE task_id = :tid AND is_deleted = false"),
|
||||
("contacts", "SELECT count(*) FROM contacts c JOIN contact_tasks ct ON ct.contact_id = c.id WHERE ct.task_id = :tid AND c.is_deleted = false"),
|
||||
]:
|
||||
result = await db.execute(text(count_sql), {"tid": task_id})
|
||||
counts[count_tab] = result.scalar() or 0
|
||||
|
||||
# Subtask count for overview badge
|
||||
result = await db.execute(text(
|
||||
"SELECT count(*) FROM tasks WHERE parent_id = :tid AND is_deleted = false"
|
||||
), {"tid": task_id})
|
||||
counts["overview"] = result.scalar() or 0
|
||||
|
||||
return templates.TemplateResponse("task_detail.html", {
|
||||
"request": request, "sidebar": sidebar, "item": item,
|
||||
"domain": domain, "project": project, "parent": parent,
|
||||
"subtasks": subtasks,
|
||||
"subtasks": subtasks, "tab": tab, "tab_data": tab_data,
|
||||
"all_contacts": all_contacts, "counts": counts,
|
||||
"running_task_id": running_task_id,
|
||||
"page_title": item["title"], "active_nav": "tasks",
|
||||
})
|
||||
@@ -368,3 +454,30 @@ async def quick_add(
|
||||
await repo.create(data)
|
||||
referer = request.headers.get("referer", "/tasks")
|
||||
return RedirectResponse(url=referer, status_code=303)
|
||||
|
||||
|
||||
# ---- Contact linking ----
|
||||
|
||||
@router.post("/{task_id}/contacts/add")
|
||||
async def add_contact(
|
||||
task_id: str,
|
||||
contact_id: str = Form(...),
|
||||
role: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(text("""
|
||||
INSERT INTO contact_tasks (contact_id, task_id, role)
|
||||
VALUES (:cid, :tid, :role) ON CONFLICT DO NOTHING
|
||||
"""), {"cid": contact_id, "tid": task_id, "role": role if role and role.strip() else None})
|
||||
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{task_id}/contacts/{contact_id}/remove")
|
||||
async def remove_contact(
|
||||
task_id: str, contact_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await db.execute(text(
|
||||
"DELETE FROM contact_tasks WHERE contact_id = :cid AND task_id = :tid"
|
||||
), {"cid": contact_id, "tid": task_id})
|
||||
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
|
||||
|
||||
@@ -78,6 +78,8 @@ async def list_weblinks(
|
||||
async def create_form(
|
||||
request: Request,
|
||||
folder_id: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
meeting_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sidebar = await get_sidebar_data(db)
|
||||
@@ -92,6 +94,8 @@ async def create_form(
|
||||
"page_title": "New Weblink", "active_nav": "weblinks",
|
||||
"item": None,
|
||||
"prefill_folder_id": folder_id or "",
|
||||
"prefill_task_id": task_id or "",
|
||||
"prefill_meeting_id": meeting_id or "",
|
||||
})
|
||||
|
||||
|
||||
@@ -102,11 +106,17 @@ async def create_weblink(
|
||||
url: str = Form(...),
|
||||
description: Optional[str] = Form(None),
|
||||
folder_id: Optional[str] = Form(None),
|
||||
task_id: Optional[str] = Form(None),
|
||||
meeting_id: Optional[str] = Form(None),
|
||||
tags: Optional[str] = Form(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = BaseRepository("weblinks", db)
|
||||
data = {"label": label, "url": url, "description": description}
|
||||
if task_id and task_id.strip():
|
||||
data["task_id"] = task_id
|
||||
if meeting_id and meeting_id.strip():
|
||||
data["meeting_id"] = meeting_id
|
||||
if tags and tags.strip():
|
||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
@@ -119,6 +129,10 @@ async def create_weblink(
|
||||
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
|
||||
"""), {"fid": folder_id, "wid": weblink["id"]})
|
||||
|
||||
if task_id and task_id.strip():
|
||||
return RedirectResponse(url=f"/tasks/{task_id}?tab=weblinks", status_code=303)
|
||||
if meeting_id and meeting_id.strip():
|
||||
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=weblinks", status_code=303)
|
||||
redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks"
|
||||
return RedirectResponse(url=redirect_url, status_code=303)
|
||||
|
||||
|
||||
@@ -1451,9 +1451,8 @@ a:hover { color: var(--accent-hover); }
|
||||
.eisenhower-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr auto;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 12px;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.eisenhower-y-label {
|
||||
@@ -1488,9 +1487,9 @@ a:hover { color: var(--accent-hover); }
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.eisenhower-q1 { border-top: 3px solid var(--red); }
|
||||
|
||||
@@ -131,6 +131,10 @@
|
||||
</div>
|
||||
|
||||
<div class="nav-section" style="margin-top: auto; padding-bottom: 12px;">
|
||||
<a href="/history" class="nav-item {{ 'active' if active_nav == 'history' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
History
|
||||
</a>
|
||||
<a href="/admin/trash" class="nav-item {{ 'active' if active_nav == 'trash' }}">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
Trash
|
||||
|
||||
@@ -77,9 +77,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Record Decision' }}</button>
|
||||
<a href="{{ '/decisions/' ~ item.id if item else '/decisions' }}" class="btn btn-secondary">Cancel</a>
|
||||
<a href="{{ '/tasks/' ~ prefill_task_id ~ '?tab=decisions' if prefill_task_id is defined and prefill_task_id else ('/decisions/' ~ item.id if item else '/decisions') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,34 @@
|
||||
<span class="text-muted">{{ total }} open tasks classified by priority & urgency</span>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form class="filters-bar" method="get" action="/eisenhower" id="eis-filters">
|
||||
<select name="domain_id" class="filter-select" id="eis-domain" onchange="this.form.submit()">
|
||||
<option value="">All Domains</option>
|
||||
{% for d in domains %}
|
||||
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="project_id" class="filter-select" id="eis-project" onchange="this.form.submit()">
|
||||
<option value="">All Projects</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="status" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="open" {{ 'selected' if current_status == 'open' }}>Open</option>
|
||||
<option value="in_progress" {{ 'selected' if current_status == 'in_progress' }}>In Progress</option>
|
||||
<option value="blocked" {{ 'selected' if current_status == 'blocked' }}>Blocked</option>
|
||||
</select>
|
||||
<select name="context" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Contexts</option>
|
||||
{% for ct in context_types %}
|
||||
<option value="{{ ct.value }}" {{ 'selected' if current_context == ct.value }}>{{ ct.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div class="eisenhower-grid">
|
||||
<!-- Axis labels -->
|
||||
<div class="eisenhower-y-label">
|
||||
@@ -126,4 +154,31 @@
|
||||
<div class="eisenhower-x-label">Not Urgent</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var domainSel = document.getElementById('eis-domain');
|
||||
var projectSel = document.getElementById('eis-project');
|
||||
var currentProjectId = '{{ current_project_id }}';
|
||||
var form = document.getElementById('eis-filters');
|
||||
|
||||
domainSel.addEventListener('change', function() {
|
||||
var did = domainSel.value;
|
||||
if (!did) { form.submit(); return; }
|
||||
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(projects) {
|
||||
projectSel.innerHTML = '<option value="">All Projects</option>';
|
||||
projects.forEach(function(p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name;
|
||||
if (p.id === currentProjectId) opt.selected = true;
|
||||
projectSel.appendChild(opt);
|
||||
});
|
||||
form.submit();
|
||||
})
|
||||
.catch(function() { form.submit(); });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -37,9 +37,31 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Add task to focus -->
|
||||
{% if available_tasks %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header"><h2 class="card-title">Add to Focus</h2></div>
|
||||
|
||||
<form class="filters-bar" method="get" action="/focus" id="focus-filters" style="padding: 8px 12px; border-bottom: 1px solid var(--border);">
|
||||
<input type="hidden" name="focus_date" value="{{ focus_date }}">
|
||||
<select name="domain_id" class="filter-select" id="focus-domain" onchange="this.form.submit()">
|
||||
<option value="">All Domains</option>
|
||||
{% for d in domains %}
|
||||
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="area_id" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Areas</option>
|
||||
{% for a in areas %}
|
||||
<option value="{{ a.id }}" {{ 'selected' if current_area_id == a.id|string }}>{{ a.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="project_id" class="filter-select" id="focus-project" onchange="this.form.submit()">
|
||||
<option value="">All Projects</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
|
||||
{% for t in available_tasks[:15] %}
|
||||
<div class="list-row">
|
||||
<span class="priority-dot priority-{{ t.priority }}"></span>
|
||||
@@ -52,7 +74,36 @@
|
||||
<button class="btn btn-ghost btn-xs" style="color:var(--green)">+ Focus</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No available tasks matching filters</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var domainSel = document.getElementById('focus-domain');
|
||||
var projectSel = document.getElementById('focus-project');
|
||||
var currentProjectId = '{{ current_project_id }}';
|
||||
var form = document.getElementById('focus-filters');
|
||||
|
||||
domainSel.addEventListener('change', function() {
|
||||
var did = domainSel.value;
|
||||
if (!did) { form.submit(); return; }
|
||||
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(projects) {
|
||||
projectSel.innerHTML = '<option value="">All Projects</option>';
|
||||
projects.forEach(function(p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name;
|
||||
if (p.id === currentProjectId) opt.selected = true;
|
||||
projectSel.appendChild(opt);
|
||||
});
|
||||
form.submit();
|
||||
})
|
||||
.catch(function() { form.submit(); });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
32
templates/history.html
Normal file
32
templates/history.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Change History<span class="page-count">{{ items|length }}</span></h1>
|
||||
</div>
|
||||
|
||||
<form class="filters-bar" method="get" action="/history">
|
||||
<select name="entity_type" class="filter-select" onchange="this.form.submit()">
|
||||
<option value="">All Types</option>
|
||||
{% for t in type_options %}
|
||||
<option value="{{ t.value }}" {{ 'selected' if current_type == t.value }}>{{ t.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
|
||||
{% if items %}
|
||||
<div class="card mt-3">
|
||||
{% for item in items %}
|
||||
<div class="list-row">
|
||||
<span class="row-tag" style="min-width: 70px; text-align: center;">{{ item.type_label }}</span>
|
||||
<span class="row-title"><a href="{{ item.url }}">{{ item.label }}</a></span>
|
||||
<span class="status-badge {{ 'status-active' if item.action == 'created' else 'status-open' }}">{{ item.action }}</span>
|
||||
<span class="row-meta">{{ item.updated_at.strftime('%Y-%m-%d %H:%M') if item.updated_at else '' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state mt-3">
|
||||
<div class="empty-state-text">No recent changes</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -95,4 +95,38 @@
|
||||
<div class="empty-state-text">No items yet. Add one above.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Contacts -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Contacts<span class="page-count">{{ contacts|length }}</span></h3>
|
||||
</div>
|
||||
<form action="/lists/{{ item.id }}/contacts/add" method="post" class="flex gap-2 items-end" style="padding: 12px; border-bottom: 1px solid var(--border);">
|
||||
<div class="form-group" style="flex:1; margin:0;">
|
||||
<select name="contact_id" class="form-select" required>
|
||||
<option value="">Select contact...</option>
|
||||
{% for c in all_contacts %}
|
||||
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1; margin:0;">
|
||||
<input type="text" name="role" class="form-input" placeholder="Role (optional)">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||
</form>
|
||||
{% for c in contacts %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a></span>
|
||||
{% if c.role %}<span class="row-tag">{{ c.role }}</span>{% endif %}
|
||||
<div class="row-actions">
|
||||
<form action="/lists/{{ item.id }}/contacts/{{ c.id }}/remove" method="post" style="display:inline">
|
||||
<button class="btn btn-ghost btn-xs" title="Remove">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: 12px; color: var(--muted); font-size: 0.85rem;">No contacts linked</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -73,9 +73,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
|
||||
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create List' }}</button>
|
||||
<a href="{{ '/lists/' ~ item.id if item else '/lists' }}" class="btn btn-secondary">Cancel</a>
|
||||
<a href="{{ '/tasks/' ~ prefill_task_id ~ '?tab=lists' if prefill_task_id is defined and prefill_task_id else ('/lists/' ~ item.id if item else '/lists') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form class="filters-bar" method="get" action="/lists">
|
||||
<select name="domain_id" class="filter-select" onchange="this.form.submit()">
|
||||
<form class="filters-bar" method="get" action="/lists" id="list-filters">
|
||||
<select name="domain_id" class="filter-select" id="domain-filter" onchange="this.form.submit()">
|
||||
<option value="">All Domains</option>
|
||||
{% for d in domains %}
|
||||
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="project_id" class="filter-select" onchange="this.form.submit()">
|
||||
<select name="project_id" class="filter-select" id="project-filter" onchange="this.form.submit()">
|
||||
<option value="">All Projects</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
||||
@@ -57,4 +57,38 @@
|
||||
<a href="/lists/create" class="btn btn-primary">Create First List</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var domainSel = document.getElementById('domain-filter');
|
||||
var projectSel = document.getElementById('project-filter');
|
||||
var currentProjectId = '{{ current_project_id }}';
|
||||
|
||||
domainSel.addEventListener('change', function() {
|
||||
var did = domainSel.value;
|
||||
if (!did) {
|
||||
// No domain filter - submit immediately, server returns all projects
|
||||
document.getElementById('list-filters').submit();
|
||||
return;
|
||||
}
|
||||
// Fetch projects for this domain
|
||||
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(projects) {
|
||||
projectSel.innerHTML = '<option value="">All Projects</option>';
|
||||
projects.forEach(function(p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name;
|
||||
if (p.id === currentProjectId) opt.selected = true;
|
||||
projectSel.appendChild(opt);
|
||||
});
|
||||
document.getElementById('list-filters').submit();
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('list-filters').submit();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -28,9 +28,21 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tab-strip">
|
||||
<a href="/meetings/{{ item.id }}?tab=overview" class="tab-item {{ 'active' if tab == 'overview' }}">Overview</a>
|
||||
<a href="/meetings/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes{% if counts.notes %} ({{ counts.notes }}){% endif %}</a>
|
||||
<a href="/meetings/{{ item.id }}?tab=weblinks" class="tab-item {{ 'active' if tab == 'weblinks' }}">Weblinks{% if counts.weblinks %} ({{ counts.weblinks }}){% endif %}</a>
|
||||
<a href="/meetings/{{ item.id }}?tab=files" class="tab-item {{ 'active' if tab == 'files' }}">Files{% if counts.files %} ({{ counts.files }}){% endif %}</a>
|
||||
<a href="/meetings/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a>
|
||||
<a href="/meetings/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a>
|
||||
<a href="/meetings/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a>
|
||||
</div>
|
||||
|
||||
{% if tab == 'overview' %}
|
||||
<!-- Agenda -->
|
||||
{% if item.agenda %}
|
||||
<div class="card mt-3">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h3 class="card-title">Agenda</h3></div>
|
||||
<div class="detail-body" style="padding: 12px 16px;">{{ item.agenda }}</div>
|
||||
</div>
|
||||
@@ -38,7 +50,7 @@
|
||||
|
||||
<!-- Meeting Notes -->
|
||||
{% if item.notes_body %}
|
||||
<div class="card mt-3">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h3 class="card-title">Notes</h3></div>
|
||||
<div class="detail-body" style="padding: 12px 16px;">{{ item.notes_body }}</div>
|
||||
</div>
|
||||
@@ -46,19 +58,17 @@
|
||||
|
||||
<!-- Transcript -->
|
||||
{% if item.transcript %}
|
||||
<div class="card mt-3">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><h3 class="card-title">Transcript</h3></div>
|
||||
<div class="detail-body" style="padding: 12px 16px; font-family: var(--font-mono); font-size: 0.82rem; white-space: pre-wrap;">{{ item.transcript }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Items -->
|
||||
<div class="card mt-3">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Action Items<span class="page-count">{{ action_items|length }}</span></h3>
|
||||
</div>
|
||||
|
||||
<!-- Quick add action item -->
|
||||
<form class="quick-add" action="/meetings/{{ item.id }}/action-item" method="post" style="border-bottom: 1px solid var(--border);">
|
||||
<input type="text" name="title" placeholder="Add action item..." required>
|
||||
<select name="domain_id" class="filter-select" required style="width: auto;">
|
||||
@@ -68,7 +78,6 @@
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||
</form>
|
||||
|
||||
{% for task in action_items %}
|
||||
<div class="list-row {{ 'completed' if task.status == 'done' }}">
|
||||
<div class="row-check">
|
||||
@@ -83,16 +92,14 @@
|
||||
{% if task.project_name %}<span class="row-tag">{{ task.project_name }}</span>{% endif %}
|
||||
<span class="status-badge status-{{ task.status }}">{{ task.status|replace('_', ' ') }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not action_items %}
|
||||
{% else %}
|
||||
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No action items yet</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Decisions -->
|
||||
{% if decisions %}
|
||||
<div class="card mt-3">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">Decisions<span class="page-count">{{ decisions|length }}</span></h3></div>
|
||||
{% for dec in decisions %}
|
||||
<div class="list-row">
|
||||
@@ -104,16 +111,89 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Attendees -->
|
||||
{% if attendees %}
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Attendees<span class="page-count">{{ attendees|length }}</span></h3></div>
|
||||
{% for att in attendees %}
|
||||
{% elif tab == 'notes' %}
|
||||
<a href="/notes/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Note</a>
|
||||
{% for n in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/contacts/{{ att.id }}">{{ att.first_name }} {{ att.last_name or '' }}</a></span>
|
||||
{% if att.role %}<span class="row-tag">{{ att.role }}</span>{% endif %}
|
||||
<span class="row-title"><a href="/notes/{{ n.id }}">{{ n.title }}</a></span>
|
||||
<span class="row-meta">{{ n.updated_at.strftime('%Y-%m-%d') if n.updated_at else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No notes linked to this meeting</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'weblinks' %}
|
||||
<a href="/weblinks/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Weblink</a>
|
||||
{% for w in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="{{ w.url }}" target="_blank">{{ w.label }}</a></span>
|
||||
<span class="row-meta">{{ w.url[:50] }}{% if w.url|length > 50 %}...{% endif %}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No weblinks linked to this meeting</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'files' %}
|
||||
<a href="/files/upload?context_type=meeting&context_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ Upload File</a>
|
||||
{% for f in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/files/{{ f.id }}">{{ f.original_filename }}</a></span>
|
||||
<span class="row-meta">{{ f.created_at.strftime('%Y-%m-%d') if f.created_at else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No files attached to this meeting</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'lists' %}
|
||||
<a href="/lists/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New List</a>
|
||||
{% for l in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/lists/{{ l.id }}">{{ l.name }}</a></span>
|
||||
<span class="row-meta">{{ l.item_count }} items</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No lists linked to this meeting</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'processes' %}
|
||||
<div class="empty-state"><div class="empty-state-text">Process management coming soon</div></div>
|
||||
|
||||
{% elif tab == 'contacts' %}
|
||||
<div class="card mb-4">
|
||||
<form action="/meetings/{{ item.id }}/contacts/add" method="post" class="flex gap-2 items-end" style="padding: 12px;">
|
||||
<div class="form-group" style="flex:1; margin:0;">
|
||||
<label class="form-label">Contact</label>
|
||||
<select name="contact_id" class="form-select" required>
|
||||
<option value="">Select contact...</option>
|
||||
{% for c in all_contacts %}
|
||||
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1; margin:0;">
|
||||
<label class="form-label">Role</label>
|
||||
<select name="role" class="form-select">
|
||||
<option value="attendee">Attendee</option>
|
||||
<option value="organizer">Organizer</option>
|
||||
<option value="optional">Optional</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% for c in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a></span>
|
||||
{% if c.role %}<span class="row-tag">{{ c.role }}</span>{% endif %}
|
||||
<span class="row-meta">{{ c.linked_at.strftime('%Y-%m-%d') if c.linked_at else '' }}</span>
|
||||
<div class="row-actions">
|
||||
<form action="/meetings/{{ item.id }}/contacts/{{ c.id }}/remove" method="post" style="display:inline">
|
||||
<button class="btn btn-ghost btn-xs" title="Remove">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No contacts linked to this meeting</div></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<div class="form-group full-width"><label class="form-label">Content</label><textarea name="body" class="form-textarea" rows="15" style="font-family:var(--font-mono);font-size:0.88rem">{{ item.body if item and item.body else '' }}</textarea></div>
|
||||
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
|
||||
<input type="hidden" name="content_format" value="rich">
|
||||
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
|
||||
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
|
||||
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="{{ '/notes/' ~ item.id if item else '/notes' }}" class="btn btn-secondary">Cancel</a></div>
|
||||
</div>
|
||||
</form></div>
|
||||
|
||||
@@ -34,8 +34,13 @@
|
||||
<!-- Tabs -->
|
||||
<div class="tab-strip">
|
||||
<a href="/projects/{{ item.id }}?tab=tasks" class="tab-item {{ 'active' if tab == 'tasks' }}">Tasks ({{ tasks|length }})</a>
|
||||
<a href="/projects/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes ({{ notes|length }})</a>
|
||||
<a href="/projects/{{ item.id }}?tab=links" class="tab-item {{ 'active' if tab == 'links' }}">Links ({{ links|length }})</a>
|
||||
<a href="/projects/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes{% if counts.notes %} ({{ counts.notes }}){% endif %}</a>
|
||||
<a href="/projects/{{ item.id }}?tab=links" class="tab-item {{ 'active' if tab == 'links' }}">Links{% if counts.links %} ({{ counts.links }}){% endif %}</a>
|
||||
<a href="/projects/{{ item.id }}?tab=files" class="tab-item {{ 'active' if tab == 'files' }}">Files{% if counts.files %} ({{ counts.files }}){% endif %}</a>
|
||||
<a href="/projects/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a>
|
||||
<a href="/projects/{{ item.id }}?tab=decisions" class="tab-item {{ 'active' if tab == 'decisions' }}">Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %}</a>
|
||||
<a href="/projects/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a>
|
||||
<a href="/projects/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a>
|
||||
</div>
|
||||
|
||||
{% if tab == 'tasks' %}
|
||||
@@ -91,5 +96,75 @@
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No links yet</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'files' %}
|
||||
<a href="/files/upload?context_type=project&context_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ Upload File</a>
|
||||
{% for f in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/files/{{ f.id }}">{{ f.original_filename }}</a></span>
|
||||
<span class="row-meta">{{ f.created_at.strftime('%Y-%m-%d') if f.created_at else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No files attached to this project</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'lists' %}
|
||||
<a href="/lists/create?domain_id={{ item.domain_id }}&project_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New List</a>
|
||||
{% for l in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/lists/{{ l.id }}">{{ l.name }}</a></span>
|
||||
<span class="row-meta">{{ l.item_count }} items</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No lists linked to this project</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'decisions' %}
|
||||
<a href="/decisions/create?project_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Decision</a>
|
||||
{% for d in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/decisions/{{ d.id }}">{{ d.title }}</a></span>
|
||||
<span class="status-badge status-{{ d.status }}">{{ d.status }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No decisions linked to this project</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'processes' %}
|
||||
<div class="empty-state"><div class="empty-state-text">Process management coming soon</div></div>
|
||||
|
||||
{% elif tab == 'contacts' %}
|
||||
<div class="card mb-4">
|
||||
<form action="/projects/{{ item.id }}/contacts/add" method="post" class="flex gap-2 items-end" style="padding: 12px;">
|
||||
<div class="form-group" style="flex:1; margin:0;">
|
||||
<label class="form-label">Contact</label>
|
||||
<select name="contact_id" class="form-select" required>
|
||||
<option value="">Select contact...</option>
|
||||
{% for c in all_contacts %}
|
||||
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1; margin:0;">
|
||||
<label class="form-label">Role</label>
|
||||
<input type="text" name="role" class="form-input" placeholder="e.g. lead, contributor...">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% for c in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a></span>
|
||||
{% if c.role %}<span class="row-tag">{{ c.role }}</span>{% endif %}
|
||||
<span class="row-meta">{{ c.linked_at.strftime('%Y-%m-%d') if c.linked_at else '' }}</span>
|
||||
<div class="row-actions">
|
||||
<form action="/projects/{{ item.id }}/contacts/{{ c.id }}/remove" method="post" style="display:inline">
|
||||
<button class="btn btn-ghost btn-xs" title="Remove">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No contacts linked to this project</div></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -54,6 +54,19 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tab-strip">
|
||||
<a href="/tasks/{{ item.id }}?tab=overview" class="tab-item {{ 'active' if tab == 'overview' }}">Overview{% if counts.overview %} ({{ counts.overview }}){% endif %}</a>
|
||||
<a href="/tasks/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes{% if counts.notes %} ({{ counts.notes }}){% endif %}</a>
|
||||
<a href="/tasks/{{ item.id }}?tab=weblinks" class="tab-item {{ 'active' if tab == 'weblinks' }}">Weblinks{% if counts.weblinks %} ({{ counts.weblinks }}){% endif %}</a>
|
||||
<a href="/tasks/{{ item.id }}?tab=files" class="tab-item {{ 'active' if tab == 'files' }}">Files{% if counts.files %} ({{ counts.files }}){% endif %}</a>
|
||||
<a href="/tasks/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a>
|
||||
<a href="/tasks/{{ item.id }}?tab=decisions" class="tab-item {{ 'active' if tab == 'decisions' }}">Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %}</a>
|
||||
<a href="/tasks/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a>
|
||||
<a href="/tasks/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a>
|
||||
</div>
|
||||
|
||||
{% if tab == 'overview' %}
|
||||
{% if parent %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-title text-sm">Parent Task</div>
|
||||
@@ -61,7 +74,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Subtasks -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Subtasks<span class="page-count">{{ subtasks|length }}</span></h2>
|
||||
@@ -84,6 +96,99 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% elif tab == 'notes' %}
|
||||
<a href="/notes/create?task_id={{ item.id }}&domain_id={{ item.domain_id }}" class="btn btn-ghost btn-sm mb-3">+ New Note</a>
|
||||
{% for n in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/notes/{{ n.id }}">{{ n.title }}</a></span>
|
||||
<span class="row-meta">{{ n.updated_at.strftime('%Y-%m-%d') if n.updated_at else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No notes linked to this task</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'weblinks' %}
|
||||
<a href="/weblinks/create?task_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Weblink</a>
|
||||
{% for w in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="{{ w.url }}" target="_blank">{{ w.label }}</a></span>
|
||||
<span class="row-meta">{{ w.url[:50] }}{% if w.url|length > 50 %}...{% endif %}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No weblinks linked to this task</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'files' %}
|
||||
<a href="/files/upload?context_type=task&context_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ Upload File</a>
|
||||
{% for f in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/files/{{ f.id }}">{{ f.original_filename }}</a></span>
|
||||
<span class="row-meta">{{ f.created_at.strftime('%Y-%m-%d') if f.created_at else '' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No files attached to this task</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'lists' %}
|
||||
<a href="/lists/create?task_id={{ item.id }}&domain_id={{ item.domain_id }}" class="btn btn-ghost btn-sm mb-3">+ New List</a>
|
||||
{% for l in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/lists/{{ l.id }}">{{ l.name }}</a></span>
|
||||
<span class="row-meta">{{ l.item_count }} items</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No lists linked to this task</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'decisions' %}
|
||||
<a href="/decisions/create?task_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Decision</a>
|
||||
{% for d in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/decisions/{{ d.id }}">{{ d.title }}</a></span>
|
||||
<span class="status-badge status-{{ d.status }}">{{ d.status }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No decisions linked to this task</div></div>
|
||||
{% endfor %}
|
||||
|
||||
{% elif tab == 'processes' %}
|
||||
<div class="empty-state"><div class="empty-state-text">Process management coming soon</div></div>
|
||||
|
||||
{% elif tab == 'contacts' %}
|
||||
<div class="card mb-4">
|
||||
<form action="/tasks/{{ item.id }}/contacts/add" method="post" class="flex gap-2 items-end" style="padding: 12px;">
|
||||
<div class="form-group" style="flex:1; margin:0;">
|
||||
<label class="form-label">Contact</label>
|
||||
<select name="contact_id" class="form-select" required>
|
||||
<option value="">Select contact...</option>
|
||||
{% for c in all_contacts %}
|
||||
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1; margin:0;">
|
||||
<label class="form-label">Role</label>
|
||||
<input type="text" name="role" class="form-input" placeholder="e.g. reviewer, assignee...">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% for c in tab_data %}
|
||||
<div class="list-row">
|
||||
<span class="row-title"><a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a></span>
|
||||
{% if c.role %}<span class="row-tag">{{ c.role }}</span>{% endif %}
|
||||
<span class="row-meta">{{ c.linked_at.strftime('%Y-%m-%d') if c.linked_at else '' }}</span>
|
||||
<div class="row-actions">
|
||||
<form action="/tasks/{{ item.id }}/contacts/{{ c.id }}/remove" method="post" style="display:inline">
|
||||
<button class="btn btn-ghost btn-xs" title="Remove">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state"><div class="empty-state-text">No contacts linked to this task</div></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="text-xs text-muted mt-4">
|
||||
Created {{ item.created_at.strftime('%Y-%m-%d %H:%M') if item.created_at else '' }}
|
||||
{% if item.completed_at %} | Completed {{ item.completed_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
|
||||
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Add Weblink' }}</button>
|
||||
<a href="/weblinks" class="btn btn-secondary">Cancel</a>
|
||||
<a href="{{ '/tasks/' ~ prefill_task_id ~ '?tab=weblinks' if prefill_task_id is defined and prefill_task_id else '/weblinks' }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -201,6 +201,24 @@ def all_seeds(sync_conn):
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""", (d["focus"], d["task"]))
|
||||
|
||||
# Junction table seeds for contact tabs
|
||||
cur.execute("""
|
||||
INSERT INTO contact_tasks (contact_id, task_id, role)
|
||||
VALUES (%s, %s, 'assignee') ON CONFLICT DO NOTHING
|
||||
""", (d["contact"], d["task"]))
|
||||
cur.execute("""
|
||||
INSERT INTO contact_projects (contact_id, project_id, role)
|
||||
VALUES (%s, %s, 'stakeholder') ON CONFLICT DO NOTHING
|
||||
""", (d["contact"], d["project"]))
|
||||
cur.execute("""
|
||||
INSERT INTO contact_meetings (contact_id, meeting_id, role)
|
||||
VALUES (%s, %s, 'attendee') ON CONFLICT DO NOTHING
|
||||
""", (d["contact"], d["meeting"]))
|
||||
cur.execute("""
|
||||
INSERT INTO contact_lists (contact_id, list_id, role)
|
||||
VALUES (%s, %s, 'contributor') ON CONFLICT DO NOTHING
|
||||
""", (d["contact"], d["list"]))
|
||||
|
||||
# Process
|
||||
cur.execute("""
|
||||
INSERT INTO processes (id, name, process_type, status, category, is_deleted, created_at, updated_at)
|
||||
@@ -253,6 +271,11 @@ def all_seeds(sync_conn):
|
||||
|
||||
# Cleanup: delete all seed data (reverse dependency order)
|
||||
try:
|
||||
# Junction tables first
|
||||
cur.execute("DELETE FROM contact_tasks WHERE task_id = %s", (d["task"],))
|
||||
cur.execute("DELETE FROM contact_projects WHERE project_id = %s", (d["project"],))
|
||||
cur.execute("DELETE FROM contact_meetings WHERE meeting_id = %s", (d["meeting"],))
|
||||
cur.execute("DELETE FROM contact_lists WHERE list_id = %s", (d["list"],))
|
||||
cur.execute("DELETE FROM files WHERE id = %s", (d["file"],))
|
||||
if os.path.exists(dummy_file_path):
|
||||
os.remove(dummy_file_path)
|
||||
|
||||
@@ -46,6 +46,9 @@ PREFIX_TO_SEED = {
|
||||
"/time-budgets": "time_budget",
|
||||
"/files": "file",
|
||||
"/admin/trash": None,
|
||||
"/history": None,
|
||||
"/eisenhower": None,
|
||||
"/calendar": None,
|
||||
}
|
||||
|
||||
def resolve_path(path_template, seeds):
|
||||
|
||||
@@ -1893,3 +1893,743 @@ async def _create_list_item(db: AsyncSession, list_id: str, content: str) -> str
|
||||
)
|
||||
await db.commit()
|
||||
return _id
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Task Detail Tabs
|
||||
# ===========================================================================
|
||||
class TestTaskDetailTabs:
|
||||
"""Test tabbed task detail page - all tabs load, correct content per tab."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overview_tab_default(self, client: AsyncClient, seed_task: dict):
|
||||
"""Default tab is overview."""
|
||||
r = await client.get(f"/tasks/{seed_task['id']}")
|
||||
assert r.status_code == 200
|
||||
# The active tab has class "tab-item active"
|
||||
assert 'tab-item active' in r.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_tabs_return_200(self, client: AsyncClient, seed_task: dict):
|
||||
"""Every tab on task detail returns 200."""
|
||||
for tab in ("overview", "notes", "weblinks", "files", "lists", "decisions", "processes", "contacts"):
|
||||
r = await client.get(f"/tasks/{seed_task['id']}?tab={tab}")
|
||||
assert r.status_code == 200, f"Tab '{tab}' returned {r.status_code}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contacts_tab_shows_seed_contact(
|
||||
self, client: AsyncClient, seed_task: dict, seed_contact: dict,
|
||||
):
|
||||
"""Contacts tab shows the linked contact from seed data."""
|
||||
r = await client.get(f"/tasks/{seed_task['id']}?tab=contacts")
|
||||
assert r.status_code == 200
|
||||
assert seed_contact["first_name"] in r.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notes_tab_shows_linked_note(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_task: dict, seed_domain: dict,
|
||||
):
|
||||
"""A note linked to a task appears on the notes tab."""
|
||||
tag = _uid()
|
||||
note_id = str(uuid.uuid4())
|
||||
await db_session.execute(
|
||||
text("INSERT INTO notes (id, title, domain_id, task_id, body, content_format, is_deleted, created_at, updated_at) "
|
||||
"VALUES (:id, :title, :did, :tid, 'body', 'markdown', false, now(), now())"),
|
||||
{"id": note_id, "title": f"TaskNote-{tag}", "did": seed_domain["id"], "tid": seed_task["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.get(f"/tasks/{seed_task['id']}?tab=notes")
|
||||
assert f"TaskNote-{tag}" in r.text
|
||||
|
||||
await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id})
|
||||
await db_session.commit()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_tab_defaults_to_overview(self, client: AsyncClient, seed_task: dict):
|
||||
"""Invalid tab param still loads the page (defaults to overview)."""
|
||||
r = await client.get(f"/tasks/{seed_task['id']}?tab=nonexistent")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Task Contact Management
|
||||
# ===========================================================================
|
||||
class TestTaskContactManagement:
|
||||
"""Test adding and removing contacts on task detail."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_contact_to_task(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_domain: dict, seed_project: dict, seed_contact: dict,
|
||||
):
|
||||
"""Adding a contact to a task creates a junction entry."""
|
||||
tid = await _create_task(db_session, seed_domain["id"], seed_project["id"], f"ContactTask-{_uid()}")
|
||||
|
||||
r = await client.post(f"/tasks/{tid}/contacts/add", data={
|
||||
"contact_id": seed_contact["id"],
|
||||
"role": "reviewer",
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT role FROM contact_tasks WHERE task_id = :tid AND contact_id = :cid"),
|
||||
{"tid": tid, "cid": seed_contact["id"]},
|
||||
)
|
||||
row = result.first()
|
||||
assert row is not None, "Contact-task junction not created"
|
||||
assert row.role == "reviewer"
|
||||
|
||||
# Cleanup
|
||||
await db_session.execute(text("DELETE FROM contact_tasks WHERE task_id = :tid"), {"tid": tid})
|
||||
await db_session.commit()
|
||||
await _delete_tasks(db_session, [tid])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_contact_from_task(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_domain: dict, seed_project: dict, seed_contact: dict,
|
||||
):
|
||||
"""Removing a contact from a task deletes the junction entry."""
|
||||
tid = await _create_task(db_session, seed_domain["id"], seed_project["id"], f"RmContact-{_uid()}")
|
||||
await db_session.execute(
|
||||
text("INSERT INTO contact_tasks (contact_id, task_id, role) VALUES (:cid, :tid, 'test') ON CONFLICT DO NOTHING"),
|
||||
{"cid": seed_contact["id"], "tid": tid},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.post(f"/tasks/{tid}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT count(*) FROM contact_tasks WHERE task_id = :tid AND contact_id = :cid"),
|
||||
{"tid": tid, "cid": seed_contact["id"]},
|
||||
)
|
||||
assert result.scalar() == 0
|
||||
|
||||
await _delete_tasks(db_session, [tid])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_duplicate_contact_no_error(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_task: dict, seed_contact: dict,
|
||||
):
|
||||
"""Adding same contact twice doesn't crash (ON CONFLICT DO NOTHING)."""
|
||||
r = await client.post(f"/tasks/{seed_task['id']}/contacts/add", data={
|
||||
"contact_id": seed_contact["id"],
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
r = await client.post(f"/tasks/{seed_task['id']}/contacts/add", data={
|
||||
"contact_id": seed_contact["id"],
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Project Detail Tabs
|
||||
# ===========================================================================
|
||||
class TestProjectDetailTabs:
|
||||
"""Test tabbed project detail page."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_project_tabs_return_200(self, client: AsyncClient, seed_project: dict):
|
||||
"""Every tab on project detail returns 200."""
|
||||
for tab in ("tasks", "notes", "links", "files", "lists", "decisions", "processes", "contacts"):
|
||||
r = await client.get(f"/projects/{seed_project['id']}?tab={tab}")
|
||||
assert r.status_code == 200, f"Project tab '{tab}' returned {r.status_code}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_contacts_tab_shows_seed_contact(
|
||||
self, client: AsyncClient, seed_project: dict, seed_contact: dict,
|
||||
):
|
||||
"""Contacts tab shows the linked contact."""
|
||||
r = await client.get(f"/projects/{seed_project['id']}?tab=contacts")
|
||||
assert seed_contact["first_name"] in r.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_tasks_tab_shows_seed_task(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_project: dict, seed_task: dict,
|
||||
):
|
||||
"""Tasks tab shows the seed task linked to this project."""
|
||||
# Ensure seed project and task are not soft-deleted (earlier tests may delete them)
|
||||
await db_session.execute(
|
||||
text("UPDATE projects SET is_deleted = false, deleted_at = NULL WHERE id = :id"),
|
||||
{"id": seed_project["id"]},
|
||||
)
|
||||
await db_session.execute(
|
||||
text("UPDATE tasks SET is_deleted = false, deleted_at = NULL WHERE id = :id"),
|
||||
{"id": seed_task["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.get(f"/projects/{seed_project['id']}?tab=tasks")
|
||||
assert seed_task["title"] in r.text
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Project Contact Management
|
||||
# ===========================================================================
|
||||
class TestProjectContactManagement:
|
||||
"""Test adding and removing contacts on project detail."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_contact_to_project(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_project: dict, seed_contact: dict,
|
||||
):
|
||||
"""Adding a contact to a project creates a junction entry."""
|
||||
# First remove existing seed junction to test fresh add
|
||||
await db_session.execute(
|
||||
text("DELETE FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"),
|
||||
{"pid": seed_project["id"], "cid": seed_contact["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.post(f"/projects/{seed_project['id']}/contacts/add", data={
|
||||
"contact_id": seed_contact["id"],
|
||||
"role": "lead",
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT role FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"),
|
||||
{"pid": seed_project["id"], "cid": seed_contact["id"]},
|
||||
)
|
||||
row = result.first()
|
||||
assert row is not None
|
||||
assert row.role == "lead"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_contact_from_project(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_project: dict, seed_contact: dict,
|
||||
):
|
||||
"""Removing a contact from a project deletes the junction entry."""
|
||||
# Ensure junction exists
|
||||
await db_session.execute(
|
||||
text("INSERT INTO contact_projects (contact_id, project_id, role) VALUES (:cid, :pid, 'test') ON CONFLICT DO NOTHING"),
|
||||
{"cid": seed_contact["id"], "pid": seed_project["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.post(f"/projects/{seed_project['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT count(*) FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"),
|
||||
{"pid": seed_project["id"], "cid": seed_contact["id"]},
|
||||
)
|
||||
assert result.scalar() == 0
|
||||
|
||||
# Re-insert for other tests
|
||||
await db_session.execute(
|
||||
text("INSERT INTO contact_projects (contact_id, project_id, role) VALUES (:cid, :pid, 'stakeholder') ON CONFLICT DO NOTHING"),
|
||||
{"cid": seed_contact["id"], "pid": seed_project["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Meeting Detail Tabs
|
||||
# ===========================================================================
|
||||
class TestMeetingDetailTabs:
|
||||
"""Test tabbed meeting detail page."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_meeting_tabs_return_200(self, client: AsyncClient, seed_meeting: dict):
|
||||
"""Every tab on meeting detail returns 200."""
|
||||
for tab in ("overview", "notes", "weblinks", "files", "lists", "processes", "contacts"):
|
||||
r = await client.get(f"/meetings/{seed_meeting['id']}?tab={tab}")
|
||||
assert r.status_code == 200, f"Meeting tab '{tab}' returned {r.status_code}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_meeting_contacts_tab_shows_seed_contact(
|
||||
self, client: AsyncClient, seed_meeting: dict, seed_contact: dict,
|
||||
):
|
||||
"""Contacts tab shows the linked contact."""
|
||||
r = await client.get(f"/meetings/{seed_meeting['id']}?tab=contacts")
|
||||
assert seed_contact["first_name"] in r.text
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Meeting Contact Management
|
||||
# ===========================================================================
|
||||
class TestMeetingContactManagement:
|
||||
"""Test adding and removing contacts on meeting detail."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_contact_to_meeting(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_meeting: dict, seed_contact: dict,
|
||||
):
|
||||
"""Adding a contact to a meeting creates a junction entry."""
|
||||
await db_session.execute(
|
||||
text("DELETE FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"),
|
||||
{"mid": seed_meeting["id"], "cid": seed_contact["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.post(f"/meetings/{seed_meeting['id']}/contacts/add", data={
|
||||
"contact_id": seed_contact["id"],
|
||||
"role": "organizer",
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT role FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"),
|
||||
{"mid": seed_meeting["id"], "cid": seed_contact["id"]},
|
||||
)
|
||||
row = result.first()
|
||||
assert row is not None
|
||||
assert row.role == "organizer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_contact_from_meeting(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_meeting: dict, seed_contact: dict,
|
||||
):
|
||||
"""Removing a contact from a meeting deletes the junction entry."""
|
||||
await db_session.execute(
|
||||
text("INSERT INTO contact_meetings (contact_id, meeting_id, role) VALUES (:cid, :mid, 'attendee') ON CONFLICT DO NOTHING"),
|
||||
{"cid": seed_contact["id"], "mid": seed_meeting["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.post(f"/meetings/{seed_meeting['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT count(*) FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"),
|
||||
{"mid": seed_meeting["id"], "cid": seed_contact["id"]},
|
||||
)
|
||||
assert result.scalar() == 0
|
||||
|
||||
# Re-insert for other tests
|
||||
await db_session.execute(
|
||||
text("INSERT INTO contact_meetings (contact_id, meeting_id, role) VALUES (:cid, :mid, 'attendee') ON CONFLICT DO NOTHING"),
|
||||
{"cid": seed_contact["id"], "mid": seed_meeting["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# List Contact Management
|
||||
# ===========================================================================
|
||||
class TestListContactManagement:
|
||||
"""Test adding and removing contacts on list detail."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_contact_to_list(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_list: dict, seed_contact: dict,
|
||||
):
|
||||
"""Adding a contact to a list creates a junction entry."""
|
||||
await db_session.execute(
|
||||
text("DELETE FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"),
|
||||
{"lid": seed_list["id"], "cid": seed_contact["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.post(f"/lists/{seed_list['id']}/contacts/add", data={
|
||||
"contact_id": seed_contact["id"],
|
||||
"role": "owner",
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT role FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"),
|
||||
{"lid": seed_list["id"], "cid": seed_contact["id"]},
|
||||
)
|
||||
row = result.first()
|
||||
assert row is not None
|
||||
assert row.role == "owner"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_contact_from_list(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_list: dict, seed_contact: dict,
|
||||
):
|
||||
"""Removing a contact from a list deletes the junction entry."""
|
||||
await db_session.execute(
|
||||
text("INSERT INTO contact_lists (contact_id, list_id, role) VALUES (:cid, :lid, 'test') ON CONFLICT DO NOTHING"),
|
||||
{"cid": seed_contact["id"], "lid": seed_list["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.post(f"/lists/{seed_list['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT count(*) FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"),
|
||||
{"lid": seed_list["id"], "cid": seed_contact["id"]},
|
||||
)
|
||||
assert result.scalar() == 0
|
||||
|
||||
# Re-insert for other tests
|
||||
await db_session.execute(
|
||||
text("INSERT INTO contact_lists (contact_id, list_id, role) VALUES (:cid, :lid, 'contributor') ON CONFLICT DO NOTHING"),
|
||||
{"cid": seed_contact["id"], "lid": seed_list["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# History Page
|
||||
# ===========================================================================
|
||||
class TestHistoryPage:
|
||||
"""Test the change history page."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_page_loads(self, client: AsyncClient):
|
||||
"""History page returns 200."""
|
||||
r = await client.get("/history/")
|
||||
assert r.status_code == 200
|
||||
assert "Change History" in r.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_shows_seed_entities(
|
||||
self, client: AsyncClient, db_session: AsyncSession, seed_task: dict,
|
||||
):
|
||||
"""History page shows recently modified seed entities."""
|
||||
# Ensure seed task is not soft-deleted (earlier tests may delete it)
|
||||
await db_session.execute(
|
||||
text("UPDATE tasks SET is_deleted = false, deleted_at = NULL WHERE id = :id"),
|
||||
{"id": seed_task["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.get("/history/")
|
||||
assert r.status_code == 200
|
||||
assert seed_task["title"] in r.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_entity_type_filter(self, client: AsyncClient):
|
||||
"""History page can filter by entity type."""
|
||||
for entity_type in ("tasks", "notes", "projects", "contacts"):
|
||||
r = await client.get(f"/history/?entity_type={entity_type}")
|
||||
assert r.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_shows_modified_vs_created(
|
||||
self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict,
|
||||
):
|
||||
"""Items with updated_at different from created_at show as 'modified'."""
|
||||
tag = _uid()
|
||||
note_id = str(uuid.uuid4())
|
||||
await db_session.execute(
|
||||
text("INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at) "
|
||||
"VALUES (:id, :title, :did, 'body', 'markdown', false, now() - interval '1 hour', now())"),
|
||||
{"id": note_id, "title": f"HistMod-{tag}", "did": seed_domain["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.get("/history/?entity_type=notes")
|
||||
assert f"HistMod-{tag}" in r.text
|
||||
assert "modified" in r.text
|
||||
|
||||
await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id})
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Search Prefix Matching
|
||||
# ===========================================================================
|
||||
class TestSearchPrefixMatching:
|
||||
"""Test the improved search with prefix tsquery and ILIKE fallback."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_api_short_query_rejected(self, client: AsyncClient):
|
||||
"""Query shorter than 2 chars returns empty results."""
|
||||
r = await client.get("/search/api?q=a")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["results"]) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_api_result_structure(self, client: AsyncClient):
|
||||
"""Search results have expected fields."""
|
||||
r = await client.get("/search/api?q=Test")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
if data["results"]:
|
||||
result = data["results"][0]
|
||||
assert "type" in result
|
||||
assert "id" in result
|
||||
assert "name" in result
|
||||
assert "url" in result
|
||||
assert "icon" in result
|
||||
assert "rank" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_page_returns_200(self, client: AsyncClient):
|
||||
"""Full search page works."""
|
||||
r = await client.get("/search/?q=Test")
|
||||
assert r.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_ilike_fallback(
|
||||
self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict,
|
||||
):
|
||||
"""ILIKE fallback finds items that tsvector might miss."""
|
||||
tag = _uid()
|
||||
# Create a note with a very specific title
|
||||
note_id = str(uuid.uuid4())
|
||||
await db_session.execute(
|
||||
text("INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at) "
|
||||
"VALUES (:id, :title, :did, 'body', 'markdown', false, now(), now())"),
|
||||
{"id": note_id, "title": f"XqzNote{tag}", "did": seed_domain["id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.get(f"/search/api?q=XqzNote{tag}&entity_type=notes")
|
||||
data = r.json()
|
||||
ids = [res["id"] for res in data["results"]]
|
||||
assert note_id in ids, "ILIKE fallback should find exact substring match"
|
||||
|
||||
await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id})
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Projects API (Dynamic Filtering)
|
||||
# ===========================================================================
|
||||
class TestProjectsAPI:
|
||||
"""Test the /projects/api/by-domain JSON endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_projects_by_domain_returns_json(
|
||||
self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict,
|
||||
):
|
||||
"""API returns JSON list of projects for a domain."""
|
||||
# Ensure seed project is not soft-deleted (earlier tests may delete it)
|
||||
await db_session.execute(
|
||||
text("UPDATE projects SET is_deleted = false, deleted_at = NULL WHERE id = :id AND is_deleted = true"),
|
||||
{"id": "a0000000-0000-0000-0000-000000000003"},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
r = await client.get(f"/projects/api/by-domain?domain_id={seed_domain['id']}")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert isinstance(data, list)
|
||||
assert any(p["name"] == "Test Project" for p in data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_projects_by_domain_empty(self, client: AsyncClient):
|
||||
"""API returns empty list for nonexistent domain."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
r = await client.get(f"/projects/api/by-domain?domain_id={fake_id}")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_projects_by_domain_no_param(self, client: AsyncClient):
|
||||
"""API without domain_id returns all active projects."""
|
||||
r = await client.get("/projects/api/by-domain")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Eisenhower Matrix Filters
|
||||
# ===========================================================================
|
||||
class TestEisenhowerFilters:
|
||||
"""Test Eisenhower matrix page with filter parameters."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eisenhower_page_loads(self, client: AsyncClient):
|
||||
"""Eisenhower page returns 200."""
|
||||
r = await client.get("/eisenhower/")
|
||||
assert r.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eisenhower_filter_by_domain(
|
||||
self, client: AsyncClient, seed_domain: dict,
|
||||
):
|
||||
"""Eisenhower page accepts domain_id filter."""
|
||||
r = await client.get(f"/eisenhower/?domain_id={seed_domain['id']}")
|
||||
assert r.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eisenhower_filter_by_status(self, client: AsyncClient):
|
||||
"""Eisenhower page accepts status filter."""
|
||||
r = await client.get("/eisenhower/?status=open")
|
||||
assert r.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eisenhower_combined_filters(
|
||||
self, client: AsyncClient, seed_domain: dict, seed_project: dict,
|
||||
):
|
||||
"""Eisenhower page accepts multiple filters."""
|
||||
r = await client.get(
|
||||
f"/eisenhower/?domain_id={seed_domain['id']}&project_id={seed_project['id']}&status=open"
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Focus Filters
|
||||
# ===========================================================================
|
||||
class TestFocusFilters:
|
||||
"""Test focus page filter parameters."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_focus_filter_by_domain(
|
||||
self, client: AsyncClient, seed_domain: dict,
|
||||
):
|
||||
"""Focus page accepts domain_id filter for available tasks."""
|
||||
r = await client.get(f"/focus/?domain_id={seed_domain['id']}")
|
||||
assert r.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_focus_filter_by_project(
|
||||
self, client: AsyncClient, seed_project: dict,
|
||||
):
|
||||
"""Focus page accepts project_id filter."""
|
||||
r = await client.get(f"/focus/?project_id={seed_project['id']}")
|
||||
assert r.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_focus_combined_filters(
|
||||
self, client: AsyncClient, seed_domain: dict, seed_project: dict,
|
||||
):
|
||||
"""Focus page accepts multiple filters."""
|
||||
r = await client.get(
|
||||
f"/focus/?domain_id={seed_domain['id']}&project_id={seed_project['id']}"
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Entity Create with task_id/meeting_id
|
||||
# ===========================================================================
|
||||
class TestEntityCreateWithParentContext:
|
||||
"""Test creating entities with task_id or meeting_id context."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note_with_task_id(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_task: dict, seed_domain: dict,
|
||||
):
|
||||
"""Creating a note with task_id sets the FK and redirects to task tab."""
|
||||
tag = _uid()
|
||||
r = await client.post("/notes/create", data={
|
||||
"title": f"TaskNote-{tag}",
|
||||
"domain_id": seed_domain["id"],
|
||||
"body": "Test body",
|
||||
"content_format": "markdown",
|
||||
"task_id": seed_task["id"],
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
# Should redirect back to task's notes tab
|
||||
location = r.headers.get("location", "")
|
||||
assert "tab=notes" in location
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT task_id FROM notes WHERE title = :t AND is_deleted = false"),
|
||||
{"t": f"TaskNote-{tag}"},
|
||||
)
|
||||
row = result.first()
|
||||
assert row is not None
|
||||
assert str(row.task_id) == seed_task["id"]
|
||||
|
||||
# Cleanup
|
||||
await db_session.execute(
|
||||
text("DELETE FROM notes WHERE title = :t"), {"t": f"TaskNote-{tag}"}
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note_form_with_task_prefill(
|
||||
self, client: AsyncClient, seed_task: dict,
|
||||
):
|
||||
"""Note create form with task_id query param loads correctly."""
|
||||
r = await client.get(f"/notes/create?task_id={seed_task['id']}")
|
||||
assert r.status_code == 200
|
||||
assert seed_task["id"] in r.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_weblink_with_task_id(
|
||||
self, client: AsyncClient, db_session: AsyncSession, seed_task: dict,
|
||||
):
|
||||
"""Creating a weblink with task_id sets the FK."""
|
||||
tag = _uid()
|
||||
r = await client.post("/weblinks/create", data={
|
||||
"label": f"TaskWeblink-{tag}",
|
||||
"url": "https://example.com/test",
|
||||
"task_id": seed_task["id"],
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT task_id FROM weblinks WHERE label = :l AND is_deleted = false"),
|
||||
{"l": f"TaskWeblink-{tag}"},
|
||||
)
|
||||
row = result.first()
|
||||
assert row is not None
|
||||
assert str(row.task_id) == seed_task["id"]
|
||||
|
||||
await db_session.execute(
|
||||
text("DELETE FROM weblinks WHERE label = :l"), {"l": f"TaskWeblink-{tag}"}
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_list_with_task_id(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
seed_task: dict, seed_domain: dict,
|
||||
):
|
||||
"""Creating a list with task_id sets the FK."""
|
||||
tag = _uid()
|
||||
r = await client.post("/lists/create", data={
|
||||
"name": f"TaskList-{tag}",
|
||||
"domain_id": seed_domain["id"],
|
||||
"list_type": "checklist",
|
||||
"task_id": seed_task["id"],
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT task_id FROM lists WHERE name = :n AND is_deleted = false"),
|
||||
{"n": f"TaskList-{tag}"},
|
||||
)
|
||||
row = result.first()
|
||||
assert row is not None
|
||||
assert str(row.task_id) == seed_task["id"]
|
||||
|
||||
await db_session.execute(
|
||||
text("DELETE FROM lists WHERE name = :n"), {"n": f"TaskList-{tag}"}
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_decision_with_task_id(
|
||||
self, client: AsyncClient, db_session: AsyncSession, seed_task: dict,
|
||||
):
|
||||
"""Creating a decision with task_id sets the FK."""
|
||||
tag = _uid()
|
||||
r = await client.post("/decisions/create", data={
|
||||
"title": f"TaskDecision-{tag}",
|
||||
"status": "proposed",
|
||||
"impact": "medium",
|
||||
"task_id": seed_task["id"],
|
||||
}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
result = await db_session.execute(
|
||||
text("SELECT task_id FROM decisions WHERE title = :t AND is_deleted = false"),
|
||||
{"t": f"TaskDecision-{tag}"},
|
||||
)
|
||||
row = result.first()
|
||||
assert row is not None
|
||||
assert str(row.task_id) == seed_task["id"]
|
||||
|
||||
await db_session.execute(
|
||||
text("DELETE FROM decisions WHERE title = :t"), {"t": f"TaskDecision-{tag}"}
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
Reference in New Issue
Block a user