feat(portal): implement dynamic drawer/page navigation management
Replace hardcoded sidebar drawer configuration with admin-manageable dynamic system. Extend page_status.json with drawer definitions and page assignments, add drawer CRUD API endpoints, render portal sidebar via Jinja2 loops, and extend /admin/pages UI with drawer management. Fix multi-worker cache invalidation via mtime-based staleness detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,9 @@
|
|||||||
{
|
{
|
||||||
"route": "/wip-overview",
|
"route": "/wip-overview",
|
||||||
"name": "WIP 即時概況",
|
"name": "WIP 即時概況",
|
||||||
"status": "released"
|
"status": "released",
|
||||||
|
"drawer_id": "reports",
|
||||||
|
"order": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/wip-detail",
|
"route": "/wip-detail",
|
||||||
@@ -23,37 +25,65 @@
|
|||||||
{
|
{
|
||||||
"route": "/resource-history",
|
"route": "/resource-history",
|
||||||
"name": "設備歷史績效",
|
"name": "設備歷史績效",
|
||||||
"status": "released"
|
"status": "released",
|
||||||
|
"drawer_id": "reports",
|
||||||
|
"order": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/tables",
|
"route": "/tables",
|
||||||
"name": "表格總覽",
|
"name": "表格總覽",
|
||||||
"status": "dev"
|
"status": "dev",
|
||||||
|
"drawer_id": "dev-tools",
|
||||||
|
"order": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/resource",
|
"route": "/resource",
|
||||||
"name": "機台狀態",
|
"name": "機台狀態",
|
||||||
"status": "released"
|
"status": "released",
|
||||||
|
"drawer_id": "reports",
|
||||||
|
"order": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/excel-query",
|
"route": "/excel-query",
|
||||||
"name": "Excel 批次查詢",
|
"name": "Excel 批次查詢",
|
||||||
"status": "dev"
|
"status": "dev",
|
||||||
|
"drawer_id": "dev-tools",
|
||||||
|
"order": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/job-query",
|
"route": "/job-query",
|
||||||
"name": "設備維修查詢",
|
"name": "設備維修查詢",
|
||||||
"status": "released"
|
"status": "released",
|
||||||
|
"drawer_id": "queries",
|
||||||
|
"order": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/query-tool",
|
"route": "/query-tool",
|
||||||
"name": "批次追蹤工具",
|
"name": "批次追蹤工具",
|
||||||
"status": "released"
|
"status": "released",
|
||||||
|
"drawer_id": "queries",
|
||||||
|
"order": 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/tmtt-defect",
|
"route": "/tmtt-defect",
|
||||||
"name": "TMTT印字腳型不良分析",
|
"name": "TMTT印字腳型不良分析",
|
||||||
"status": "released"
|
"status": "released",
|
||||||
|
"drawer_id": "queries",
|
||||||
|
"order": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/admin/pages",
|
||||||
|
"name": "頁面管理",
|
||||||
|
"status": "dev",
|
||||||
|
"drawer_id": "dev-tools",
|
||||||
|
"order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/admin/performance",
|
||||||
|
"name": "效能監控",
|
||||||
|
"status": "dev",
|
||||||
|
"drawer_id": "dev-tools",
|
||||||
|
"order": 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"api_public": true,
|
"api_public": true,
|
||||||
@@ -62,5 +92,25 @@
|
|||||||
"updated_at": "2026-01-29 13:49:59",
|
"updated_at": "2026-01-29 13:49:59",
|
||||||
"object_count": 19,
|
"object_count": 19,
|
||||||
"source": "tools/query_table_schema.py"
|
"source": "tools/query_table_schema.py"
|
||||||
}
|
},
|
||||||
|
"drawers": [
|
||||||
|
{
|
||||||
|
"id": "reports",
|
||||||
|
"name": "報表類",
|
||||||
|
"order": 1,
|
||||||
|
"admin_only": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "queries",
|
||||||
|
"name": "查詢類",
|
||||||
|
"order": 2,
|
||||||
|
"admin_only": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dev-tools",
|
||||||
|
"name": "開發工具",
|
||||||
|
"order": 3,
|
||||||
|
"admin_only": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ import './portal.css';
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (sidebarItems.length > 0) {
|
if (sidebarItems.length > 0) {
|
||||||
activateTab(sidebarItems[0].dataset.target);
|
activateTab(sidebarItems[0].dataset.target, sidebarItems[0].dataset.toolSrc || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.toggleHealthPopup = toggleHealthPopup;
|
window.toggleHealthPopup = toggleHealthPopup;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-09
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Portal sidebar 導航結構目前寫死在 `portal.html` (lines 356-421),包含三個固定分類與十個固定頁面按鈕。頁面的 `released`/`dev` 狀態已經由 `data/page_status.json` + `page_registry.py` 動態管理,但抽屜分類與頁面歸屬仍需改程式碼。
|
||||||
|
|
||||||
|
現有架構:
|
||||||
|
- `data/page_status.json`: 頁面狀態持久化(JSON file, atomic write, thread-safe cache)
|
||||||
|
- `page_registry.py`: 服務層(CRUD + cache + lock)
|
||||||
|
- `admin_routes.py` (lines 629-667): Admin API(GET/PUT pages)
|
||||||
|
- `admin/pages.html`: Admin UI(頁面狀態切換表格)
|
||||||
|
- `portal.html`: Jinja2 模板,`{% if can_view_page() %}` 控制可見性
|
||||||
|
|
||||||
|
關鍵約束:
|
||||||
|
- 專案使用 Jinja2 shell + Vite JS 混合架構,所有 11 個頁面一致
|
||||||
|
- 無資料庫,所有配置用 JSON file 持久化
|
||||||
|
- 需要向下相容:現有 `page_status.json` 的 page entries 不可遺失
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Admin 可透過 UI 對抽屜進行 CRUD(新增、改名、刪除、排序)
|
||||||
|
- Admin 可透過 UI 指定頁面歸屬的抽屜與排序
|
||||||
|
- Portal sidebar 根據 JSON 配置動態渲染(Jinja2 for loop)
|
||||||
|
- 向下相容:首次載入時自動從現有 hardcoded 結構產生初始配置
|
||||||
|
- 為未來全面 Vite SPA 化鋪路:API 設計可直接被前端 fetch 使用
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- 不做全面 Vite SPA 化(本次只做 Jinja2 動態渲染)
|
||||||
|
- 不做頁面路由的動態新增(頁面路由仍在 `app.py` 中定義)
|
||||||
|
- 不改變 `released`/`dev` 狀態的邏輯
|
||||||
|
- 不做拖拉排序(使用上下箭頭或數字排序即可)
|
||||||
|
- 不做多使用者即時同步(Admin 改完,其他使用者刷新頁面即可看到)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. 資料結構:擴展 `page_status.json`
|
||||||
|
|
||||||
|
**選擇**: 在現有 JSON 中新增 `drawers` 頂層欄位,並在 page 中加入 `drawer_id` 和 `order`。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"drawers": [
|
||||||
|
{ "id": "reports", "name": "報表類", "order": 1 },
|
||||||
|
{ "id": "queries", "name": "查詢類", "order": 2 },
|
||||||
|
{ "id": "dev-tools", "name": "開發工具", "order": 3, "admin_only": true }
|
||||||
|
],
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"route": "/wip-overview",
|
||||||
|
"name": "WIP 即時概況",
|
||||||
|
"status": "released",
|
||||||
|
"drawer_id": "reports",
|
||||||
|
"order": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_public": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**替代方案**: 獨立 `navigation.json` 檔案。
|
||||||
|
**放棄原因**: 頁面狀態和歸屬是同一份資料的不同面向,拆成兩個檔案增加同步複雜度。現有的 atomic write + lock 機制可以直接沿用。
|
||||||
|
|
||||||
|
### 2. 向下相容:自動遷移策略
|
||||||
|
|
||||||
|
**選擇**: `page_registry.py` 的 `_load()` 函式在讀取時檢測是否存在 `drawers` 欄位。若不存在,自動注入預設的三個抽屜定義並根據目前 portal.html 的 hardcoded 映射填充 `drawer_id`。遷移後立即 `_save()` 持久化。
|
||||||
|
|
||||||
|
**理由**: 零手動操作部署。首次啟動即完成遷移。
|
||||||
|
|
||||||
|
### 3. iframe 策略:維持現行邏輯
|
||||||
|
|
||||||
|
**選擇**: 一般頁面每個獨立 iframe,admin 工具頁面共用 `toolFrame`。動態渲染時根據 `drawer.admin_only` 判斷是否使用共用 iframe。
|
||||||
|
|
||||||
|
**理由**: 最小變動。iframe 行為和 `portal.js` 的 `activateTab()` 邏輯不需改動。
|
||||||
|
|
||||||
|
**具體做法**: 每個 page 的 iframe id 由 route 推導(如 `/wip-overview` → `wipOverviewFrame`),`admin_only` 抽屜下的頁面共用 `toolFrame` 並使用 `data-tool-src` 切換。
|
||||||
|
|
||||||
|
### 4. Admin API 設計
|
||||||
|
|
||||||
|
**選擇**: 在現有 `admin_routes.py` 中擴展,新增 drawer endpoints。
|
||||||
|
|
||||||
|
| Endpoint | Method | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /admin/api/drawers` | GET | 取得所有抽屜 |
|
||||||
|
| `POST /admin/api/drawers` | POST | 新增抽屜 |
|
||||||
|
| `PUT /admin/api/drawers/<id>` | PUT | 更新抽屜(改名、排序) |
|
||||||
|
| `DELETE /admin/api/drawers/<id>` | DELETE | 刪除抽屜(需先移走其下頁面) |
|
||||||
|
| `PUT /admin/api/pages/<route>` | PUT | 擴展現有 endpoint,支援 `drawer_id` 和 `order` |
|
||||||
|
|
||||||
|
**替代方案**: RESTful nested resource `/admin/api/drawers/<id>/pages`。
|
||||||
|
**放棄原因**: 頁面已經有獨立的 PUT endpoint,加上 `drawer_id` 欄位更簡單。
|
||||||
|
|
||||||
|
### 5. Admin UI:擴展 `/admin/pages`
|
||||||
|
|
||||||
|
**選擇**: 在現有 `admin/pages.html` 上方加入抽屜管理區塊,下方頁面列表加入抽屜歸屬下拉選單和排序控制。
|
||||||
|
|
||||||
|
**理由**: 使用者已經知道去哪裡管理頁面,擴展比新頁面更自然。
|
||||||
|
|
||||||
|
### 6. Portal 模板動態化
|
||||||
|
|
||||||
|
**選擇**: `app.py` 的 portal route 讀取 drawers + pages 配置,組裝成結構化資料傳入 Jinja2 context。`portal.html` 用 `{% for %}` 渲染 sidebar 和 iframes。
|
||||||
|
|
||||||
|
**理由**: 維持現有 Jinja2 shell 架構一致性,`can_view_page()` server-side 過濾不需改動。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Risk] JSON 並發寫入** → 現有 `_lock` + atomic write 已處理。單行程 Flask 部署下無問題。如果未來 multi-worker 部署,需考慮 file lock(但這是既有風險,非本次引入)。
|
||||||
|
- **[Risk] 刪除抽屜時頁面孤立** → API 層強制檢查:抽屜下仍有頁面時禁止刪除,回傳 409 Conflict。
|
||||||
|
- **[Risk] 首次遷移時 hardcoded 映射不正確** → 遷移邏輯使用明確的 route-to-drawer 映射表,與現有 portal.html 一一對應。
|
||||||
|
- **[Trade-off] 未歸屬抽屜的頁面不會出現在 sidebar** → 這是刻意設計。子頁面(如 `/wip-detail`, `/hold-detail`)不需出現在 sidebar 中,它們沒有 `drawer_id` 即可。
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- 無。探索階段已充分討論並確認方向。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Portal sidebar 的抽屜分類(報表類/查詢類/開發工具)與頁面歸屬目前寫死在 `portal.html` Jinja2 模板中,任何導航結構的變更都需要改程式碼並重新部署。Admin 應能透過現有的「頁面管理」UI 動態管理抽屜與頁面分配,無需開發介入。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 擴展 `data/page_status.json` 資料結構,加入 `drawers` 陣列定義抽屜(名稱、排序、可見性),並在每個 page 中加入 `drawer_id` 和 `order` 欄位
|
||||||
|
- 擴展 `page_registry.py` 服務層,新增 drawer CRUD 函式與 page-to-drawer 指派函式
|
||||||
|
- 新增 admin API endpoints:drawer 的增刪改查、頁面的抽屜指派與排序
|
||||||
|
- **BREAKING**: `portal.html` sidebar 從寫死 HTML 改為 `{% for drawer in drawers %}` 動態渲染
|
||||||
|
- 擴展現有 `/admin/pages` UI,加入抽屜管理區塊與頁面拖拉/下拉分配功能
|
||||||
|
- 頁面的 `released`/`dev` 狀態邏輯與 `can_view_page()` 權限檢查保持不變
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `drawer-management`: Admin 可透過 API 與 UI 對抽屜進行 CRUD 操作(新增、刪除、改名、排序)
|
||||||
|
- `page-drawer-assignment`: Admin 可透過 API 與 UI 將頁面指派到不同抽屜,並控制頁面在抽屜內的排序
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `portal-drawer-navigation`: 導航結構從寫死改為從 JSON 配置動態渲染,抽屜分類與頁面歸屬由資料驅動
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **資料層**: `data/page_status.json` 結構擴展(向下相容,新增欄位有預設值)
|
||||||
|
- **服務層**: `page_registry.py` 新增 drawer 相關函式
|
||||||
|
- **路由層**: `admin_routes.py` 新增 drawer API endpoints
|
||||||
|
- **模板層**: `portal.html` sidebar 區塊重寫為動態渲染
|
||||||
|
- **前端層**: `admin/pages.html` UI 擴展
|
||||||
|
- **無影響**: 各頁面本身的路由、模板、Vite 模組均不需變更
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to create drawers
|
||||||
|
The system SHALL allow admin users to create new navigation drawers via API, specifying a name, an order value, and an optional `admin_only` flag.
|
||||||
|
|
||||||
|
#### Scenario: Create a new drawer
|
||||||
|
- **WHEN** admin sends POST `/admin/api/drawers` with `{"name": "自訂分類", "order": 4}`
|
||||||
|
- **THEN** the system SHALL create a new drawer with a generated kebab-case id and persist it to `page_status.json`
|
||||||
|
|
||||||
|
#### Scenario: Create drawer with duplicate name
|
||||||
|
- **WHEN** admin sends POST `/admin/api/drawers` with a name that already exists
|
||||||
|
- **THEN** the system SHALL return 409 Conflict with an error message
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to rename drawers
|
||||||
|
The system SHALL allow admin users to update a drawer's name via API.
|
||||||
|
|
||||||
|
#### Scenario: Rename a drawer
|
||||||
|
- **WHEN** admin sends PUT `/admin/api/drawers/<id>` with `{"name": "新名稱"}`
|
||||||
|
- **THEN** the system SHALL update the drawer name and persist the change
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to reorder drawers
|
||||||
|
The system SHALL allow admin users to change a drawer's sort order via API.
|
||||||
|
|
||||||
|
#### Scenario: Change drawer order
|
||||||
|
- **WHEN** admin sends PUT `/admin/api/drawers/<id>` with `{"order": 2}`
|
||||||
|
- **THEN** the system SHALL update the drawer order and the sidebar SHALL reflect the new order on next page load
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to delete empty drawers
|
||||||
|
The system SHALL allow admin users to delete a drawer only when no pages are assigned to it.
|
||||||
|
|
||||||
|
#### Scenario: Delete an empty drawer
|
||||||
|
- **WHEN** admin sends DELETE `/admin/api/drawers/<id>` and the drawer has no assigned pages
|
||||||
|
- **THEN** the system SHALL remove the drawer from the configuration
|
||||||
|
|
||||||
|
#### Scenario: Attempt to delete a drawer with assigned pages
|
||||||
|
- **WHEN** admin sends DELETE `/admin/api/drawers/<id>` and the drawer still has assigned pages
|
||||||
|
- **THEN** the system SHALL return 409 Conflict with an error listing the assigned pages
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to list all drawers
|
||||||
|
The system SHALL provide an API to retrieve all drawers with their metadata.
|
||||||
|
|
||||||
|
#### Scenario: List all drawers
|
||||||
|
- **WHEN** admin sends GET `/admin/api/drawers`
|
||||||
|
- **THEN** the system SHALL return all drawers sorted by their `order` field
|
||||||
|
|
||||||
|
### Requirement: Drawer management SHALL be accessible from the admin UI
|
||||||
|
The existing `/admin/pages` page SHALL include a drawer management section where admin can create, rename, reorder, and delete drawers.
|
||||||
|
|
||||||
|
#### Scenario: Admin opens page management
|
||||||
|
- **WHEN** admin navigates to the page management UI
|
||||||
|
- **THEN** the UI SHALL display a drawer list with controls for add, rename, reorder, and delete
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to assign a page to a drawer
|
||||||
|
The system SHALL allow admin users to assign a page to a specific drawer by setting its `drawer_id` via the existing page update API.
|
||||||
|
|
||||||
|
#### Scenario: Assign page to a drawer
|
||||||
|
- **WHEN** admin sends PUT `/admin/api/pages/<route>` with `{"drawer_id": "queries"}`
|
||||||
|
- **THEN** the page SHALL be associated with the specified drawer
|
||||||
|
|
||||||
|
#### Scenario: Assign page to non-existent drawer
|
||||||
|
- **WHEN** admin sends PUT `/admin/api/pages/<route>` with a `drawer_id` that does not exist
|
||||||
|
- **THEN** the system SHALL return 400 Bad Request with an error message
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to set page order within a drawer
|
||||||
|
The system SHALL allow admin users to control the display order of pages within a drawer.
|
||||||
|
|
||||||
|
#### Scenario: Set page order
|
||||||
|
- **WHEN** admin sends PUT `/admin/api/pages/<route>` with `{"order": 3}`
|
||||||
|
- **THEN** the page SHALL be displayed at position 3 within its drawer on next portal load
|
||||||
|
|
||||||
|
### Requirement: Pages without a drawer assignment SHALL NOT appear in the sidebar
|
||||||
|
Pages that have no `drawer_id` (e.g., sub-pages like `/wip-detail`, `/hold-detail`) SHALL NOT be rendered in the portal sidebar, but SHALL remain accessible via their direct routes.
|
||||||
|
|
||||||
|
#### Scenario: Sub-page without drawer assignment
|
||||||
|
- **WHEN** a page exists in `page_status.json` without a `drawer_id`
|
||||||
|
- **THEN** the page SHALL NOT appear in any sidebar drawer
|
||||||
|
- **THEN** the page SHALL still be accessible via its direct URL
|
||||||
|
|
||||||
|
### Requirement: Page drawer assignment SHALL be configurable from the admin UI
|
||||||
|
The existing `/admin/pages` page table SHALL include a drawer assignment dropdown and order controls for each page.
|
||||||
|
|
||||||
|
#### Scenario: Admin changes page drawer via UI
|
||||||
|
- **WHEN** admin selects a different drawer from the dropdown for a page
|
||||||
|
- **THEN** the UI SHALL call the page update API with the new `drawer_id`
|
||||||
|
|
||||||
|
#### Scenario: Admin clears drawer assignment via UI
|
||||||
|
- **WHEN** admin selects "未分類" (unassigned) from the dropdown
|
||||||
|
- **THEN** the page's `drawer_id` SHALL be removed and the page SHALL no longer appear in the sidebar
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers
|
||||||
|
The portal SHALL group navigation entries into functional drawers as defined in the `drawers` configuration of `page_status.json`, rendered dynamically via Jinja2 loop instead of hardcoded HTML.
|
||||||
|
|
||||||
|
#### Scenario: Drawer grouping visibility
|
||||||
|
- **WHEN** users open the portal
|
||||||
|
- **THEN** the sidebar SHALL display drawers in the order defined by each drawer's `order` field
|
||||||
|
- **THEN** each drawer SHALL show only the pages assigned to it via `drawer_id`, sorted by each page's `order` field
|
||||||
|
|
||||||
|
#### Scenario: Admin-only drawer visibility
|
||||||
|
- **WHEN** a drawer has `admin_only: true` and the current user is not admin
|
||||||
|
- **THEN** the drawer and all its pages SHALL NOT be rendered in the sidebar
|
||||||
|
|
||||||
|
#### Scenario: Empty drawer visibility
|
||||||
|
- **WHEN** a drawer has no visible pages (all filtered out by `can_view_page()`)
|
||||||
|
- **THEN** the drawer group title SHALL NOT be rendered
|
||||||
|
|
||||||
|
### Requirement: Existing Page Behavior SHALL Remain Compatible
|
||||||
|
The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames.
|
||||||
|
|
||||||
|
#### Scenario: Route continuity
|
||||||
|
- **WHEN** a user selects an existing page entry from a dynamically rendered drawer
|
||||||
|
- **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior
|
||||||
|
|
||||||
|
#### Scenario: Iframe lazy-load continuity
|
||||||
|
- **WHEN** a sidebar item is clicked for the first time
|
||||||
|
- **THEN** the iframe SHALL lazy-load its content from the page's route, consistent with current behavior
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: First-run migration SHALL populate drawer configuration automatically
|
||||||
|
When `page_status.json` does not contain a `drawers` field, the system SHALL automatically create the default drawer structure matching the current hardcoded layout and assign existing pages to their corresponding drawers.
|
||||||
|
|
||||||
|
#### Scenario: First startup after deployment
|
||||||
|
- **WHEN** the application starts and `page_status.json` has no `drawers` field
|
||||||
|
- **THEN** the system SHALL create three default drawers (報表類, 查詢類, 開發工具)
|
||||||
|
- **THEN** the system SHALL assign each existing page to its historically correct drawer
|
||||||
|
- **THEN** the system SHALL persist the updated configuration immediately
|
||||||
|
|
||||||
|
#### Scenario: Subsequent startup
|
||||||
|
- **WHEN** the application starts and `page_status.json` already contains a `drawers` field
|
||||||
|
- **THEN** the system SHALL NOT modify the existing drawer configuration
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
## 1. 資料層:擴展 page_status.json 與 page_registry.py
|
||||||
|
|
||||||
|
- [x] 1.1 在 `page_registry.py` 的 `_load()` 中加入自動遷移邏輯:當 `drawers` 欄位不存在時,注入預設三個抽屜並根據 hardcoded 映射表填充每個 page 的 `drawer_id` 和 `order`,然後 `_save()`
|
||||||
|
- [x] 1.2 新增 drawer CRUD 函式:`get_all_drawers()`, `create_drawer(name, order, admin_only)`, `update_drawer(id, ...)`, `delete_drawer(id)`
|
||||||
|
- [x] 1.3 擴展 `set_page_status()` 支援 `drawer_id` 和 `order` 參數
|
||||||
|
- [x] 1.4 新增 `get_navigation_config()` 函式:回傳按 drawer order 排序的巢狀結構(drawers → pages),供 portal route 使用
|
||||||
|
|
||||||
|
## 2. API 層:擴展 admin_routes.py
|
||||||
|
|
||||||
|
- [x] 2.1 新增 `GET /admin/api/drawers` endpoint,回傳所有抽屜(sorted by order)
|
||||||
|
- [x] 2.2 新增 `POST /admin/api/drawers` endpoint,建立新抽屜(驗證名稱不重複)
|
||||||
|
- [x] 2.3 新增 `PUT /admin/api/drawers/<id>` endpoint,更新抽屜名稱/排序/admin_only
|
||||||
|
- [x] 2.4 新增 `DELETE /admin/api/drawers/<id>` endpoint,刪除空抽屜(有頁面時回傳 409)
|
||||||
|
- [x] 2.5 擴展現有 `PUT /admin/api/pages/<route>` endpoint,接受 `drawer_id` 和 `order` 欄位(驗證 drawer_id 存在)
|
||||||
|
|
||||||
|
## 3. 模板層:portal.html 動態渲染
|
||||||
|
|
||||||
|
- [x] 3.1 修改 `app.py` 的 portal route,呼叫 `get_navigation_config()` 取得結構化導航資料,傳入 Jinja2 context
|
||||||
|
- [x] 3.2 將 `portal.html` sidebar 區塊(lines 356-392)改為 `{% for drawer in drawers %}` 動態渲染,保留 `can_view_page()` 過濾與 `admin_only` 判斷
|
||||||
|
- [x] 3.3 將 `portal.html` iframe 區塊(lines 394-421)改為動態渲染,根據配置產生 iframe elements
|
||||||
|
- [x] 3.4 確認 `portal.js` 的 `activateTab()` 與 lazy-load 邏輯在動態 DOM 下正常運作(iframe id 命名規則需一致)
|
||||||
|
|
||||||
|
## 4. Admin UI:擴展 /admin/pages
|
||||||
|
|
||||||
|
- [x] 4.1 在 `admin/pages.html` 上方加入「抽屜管理」區塊:抽屜列表 + 新增/改名/刪除/排序控制
|
||||||
|
- [x] 4.2 在頁面列表每一列加入 drawer 歸屬下拉選單(含「未分類」選項)和排序輸入
|
||||||
|
- [x] 4.3 實作前端 JS:drawer CRUD 操作(呼叫 API → 更新 UI)
|
||||||
|
- [x] 4.4 實作前端 JS:頁面 drawer 指派操作(下拉變更 → PUT API)
|
||||||
|
|
||||||
|
## 5. 驗證
|
||||||
|
|
||||||
|
- [x] 5.1 驗證首次啟動遷移:刪除 `page_status.json` 中的 `drawers` 欄位,重啟後確認自動產生正確的預設配置
|
||||||
|
- [x] 5.2 驗證 portal sidebar 動態渲染:新增/刪除抽屜後刷新 portal,確認 sidebar 正確反映
|
||||||
|
- [x] 5.3 驗證頁面歸屬變更:改變頁面的 drawer_id 後刷新 portal,確認頁面出現在正確的抽屜中
|
||||||
|
- [x] 5.4 驗證權限邏輯不變:非 admin 使用者看不到 dev 頁面、看不到 admin_only 抽屜
|
||||||
|
- [x] 5.5 驗證安全性:非 admin 使用者無法存取 drawer API endpoints
|
||||||
55
openspec/specs/drawer-management/spec.md
Normal file
55
openspec/specs/drawer-management/spec.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
## Purpose
|
||||||
|
Define stable requirements for drawer-management.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to create drawers
|
||||||
|
The system SHALL allow admin users to create new navigation drawers via API, specifying a name, an order value, and an optional `admin_only` flag.
|
||||||
|
|
||||||
|
#### Scenario: Create a new drawer
|
||||||
|
- **WHEN** admin sends POST `/admin/api/drawers` with `{"name": "自訂分類", "order": 4}`
|
||||||
|
- **THEN** the system SHALL create a new drawer with a generated kebab-case id and persist it to `page_status.json`
|
||||||
|
|
||||||
|
#### Scenario: Create drawer with duplicate name
|
||||||
|
- **WHEN** admin sends POST `/admin/api/drawers` with a name that already exists
|
||||||
|
- **THEN** the system SHALL return 409 Conflict with an error message
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to rename drawers
|
||||||
|
The system SHALL allow admin users to update a drawer's name via API.
|
||||||
|
|
||||||
|
#### Scenario: Rename a drawer
|
||||||
|
- **WHEN** admin sends PUT `/admin/api/drawers/<id>` with `{"name": "新名稱"}`
|
||||||
|
- **THEN** the system SHALL update the drawer name and persist the change
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to reorder drawers
|
||||||
|
The system SHALL allow admin users to change a drawer's sort order via API.
|
||||||
|
|
||||||
|
#### Scenario: Change drawer order
|
||||||
|
- **WHEN** admin sends PUT `/admin/api/drawers/<id>` with `{"order": 2}`
|
||||||
|
- **THEN** the system SHALL update the drawer order and the sidebar SHALL reflect the new order on next page load
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to delete empty drawers
|
||||||
|
The system SHALL allow admin users to delete a drawer only when no pages are assigned to it.
|
||||||
|
|
||||||
|
#### Scenario: Delete an empty drawer
|
||||||
|
- **WHEN** admin sends DELETE `/admin/api/drawers/<id>` and the drawer has no assigned pages
|
||||||
|
- **THEN** the system SHALL remove the drawer from the configuration
|
||||||
|
|
||||||
|
#### Scenario: Attempt to delete a drawer with assigned pages
|
||||||
|
- **WHEN** admin sends DELETE `/admin/api/drawers/<id>` and the drawer still has assigned pages
|
||||||
|
- **THEN** the system SHALL return 409 Conflict with an error listing the assigned pages
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to list all drawers
|
||||||
|
The system SHALL provide an API to retrieve all drawers with their metadata.
|
||||||
|
|
||||||
|
#### Scenario: List all drawers
|
||||||
|
- **WHEN** admin sends GET `/admin/api/drawers`
|
||||||
|
- **THEN** the system SHALL return all drawers sorted by their `order` field
|
||||||
|
|
||||||
|
### Requirement: Drawer management SHALL be accessible from the admin UI
|
||||||
|
The existing `/admin/pages` page SHALL include a drawer management section where admin can create, rename, reorder, and delete drawers.
|
||||||
|
|
||||||
|
#### Scenario: Admin opens page management
|
||||||
|
- **WHEN** admin navigates to the page management UI
|
||||||
|
- **THEN** the UI SHALL display a drawer list with controls for add, rename, reorder, and delete
|
||||||
42
openspec/specs/page-drawer-assignment/spec.md
Normal file
42
openspec/specs/page-drawer-assignment/spec.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
## Purpose
|
||||||
|
Define stable requirements for page-drawer-assignment.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to assign a page to a drawer
|
||||||
|
The system SHALL allow admin users to assign a page to a specific drawer by setting its `drawer_id` via the existing page update API.
|
||||||
|
|
||||||
|
#### Scenario: Assign page to a drawer
|
||||||
|
- **WHEN** admin sends PUT `/admin/api/pages/<route>` with `{"drawer_id": "queries"}`
|
||||||
|
- **THEN** the page SHALL be associated with the specified drawer
|
||||||
|
|
||||||
|
#### Scenario: Assign page to non-existent drawer
|
||||||
|
- **WHEN** admin sends PUT `/admin/api/pages/<route>` with a `drawer_id` that does not exist
|
||||||
|
- **THEN** the system SHALL return 400 Bad Request with an error message
|
||||||
|
|
||||||
|
### Requirement: Admin SHALL be able to set page order within a drawer
|
||||||
|
The system SHALL allow admin users to control the display order of pages within a drawer.
|
||||||
|
|
||||||
|
#### Scenario: Set page order
|
||||||
|
- **WHEN** admin sends PUT `/admin/api/pages/<route>` with `{"order": 3}`
|
||||||
|
- **THEN** the page SHALL be displayed at position 3 within its drawer on next portal load
|
||||||
|
|
||||||
|
### Requirement: Pages without a drawer assignment SHALL NOT appear in the sidebar
|
||||||
|
Pages that have no `drawer_id` (e.g., sub-pages like `/wip-detail`, `/hold-detail`) SHALL NOT be rendered in the portal sidebar, but SHALL remain accessible via their direct routes.
|
||||||
|
|
||||||
|
#### Scenario: Sub-page without drawer assignment
|
||||||
|
- **WHEN** a page exists in `page_status.json` without a `drawer_id`
|
||||||
|
- **THEN** the page SHALL NOT appear in any sidebar drawer
|
||||||
|
- **THEN** the page SHALL still be accessible via its direct URL
|
||||||
|
|
||||||
|
### Requirement: Page drawer assignment SHALL be configurable from the admin UI
|
||||||
|
The existing `/admin/pages` page table SHALL include a drawer assignment dropdown and order controls for each page.
|
||||||
|
|
||||||
|
#### Scenario: Admin changes page drawer via UI
|
||||||
|
- **WHEN** admin selects a different drawer from the dropdown for a page
|
||||||
|
- **THEN** the UI SHALL call the page update API with the new `drawer_id`
|
||||||
|
|
||||||
|
#### Scenario: Admin clears drawer assignment via UI
|
||||||
|
- **WHEN** admin selects "未分類" (unassigned) from the dropdown
|
||||||
|
- **THEN** the page's `drawer_id` SHALL be removed and the page SHALL no longer appear in the sidebar
|
||||||
@@ -5,15 +5,41 @@ Define stable requirements for portal-drawer-navigation.
|
|||||||
|
|
||||||
|
|
||||||
### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers
|
### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers
|
||||||
The portal SHALL group navigation entries into functional drawers: reports, queries, and development tools.
|
The portal SHALL group navigation entries into functional drawers as defined in the `drawers` configuration of `page_status.json`, rendered dynamically via Jinja2 loop instead of hardcoded HTML.
|
||||||
|
|
||||||
#### Scenario: Drawer grouping visibility
|
#### Scenario: Drawer grouping visibility
|
||||||
- **WHEN** users open the portal
|
- **WHEN** users open the portal
|
||||||
- **THEN** report pages and query pages SHALL appear in separate drawer groups
|
- **THEN** the sidebar SHALL display drawers in the order defined by each drawer's `order` field
|
||||||
|
- **THEN** each drawer SHALL show only the pages assigned to it via `drawer_id`, sorted by each page's `order` field
|
||||||
|
|
||||||
|
#### Scenario: Admin-only drawer visibility
|
||||||
|
- **WHEN** a drawer has `admin_only: true` and the current user is not admin
|
||||||
|
- **THEN** the drawer and all its pages SHALL NOT be rendered in the sidebar
|
||||||
|
|
||||||
|
#### Scenario: Empty drawer visibility
|
||||||
|
- **WHEN** a drawer has no visible pages (all filtered out by `can_view_page()`)
|
||||||
|
- **THEN** the drawer group title SHALL NOT be rendered
|
||||||
|
|
||||||
### Requirement: Existing Page Behavior SHALL Remain Compatible
|
### Requirement: Existing Page Behavior SHALL Remain Compatible
|
||||||
The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames.
|
The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames.
|
||||||
|
|
||||||
#### Scenario: Route continuity
|
#### Scenario: Route continuity
|
||||||
- **WHEN** a user selects an existing page entry from the new drawer
|
- **WHEN** a user selects an existing page entry from a dynamically rendered drawer
|
||||||
- **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior
|
- **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior
|
||||||
|
|
||||||
|
#### Scenario: Iframe lazy-load continuity
|
||||||
|
- **WHEN** a sidebar item is clicked for the first time
|
||||||
|
- **THEN** the iframe SHALL lazy-load its content from the page's route, consistent with current behavior
|
||||||
|
|
||||||
|
### Requirement: First-run migration SHALL populate drawer configuration automatically
|
||||||
|
When `page_status.json` does not contain a `drawers` field, the system SHALL automatically create the default drawer structure matching the current hardcoded layout and assign existing pages to their corresponding drawers.
|
||||||
|
|
||||||
|
#### Scenario: First startup after deployment
|
||||||
|
- **WHEN** the application starts and `page_status.json` has no `drawers` field
|
||||||
|
- **THEN** the system SHALL create three default drawers (報表類, 查詢類, 開發工具)
|
||||||
|
- **THEN** the system SHALL assign each existing page to its historically correct drawer
|
||||||
|
- **THEN** the system SHALL persist the updated configuration immediately
|
||||||
|
|
||||||
|
#### Scenario: Subsequent startup
|
||||||
|
- **WHEN** the application starts and `page_status.json` already contains a `drawers` field
|
||||||
|
- **THEN** the system SHALL NOT modify the existing drawer configuration
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ from mes_dashboard.routes import register_routes
|
|||||||
from mes_dashboard.routes.auth_routes import auth_bp
|
from mes_dashboard.routes.auth_routes import auth_bp
|
||||||
from mes_dashboard.routes.admin_routes import admin_bp
|
from mes_dashboard.routes.admin_routes import admin_bp
|
||||||
from mes_dashboard.routes.health_routes import health_bp
|
from mes_dashboard.routes.health_routes import health_bp
|
||||||
from mes_dashboard.services.page_registry import get_page_status, is_api_public
|
from mes_dashboard.services.page_registry import (
|
||||||
|
get_navigation_config,
|
||||||
|
get_page_status,
|
||||||
|
is_api_public,
|
||||||
|
)
|
||||||
from mes_dashboard.core.cache_updater import start_cache_updater, stop_cache_updater
|
from mes_dashboard.core.cache_updater import start_cache_updater, stop_cache_updater
|
||||||
from mes_dashboard.services.realtime_equipment_cache import (
|
from mes_dashboard.services.realtime_equipment_cache import (
|
||||||
init_realtime_equipment_cache,
|
init_realtime_equipment_cache,
|
||||||
@@ -367,7 +371,7 @@ def create_app(config_name: str | None = None) -> Flask:
|
|||||||
@app.route('/')
|
@app.route('/')
|
||||||
def portal_index():
|
def portal_index():
|
||||||
"""Portal home with tabs."""
|
"""Portal home with tabs."""
|
||||||
return render_template('portal.html')
|
return render_template('portal.html', drawers=get_navigation_config())
|
||||||
|
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
def favicon():
|
def favicon():
|
||||||
|
|||||||
@@ -31,7 +31,17 @@ from mes_dashboard.core.worker_recovery_policy import (
|
|||||||
extract_restart_history,
|
extract_restart_history,
|
||||||
load_restart_state,
|
load_restart_state,
|
||||||
)
|
)
|
||||||
from mes_dashboard.services.page_registry import get_all_pages, set_page_status
|
from mes_dashboard.services.page_registry import (
|
||||||
|
DrawerConflictError,
|
||||||
|
DrawerNotFoundError,
|
||||||
|
create_drawer,
|
||||||
|
delete_drawer,
|
||||||
|
get_all_drawers,
|
||||||
|
get_all_pages,
|
||||||
|
get_page_status,
|
||||||
|
set_page_status,
|
||||||
|
update_drawer,
|
||||||
|
)
|
||||||
|
|
||||||
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
logger = logging.getLogger("mes_dashboard.admin")
|
logger = logging.getLogger("mes_dashboard.admin")
|
||||||
@@ -637,30 +647,127 @@ def pages():
|
|||||||
return render_template("admin/pages.html")
|
return render_template("admin/pages.html")
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/api/pages", methods=["GET"])
|
@admin_bp.route("/api/pages", methods=["GET"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def api_get_pages():
|
def api_get_pages():
|
||||||
"""API: Get all page configurations."""
|
"""API: Get all page configurations."""
|
||||||
return jsonify({"success": True, "pages": get_all_pages()})
|
return jsonify({"success": True, "pages": get_all_pages()})
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/api/pages/<path:route>", methods=["PUT"])
|
@admin_bp.route("/api/drawers", methods=["GET"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def api_update_page(route: str):
|
def api_get_drawers():
|
||||||
"""API: Update page status."""
|
"""API: Get all drawer configurations."""
|
||||||
data = request.get_json()
|
return jsonify({"success": True, "drawers": get_all_drawers()})
|
||||||
status = data.get("status")
|
|
||||||
name = data.get("name")
|
|
||||||
|
@admin_bp.route("/api/drawers", methods=["POST"])
|
||||||
if status not in ("released", "dev"):
|
@admin_required
|
||||||
return jsonify({"success": False, "error": "Invalid status"}), 400
|
def api_create_drawer():
|
||||||
|
"""API: Create a new drawer."""
|
||||||
# Ensure route starts with /
|
payload = request.get_json(silent=True) or {}
|
||||||
if not route.startswith("/"):
|
name = payload.get("name")
|
||||||
route = "/" + route
|
order = payload.get("order")
|
||||||
|
admin_only = bool(payload.get("admin_only", False))
|
||||||
try:
|
|
||||||
set_page_status(route, status, name)
|
try:
|
||||||
return jsonify({"success": True})
|
drawer = create_drawer(name=name, order=order, admin_only=admin_only)
|
||||||
except Exception as e:
|
except DrawerConflictError as exc:
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(exc)}), 409
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
except Exception as exc: # pragma: no cover - defensive fallback
|
||||||
|
logger.exception("Failed to create drawer")
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 500
|
||||||
|
|
||||||
|
return jsonify({"success": True, "drawer": drawer}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/api/drawers/<drawer_id>", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
def api_update_drawer(drawer_id: str):
|
||||||
|
"""API: Update drawer name/order/admin_only."""
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
updates: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if "name" in payload:
|
||||||
|
updates["name"] = payload.get("name")
|
||||||
|
if "order" in payload:
|
||||||
|
updates["order"] = payload.get("order")
|
||||||
|
if "admin_only" in payload:
|
||||||
|
updates["admin_only"] = bool(payload.get("admin_only"))
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return jsonify({"success": False, "error": "No drawer fields to update"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
drawer = update_drawer(drawer_id, **updates)
|
||||||
|
except DrawerNotFoundError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 404
|
||||||
|
except DrawerConflictError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 409
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
except Exception as exc: # pragma: no cover - defensive fallback
|
||||||
|
logger.exception("Failed to update drawer %s", drawer_id)
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 500
|
||||||
|
|
||||||
|
return jsonify({"success": True, "drawer": drawer})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/api/drawers/<drawer_id>", methods=["DELETE"])
|
||||||
|
@admin_required
|
||||||
|
def api_delete_drawer(drawer_id: str):
|
||||||
|
"""API: Delete a drawer if no pages are assigned to it."""
|
||||||
|
try:
|
||||||
|
delete_drawer(drawer_id)
|
||||||
|
except DrawerNotFoundError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 404
|
||||||
|
except DrawerConflictError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 409
|
||||||
|
except Exception as exc: # pragma: no cover - defensive fallback
|
||||||
|
logger.exception("Failed to delete drawer %s", drawer_id)
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 500
|
||||||
|
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/api/pages/<path:route>", methods=["PUT"])
|
||||||
|
@admin_required
|
||||||
|
def api_update_page(route: str):
|
||||||
|
"""API: Update page status/name/drawer assignment/order."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
updatable_fields = {"status", "name", "drawer_id", "order"}
|
||||||
|
if not any(field in data for field in updatable_fields):
|
||||||
|
return jsonify({"success": False, "error": "No page fields to update"}), 400
|
||||||
|
|
||||||
|
# Ensure route starts with /
|
||||||
|
if not route.startswith("/"):
|
||||||
|
route = "/" + route
|
||||||
|
|
||||||
|
status = data.get("status")
|
||||||
|
if "status" in data and status not in ("released", "dev"):
|
||||||
|
return jsonify({"success": False, "error": "Invalid status"}), 400
|
||||||
|
if "status" not in data:
|
||||||
|
status = get_page_status(route)
|
||||||
|
if status is None:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Status is required for unregistered pages",
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
update_kwargs: dict[str, Any] = {}
|
||||||
|
if "name" in data:
|
||||||
|
update_kwargs["name"] = data.get("name")
|
||||||
|
if "drawer_id" in data:
|
||||||
|
update_kwargs["drawer_id"] = data.get("drawer_id")
|
||||||
|
if "order" in data:
|
||||||
|
update_kwargs["order"] = data.get("order")
|
||||||
|
|
||||||
|
try:
|
||||||
|
set_page_status(route, status, **update_kwargs)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|||||||
@@ -1,44 +1,120 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Page registry service for managing page access status."""
|
"""Page registry service for managing page access status."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Data file path (relative to project root)
|
# Data file path (relative to project root)
|
||||||
# Path: src/mes_dashboard/services/page_registry.py -> project root/data/
|
# Path: src/mes_dashboard/services/page_registry.py -> project root/data/
|
||||||
DATA_FILE = Path(__file__).parent.parent.parent.parent / "data" / "page_status.json"
|
DATA_FILE = Path(__file__).parent.parent.parent.parent / "data" / "page_status.json"
|
||||||
_lock = Lock()
|
_lock = Lock()
|
||||||
_cache: dict | None = None
|
_cache: dict | None = None
|
||||||
|
_cache_mtime: float = 0.0
|
||||||
|
_UNSET = object()
|
||||||
def _load() -> dict:
|
|
||||||
"""Load page status configuration."""
|
DEFAULT_DRAWERS = [
|
||||||
global _cache
|
{"id": "reports", "name": "報表類", "order": 1, "admin_only": False},
|
||||||
if _cache is None:
|
{"id": "queries", "name": "查詢類", "order": 2, "admin_only": False},
|
||||||
if DATA_FILE.exists():
|
{"id": "dev-tools", "name": "開發工具", "order": 3, "admin_only": True},
|
||||||
try:
|
]
|
||||||
_cache = json.loads(DATA_FILE.read_text(encoding="utf-8"))
|
|
||||||
logger.debug("Loaded page status from %s", DATA_FILE)
|
LEGACY_NAV_ASSIGNMENTS = {
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
"/wip-overview": {"drawer_id": "reports", "order": 1},
|
||||||
logger.warning("Failed to load page status: %s", e)
|
"/resource": {"drawer_id": "reports", "order": 2},
|
||||||
_cache = {"pages": [], "api_public": True}
|
"/resource-history": {"drawer_id": "reports", "order": 3},
|
||||||
else:
|
"/tables": {"drawer_id": "queries", "order": 1},
|
||||||
logger.info("Page status file not found, using defaults")
|
"/excel-query": {"drawer_id": "queries", "order": 2},
|
||||||
_cache = {"pages": [], "api_public": True}
|
"/job-query": {"drawer_id": "queries", "order": 3},
|
||||||
return _cache
|
"/query-tool": {"drawer_id": "queries", "order": 4},
|
||||||
|
"/tmtt-defect": {"drawer_id": "queries", "order": 5},
|
||||||
|
"/admin/pages": {
|
||||||
|
"drawer_id": "dev-tools",
|
||||||
|
"order": 1,
|
||||||
|
"status": "dev",
|
||||||
|
"name": "頁面管理",
|
||||||
|
},
|
||||||
|
"/admin/performance": {
|
||||||
|
"drawer_id": "dev-tools",
|
||||||
|
"order": 2,
|
||||||
|
"status": "dev",
|
||||||
|
"name": "效能監控",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
LEGACY_FRAME_ID_MAP = {
|
||||||
|
"/wip-overview": "wipOverviewFrame",
|
||||||
|
"/resource": "resourceFrame",
|
||||||
|
"/tables": "tableFrame",
|
||||||
|
"/excel-query": "excelQueryFrame",
|
||||||
|
"/resource-history": "resourceHistoryFrame",
|
||||||
|
"/job-query": "jobQueryFrame",
|
||||||
|
"/query-tool": "queryToolFrame",
|
||||||
|
"/tmtt-defect": "tmttDefectFrame",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DrawerError(Exception):
|
||||||
|
"""Base drawer management error."""
|
||||||
|
|
||||||
|
|
||||||
|
class DrawerNotFoundError(DrawerError):
|
||||||
|
"""Raised when drawer cannot be found."""
|
||||||
|
|
||||||
|
|
||||||
|
class DrawerConflictError(DrawerError):
|
||||||
|
"""Raised when drawer operation conflicts with existing data."""
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict:
|
||||||
|
"""Load page status configuration.
|
||||||
|
|
||||||
|
Detects file changes across gunicorn workers by comparing mtime,
|
||||||
|
so writes from one worker are visible to reads in another.
|
||||||
|
"""
|
||||||
|
global _cache, _cache_mtime
|
||||||
|
|
||||||
|
# Check if another worker has written a newer version to disk.
|
||||||
|
if _cache is not None:
|
||||||
|
try:
|
||||||
|
disk_mtime = DATA_FILE.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
disk_mtime = 0.0
|
||||||
|
if disk_mtime > _cache_mtime:
|
||||||
|
_cache = None # Invalidate so we re-read below.
|
||||||
|
|
||||||
|
if _cache is None:
|
||||||
|
if DATA_FILE.exists():
|
||||||
|
try:
|
||||||
|
_cache = json.loads(DATA_FILE.read_text(encoding="utf-8"))
|
||||||
|
_cache_mtime = DATA_FILE.stat().st_mtime
|
||||||
|
logger.debug("Loaded page status from %s", DATA_FILE)
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.warning("Failed to load page status: %s", e)
|
||||||
|
_cache = {"pages": [], "api_public": True}
|
||||||
|
_cache_mtime = 0.0
|
||||||
|
else:
|
||||||
|
logger.info("Page status file not found, using defaults")
|
||||||
|
_cache = {"pages": [], "api_public": True}
|
||||||
|
_cache_mtime = 0.0
|
||||||
|
|
||||||
|
if _migrate_navigation_schema(_cache):
|
||||||
|
_save(_cache)
|
||||||
|
logger.info("Migrated page status config to drawers schema")
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
def _save(data: dict) -> None:
|
def _save(data: dict) -> None:
|
||||||
"""Save page status configuration."""
|
"""Save page status configuration."""
|
||||||
global _cache
|
global _cache, _cache_mtime
|
||||||
tmp_path: Path | None = None
|
tmp_path: Path | None = None
|
||||||
try:
|
try:
|
||||||
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -59,6 +135,10 @@ def _save(data: dict) -> None:
|
|||||||
tmp_path = Path(tmp.name)
|
tmp_path = Path(tmp.name)
|
||||||
os.replace(tmp_path, DATA_FILE)
|
os.replace(tmp_path, DATA_FILE)
|
||||||
_cache = data
|
_cache = data
|
||||||
|
try:
|
||||||
|
_cache_mtime = DATA_FILE.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
_cache_mtime = 0.0
|
||||||
logger.debug("Saved page status to %s", DATA_FILE)
|
logger.debug("Saved page status to %s", DATA_FILE)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if tmp_path is not None:
|
if tmp_path is not None:
|
||||||
@@ -68,96 +148,378 @@ def _save(data: dict) -> None:
|
|||||||
pass
|
pass
|
||||||
logger.error("Failed to save page status: %s", e)
|
logger.error("Failed to save page status: %s", e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def get_page_status(route: str) -> str | None:
|
def _migrate_navigation_schema(data: dict) -> bool:
|
||||||
"""Get page status ('released' or 'dev').
|
"""Migrate legacy schema to drawers schema when needed."""
|
||||||
|
if "drawers" in data:
|
||||||
Args:
|
return False
|
||||||
route: Page route path (e.g., '/wip-overview')
|
|
||||||
|
data["drawers"] = [drawer.copy() for drawer in DEFAULT_DRAWERS]
|
||||||
Returns:
|
pages = data.setdefault("pages", [])
|
||||||
'released', 'dev', or None if page is not registered.
|
pages_by_route = {page.get("route"): page for page in pages if page.get("route")}
|
||||||
"""
|
|
||||||
with _lock:
|
for route, assignment in LEGACY_NAV_ASSIGNMENTS.items():
|
||||||
data = _load()
|
page = pages_by_route.get(route)
|
||||||
for page in data.get("pages", []):
|
if page is None and route.startswith("/admin/"):
|
||||||
if page["route"] == route:
|
page = {
|
||||||
return page.get("status", "dev")
|
"route": route,
|
||||||
return None # Not registered - let Flask handle it
|
"name": assignment.get("name", route),
|
||||||
|
"status": assignment.get("status", "dev"),
|
||||||
|
}
|
||||||
def is_page_registered(route: str) -> bool:
|
pages.append(page)
|
||||||
"""Check if a page is registered in the page registry.
|
pages_by_route[route] = page
|
||||||
|
|
||||||
Args:
|
if page is None:
|
||||||
route: Page route path (e.g., '/wip-overview')
|
continue
|
||||||
|
|
||||||
Returns:
|
page.setdefault("drawer_id", assignment["drawer_id"])
|
||||||
True if page is registered, False otherwise.
|
page.setdefault("order", assignment["order"])
|
||||||
"""
|
if assignment.get("name"):
|
||||||
return get_page_status(route) is not None
|
page.setdefault("name", assignment["name"])
|
||||||
|
if assignment.get("status"):
|
||||||
|
page.setdefault("status", assignment["status"])
|
||||||
def set_page_status(route: str, status: str, name: str | None = None) -> None:
|
|
||||||
"""Set page status.
|
return True
|
||||||
|
|
||||||
Args:
|
|
||||||
route: Page route path
|
def _safe_int(value: object, default: int) -> int:
|
||||||
status: 'released' or 'dev'
|
try:
|
||||||
name: Optional page display name
|
return int(value) # type: ignore[arg-type]
|
||||||
"""
|
except (TypeError, ValueError):
|
||||||
if status not in ("released", "dev"):
|
return default
|
||||||
raise ValueError(f"Invalid status: {status}")
|
|
||||||
|
|
||||||
with _lock:
|
def _as_positive_int(value: object, *, field: str) -> int:
|
||||||
data = _load()
|
try:
|
||||||
pages = data.setdefault("pages", [])
|
parsed = int(value) # type: ignore[arg-type]
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
# Update existing page
|
raise ValueError(f"{field} must be an integer") from exc
|
||||||
for page in pages:
|
|
||||||
if page["route"] == route:
|
if parsed < 1:
|
||||||
page["status"] = status
|
raise ValueError(f"{field} must be >= 1")
|
||||||
if name:
|
return parsed
|
||||||
page["name"] = name
|
|
||||||
_save(data)
|
|
||||||
logger.info("Updated page status: %s -> %s", route, status)
|
def _drawer_exists(data: dict, drawer_id: str) -> bool:
|
||||||
return
|
return any(drawer.get("id") == drawer_id for drawer in data.get("drawers", []))
|
||||||
|
|
||||||
# Add new page
|
|
||||||
pages.append({
|
def _apply_page_drawer(data: dict, page: dict, drawer_id: str | None) -> None:
|
||||||
"route": route,
|
if drawer_id in (None, ""):
|
||||||
"name": name or route,
|
page.pop("drawer_id", None)
|
||||||
"status": status
|
return
|
||||||
})
|
|
||||||
_save(data)
|
drawer_id = str(drawer_id)
|
||||||
logger.info("Added new page: %s (%s)", route, status)
|
if not _drawer_exists(data, drawer_id):
|
||||||
|
raise ValueError(f"Drawer not found: {drawer_id}")
|
||||||
|
page["drawer_id"] = drawer_id
|
||||||
def get_all_pages() -> list[dict]:
|
|
||||||
"""Get all page configurations.
|
|
||||||
|
def _apply_page_order(page: dict, order: object) -> None:
|
||||||
Returns:
|
if order in (None, ""):
|
||||||
List of page dicts: [{route, name, status}, ...]
|
page.pop("order", None)
|
||||||
"""
|
return
|
||||||
with _lock:
|
page["order"] = _as_positive_int(order, field="order")
|
||||||
return _load().get("pages", [])
|
|
||||||
|
|
||||||
|
def _slugify_drawer_id(name: str) -> str:
|
||||||
def is_api_public() -> bool:
|
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||||
"""Check if API endpoints are publicly accessible.
|
return slug or "drawer"
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if API endpoints bypass permission checks
|
def _generate_drawer_id(name: str, existing_ids: set[str]) -> str:
|
||||||
"""
|
base = _slugify_drawer_id(name)
|
||||||
with _lock:
|
candidate = base
|
||||||
return _load().get("api_public", True)
|
suffix = 2
|
||||||
|
while candidate in existing_ids:
|
||||||
|
candidate = f"{base}-{suffix}"
|
||||||
def reload_cache() -> None:
|
suffix += 1
|
||||||
"""Force reload of page status from disk."""
|
return candidate
|
||||||
global _cache
|
|
||||||
with _lock:
|
|
||||||
_cache = None
|
def _route_to_frame_id(route: str) -> str:
|
||||||
_load()
|
if route in LEGACY_FRAME_ID_MAP:
|
||||||
logger.info("Reloaded page status cache")
|
return LEGACY_FRAME_ID_MAP[route]
|
||||||
|
|
||||||
|
parts = [part for part in re.split(r"[\/_-]+", route.strip("/")) if part]
|
||||||
|
if not parts:
|
||||||
|
return "homeFrame"
|
||||||
|
camel = parts[0].lower() + "".join(part.capitalize() for part in parts[1:])
|
||||||
|
return f"{camel}Frame"
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_drawers(drawers: list[dict]) -> list[dict]:
|
||||||
|
return sorted(
|
||||||
|
drawers,
|
||||||
|
key=lambda drawer: (
|
||||||
|
_safe_int(drawer.get("order"), 9999),
|
||||||
|
str(drawer.get("name", "")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_pages(pages: list[dict]) -> list[dict]:
|
||||||
|
return sorted(
|
||||||
|
pages,
|
||||||
|
key=lambda page: (
|
||||||
|
_safe_int(page.get("order"), 9999),
|
||||||
|
str(page.get("name") or page.get("route", "")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_status(route: str) -> str | None:
|
||||||
|
"""Get page status ('released' or 'dev').
|
||||||
|
|
||||||
|
Args:
|
||||||
|
route: Page route path (e.g., '/wip-overview')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
'released', 'dev', or None if page is not registered.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
data = _load()
|
||||||
|
for page in data.get("pages", []):
|
||||||
|
if page["route"] == route:
|
||||||
|
return page.get("status", "dev")
|
||||||
|
return None # Not registered - let Flask handle it
|
||||||
|
|
||||||
|
|
||||||
|
def is_page_registered(route: str) -> bool:
|
||||||
|
"""Check if a page is registered in the page registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
route: Page route path (e.g., '/wip-overview')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if page is registered, False otherwise.
|
||||||
|
"""
|
||||||
|
return get_page_status(route) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def set_page_status(
|
||||||
|
route: str,
|
||||||
|
status: str,
|
||||||
|
name: str | None = None,
|
||||||
|
drawer_id: str | None | object = _UNSET,
|
||||||
|
order: int | None | object = _UNSET,
|
||||||
|
) -> None:
|
||||||
|
"""Set page status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
route: Page route path
|
||||||
|
status: 'released' or 'dev'
|
||||||
|
name: Optional page display name
|
||||||
|
drawer_id: Optional drawer assignment (None/'': clear assignment)
|
||||||
|
order: Optional page order within drawer (None/'': clear order)
|
||||||
|
"""
|
||||||
|
if status not in ("released", "dev"):
|
||||||
|
raise ValueError(f"Invalid status: {status}")
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
data = _load()
|
||||||
|
pages = data.setdefault("pages", [])
|
||||||
|
|
||||||
|
# Update existing page
|
||||||
|
for page in pages:
|
||||||
|
if page["route"] == route:
|
||||||
|
page["status"] = status
|
||||||
|
if name:
|
||||||
|
page["name"] = name
|
||||||
|
if drawer_id is not _UNSET:
|
||||||
|
_apply_page_drawer(data, page, drawer_id)
|
||||||
|
if order is not _UNSET:
|
||||||
|
_apply_page_order(page, order)
|
||||||
|
_save(data)
|
||||||
|
logger.info("Updated page status: %s -> %s", route, status)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add new page
|
||||||
|
new_page = {
|
||||||
|
"route": route,
|
||||||
|
"name": name or route,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if drawer_id is not _UNSET:
|
||||||
|
_apply_page_drawer(data, new_page, drawer_id)
|
||||||
|
if order is not _UNSET:
|
||||||
|
_apply_page_order(new_page, order)
|
||||||
|
|
||||||
|
pages.append(new_page)
|
||||||
|
_save(data)
|
||||||
|
logger.info("Added new page: %s (%s)", route, status)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_pages() -> list[dict]:
|
||||||
|
"""Get all page configurations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of page dicts: [{route, name, status, drawer_id?, order?}, ...]
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
return _load().get("pages", [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_drawers() -> list[dict]:
|
||||||
|
"""Get all drawer configurations sorted by order."""
|
||||||
|
with _lock:
|
||||||
|
data = _load()
|
||||||
|
drawers = data.setdefault("drawers", [drawer.copy() for drawer in DEFAULT_DRAWERS])
|
||||||
|
return [dict(drawer) for drawer in _sorted_drawers(drawers)]
|
||||||
|
|
||||||
|
|
||||||
|
def create_drawer(name: str, order: int | None = None, admin_only: bool = False) -> dict:
|
||||||
|
"""Create a new drawer and persist it."""
|
||||||
|
normalized_name = (name or "").strip()
|
||||||
|
if not normalized_name:
|
||||||
|
raise ValueError("Drawer name is required")
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
data = _load()
|
||||||
|
drawers = data.setdefault("drawers", [drawer.copy() for drawer in DEFAULT_DRAWERS])
|
||||||
|
lower_names = {str(drawer.get("name", "")).strip().casefold() for drawer in drawers}
|
||||||
|
if normalized_name.casefold() in lower_names:
|
||||||
|
raise DrawerConflictError(f"Drawer name already exists: {normalized_name}")
|
||||||
|
|
||||||
|
existing_ids = {str(drawer.get("id", "")) for drawer in drawers}
|
||||||
|
drawer_id = _generate_drawer_id(normalized_name, existing_ids)
|
||||||
|
if order is None:
|
||||||
|
order = max((_safe_int(drawer.get("order"), 0) for drawer in drawers), default=0) + 1
|
||||||
|
else:
|
||||||
|
order = _as_positive_int(order, field="order")
|
||||||
|
|
||||||
|
created = {
|
||||||
|
"id": drawer_id,
|
||||||
|
"name": normalized_name,
|
||||||
|
"order": order,
|
||||||
|
"admin_only": bool(admin_only),
|
||||||
|
}
|
||||||
|
drawers.append(created)
|
||||||
|
_save(data)
|
||||||
|
logger.info("Created drawer: %s", drawer_id)
|
||||||
|
return dict(created)
|
||||||
|
|
||||||
|
|
||||||
|
def update_drawer(
|
||||||
|
drawer_id: str,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
order: int | None = None,
|
||||||
|
admin_only: bool | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Update drawer fields and persist it."""
|
||||||
|
with _lock:
|
||||||
|
data = _load()
|
||||||
|
drawers = data.setdefault("drawers", [drawer.copy() for drawer in DEFAULT_DRAWERS])
|
||||||
|
target = next((drawer for drawer in drawers if drawer.get("id") == drawer_id), None)
|
||||||
|
if target is None:
|
||||||
|
raise DrawerNotFoundError(f"Drawer not found: {drawer_id}")
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
normalized_name = name.strip()
|
||||||
|
if not normalized_name:
|
||||||
|
raise ValueError("Drawer name is required")
|
||||||
|
for drawer in drawers:
|
||||||
|
if drawer is target:
|
||||||
|
continue
|
||||||
|
if str(drawer.get("name", "")).strip().casefold() == normalized_name.casefold():
|
||||||
|
raise DrawerConflictError(f"Drawer name already exists: {normalized_name}")
|
||||||
|
target["name"] = normalized_name
|
||||||
|
|
||||||
|
if order is not None:
|
||||||
|
target["order"] = _as_positive_int(order, field="order")
|
||||||
|
|
||||||
|
if admin_only is not None:
|
||||||
|
target["admin_only"] = bool(admin_only)
|
||||||
|
|
||||||
|
_save(data)
|
||||||
|
logger.info("Updated drawer: %s", drawer_id)
|
||||||
|
return dict(target)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_drawer(drawer_id: str) -> None:
|
||||||
|
"""Delete a drawer when no pages are assigned to it."""
|
||||||
|
with _lock:
|
||||||
|
data = _load()
|
||||||
|
drawers = data.setdefault("drawers", [drawer.copy() for drawer in DEFAULT_DRAWERS])
|
||||||
|
target = next((drawer for drawer in drawers if drawer.get("id") == drawer_id), None)
|
||||||
|
if target is None:
|
||||||
|
raise DrawerNotFoundError(f"Drawer not found: {drawer_id}")
|
||||||
|
|
||||||
|
assigned_pages = [
|
||||||
|
page.get("route")
|
||||||
|
for page in data.get("pages", [])
|
||||||
|
if page.get("drawer_id") == drawer_id
|
||||||
|
]
|
||||||
|
if assigned_pages:
|
||||||
|
assigned = ", ".join(str(route) for route in assigned_pages if route)
|
||||||
|
raise DrawerConflictError(f"Drawer has assigned pages: {assigned}")
|
||||||
|
|
||||||
|
data["drawers"] = [drawer for drawer in drawers if drawer.get("id") != drawer_id]
|
||||||
|
_save(data)
|
||||||
|
logger.info("Deleted drawer: %s", drawer_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_navigation_config() -> list[dict]:
|
||||||
|
"""Get drawers with nested pages sorted for portal sidebar rendering."""
|
||||||
|
with _lock:
|
||||||
|
data = _load()
|
||||||
|
drawers = _sorted_drawers(data.get("drawers", []))
|
||||||
|
grouped: dict[str, dict] = {}
|
||||||
|
ordered_drawers: list[dict] = []
|
||||||
|
|
||||||
|
for drawer in drawers:
|
||||||
|
normalized_drawer = {
|
||||||
|
"id": str(drawer.get("id")),
|
||||||
|
"name": drawer.get("name") or str(drawer.get("id")),
|
||||||
|
"order": _safe_int(drawer.get("order"), 9999),
|
||||||
|
"admin_only": bool(drawer.get("admin_only", False)),
|
||||||
|
"pages": [],
|
||||||
|
}
|
||||||
|
grouped[normalized_drawer["id"]] = normalized_drawer
|
||||||
|
ordered_drawers.append(normalized_drawer)
|
||||||
|
|
||||||
|
for page in data.get("pages", []):
|
||||||
|
route = page.get("route")
|
||||||
|
drawer_id = page.get("drawer_id")
|
||||||
|
if not route or not drawer_id or drawer_id not in grouped:
|
||||||
|
continue
|
||||||
|
|
||||||
|
drawer = grouped[drawer_id]
|
||||||
|
use_tool_frame = bool(drawer["admin_only"])
|
||||||
|
drawer["pages"].append(
|
||||||
|
{
|
||||||
|
"route": route,
|
||||||
|
"name": page.get("name") or route,
|
||||||
|
"status": page.get("status", "dev"),
|
||||||
|
"order": _safe_int(page.get("order"), 9999),
|
||||||
|
"frame_id": "toolFrame" if use_tool_frame else _route_to_frame_id(route),
|
||||||
|
"tool_src": route if use_tool_frame else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for drawer in ordered_drawers:
|
||||||
|
drawer["pages"] = _sorted_pages(drawer["pages"])
|
||||||
|
|
||||||
|
return ordered_drawers
|
||||||
|
|
||||||
|
|
||||||
|
def is_api_public() -> bool:
|
||||||
|
"""Check if API endpoints are publicly accessible.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if API endpoints bypass permission checks
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
return _load().get("api_public", True)
|
||||||
|
|
||||||
|
|
||||||
|
def reload_cache() -> None:
|
||||||
|
"""Force reload of page status from disk."""
|
||||||
|
global _cache, _cache_mtime
|
||||||
|
with _lock:
|
||||||
|
_cache = None
|
||||||
|
_cache_mtime = 0.0
|
||||||
|
_load()
|
||||||
|
logger.info("Reloaded page status cache")
|
||||||
|
|||||||
@@ -1,301 +1,669 @@
|
|||||||
{% extends "_base.html" %}
|
{% extends "_base.html" %}
|
||||||
|
|
||||||
{% block title %}頁面管理 - MES Dashboard{% endblock %}
|
{% block title %}頁面管理 - MES Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Microsoft JhengHei', Arial, sans-serif;
|
font-family: 'Microsoft JhengHei', Arial, sans-serif;
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
color: #222;
|
color: #222;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
max-width: 1200px;
|
max-width: 1320px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left h1 {
|
.header-left h1 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left p {
|
.header-left p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-info {
|
.admin-info {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-info .name {
|
.admin-info .name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
color: white;
|
color: white;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn:hover {
|
.logout-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
.panel-header {
|
|
||||||
padding: 16px 20px;
|
.panel-header {
|
||||||
border-bottom: 1px solid #eee;
|
padding: 16px 20px;
|
||||||
display: flex;
|
border-bottom: 1px solid #eee;
|
||||||
justify-content: space-between;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: space-between;
|
||||||
}
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
.panel-header h2 {
|
}
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
.panel-header h2 {
|
||||||
}
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
.table-container {
|
}
|
||||||
overflow-x: auto;
|
|
||||||
}
|
.panel-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
table {
|
color: #64748b;
|
||||||
width: 100%;
|
}
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
th, td {
|
}
|
||||||
padding: 14px 20px;
|
|
||||||
text-align: left;
|
table {
|
||||||
border-bottom: 1px solid #eee;
|
width: 100%;
|
||||||
}
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
th {
|
|
||||||
background: #f8f9fa;
|
th, td {
|
||||||
font-weight: 600;
|
padding: 14px 16px;
|
||||||
font-size: 14px;
|
text-align: left;
|
||||||
color: #555;
|
border-bottom: 1px solid #eee;
|
||||||
}
|
vertical-align: middle;
|
||||||
|
}
|
||||||
td {
|
|
||||||
font-size: 14px;
|
th {
|
||||||
}
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
tr:hover {
|
font-size: 14px;
|
||||||
background: #fafbfc;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-cell {
|
td {
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
font-size: 14px;
|
||||||
color: #555;
|
}
|
||||||
}
|
|
||||||
|
tr:hover {
|
||||||
.status-badge {
|
background: #fafbfc;
|
||||||
display: inline-flex;
|
}
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
.route-cell {
|
||||||
padding: 6px 12px;
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
border-radius: 20px;
|
color: #555;
|
||||||
font-size: 13px;
|
}
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
.status-badge {
|
||||||
transition: all 0.2s ease;
|
display: inline-flex;
|
||||||
}
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
.status-released {
|
padding: 6px 12px;
|
||||||
background: #e8f5e9;
|
border-radius: 20px;
|
||||||
color: #2e7d32;
|
font-size: 13px;
|
||||||
}
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
.status-dev {
|
transition: all 0.2s ease;
|
||||||
background: #fff3e0;
|
border: none;
|
||||||
color: #e65100;
|
}
|
||||||
}
|
|
||||||
|
.status-released {
|
||||||
.status-badge:hover {
|
background: #e8f5e9;
|
||||||
transform: scale(1.05);
|
color: #2e7d32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.status-dev {
|
||||||
text-align: center;
|
background: #fff3e0;
|
||||||
padding: 40px;
|
color: #e65100;
|
||||||
color: #666;
|
}
|
||||||
}
|
|
||||||
|
.status-badge:hover {
|
||||||
.back-link {
|
transform: scale(1.05);
|
||||||
display: inline-block;
|
}
|
||||||
margin-top: 16px;
|
|
||||||
color: #667eea;
|
.input {
|
||||||
text-decoration: none;
|
width: 100%;
|
||||||
font-size: 14px;
|
border: 1px solid #cbd5e1;
|
||||||
}
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
.back-link:hover {
|
font-size: 13px;
|
||||||
text-decoration: underline;
|
background: #fff;
|
||||||
}
|
color: #1e293b;
|
||||||
</style>
|
}
|
||||||
{% endblock %}
|
|
||||||
|
.input:focus {
|
||||||
{% block content %}
|
outline: none;
|
||||||
<div class="shell">
|
border-color: #667eea;
|
||||||
<div class="header">
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||||
<div class="header-left">
|
}
|
||||||
<h1>頁面管理</h1>
|
|
||||||
<p>設定頁面存取權限:Released(所有人可見)/ Dev(僅管理員可見)</p>
|
.order-input {
|
||||||
</div>
|
width: 96px;
|
||||||
<div class="header-right">
|
}
|
||||||
{% if admin_user %}
|
|
||||||
<div class="admin-info">
|
.drawer-create {
|
||||||
<div class="name">{{ admin_user.displayName }}</div>
|
padding: 14px 20px;
|
||||||
<div>{{ admin_user.mail }}</div>
|
border-bottom: 1px solid #eee;
|
||||||
</div>
|
display: flex;
|
||||||
{% endif %}
|
gap: 10px;
|
||||||
<a href="{{ url_for('auth.logout') }}" class="logout-btn">登出</a>
|
flex-wrap: wrap;
|
||||||
</div>
|
align-items: center;
|
||||||
</div>
|
background: #f8fafc;
|
||||||
|
}
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-header">
|
.checkbox-label {
|
||||||
<h2>所有頁面</h2>
|
display: inline-flex;
|
||||||
</div>
|
align-items: center;
|
||||||
<div class="table-container">
|
gap: 6px;
|
||||||
<table>
|
color: #475569;
|
||||||
<thead>
|
font-size: 13px;
|
||||||
<tr>
|
white-space: nowrap;
|
||||||
<th>路由</th>
|
}
|
||||||
<th>名稱</th>
|
|
||||||
<th>狀態</th>
|
.action-btn {
|
||||||
</tr>
|
border: 1px solid #d0d7e3;
|
||||||
</thead>
|
border-radius: 6px;
|
||||||
<tbody id="pages-tbody">
|
padding: 6px 10px;
|
||||||
<tr>
|
background: #fff;
|
||||||
<td colspan="3" class="loading">載入中...</td>
|
color: #334155;
|
||||||
</tr>
|
cursor: pointer;
|
||||||
</tbody>
|
font-size: 12px;
|
||||||
</table>
|
transition: all 0.2s ease;
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
|
.action-btn:hover {
|
||||||
<a href="{{ url_for('portal_index') }}" class="back-link">返回首頁</a>
|
border-color: #94a3b8;
|
||||||
</div>
|
background: #f8fafc;
|
||||||
{% endblock %}
|
}
|
||||||
|
|
||||||
{% block scripts %}
|
.action-btn.primary {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #667eea;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary:hover {
|
||||||
|
background: #5668d8;
|
||||||
|
border-color: #5668d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.danger {
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: #b91c1c;
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.danger:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="shell">
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>頁面管理</h1>
|
||||||
|
<p>管理頁面狀態、抽屜分類與排序(Released:所有人可見 / Dev:僅管理員可見)</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
{% if admin_user %}
|
||||||
|
<div class="admin-info">
|
||||||
|
<div class="name">{{ admin_user.displayName }}</div>
|
||||||
|
<div>{{ admin_user.mail }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('auth.logout') }}" class="logout-btn">登出</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>抽屜管理</h2>
|
||||||
|
<div class="panel-subtitle">可新增、改名、排序、設定 admin-only,空抽屜才能刪除</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-create">
|
||||||
|
<input id="new-drawer-name" class="input" type="text" placeholder="新抽屜名稱(例如:自訂分類)">
|
||||||
|
<input id="new-drawer-order" class="input order-input" type="number" min="1" placeholder="排序">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input id="new-drawer-admin-only" type="checkbox">
|
||||||
|
僅管理員可見
|
||||||
|
</label>
|
||||||
|
<button id="create-drawer-btn" class="action-btn primary">新增抽屜</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>名稱</th>
|
||||||
|
<th>排序</th>
|
||||||
|
<th>可見性</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="drawers-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="loading">載入中...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>所有頁面</h2>
|
||||||
|
<div class="panel-subtitle">可切換 Released/Dev,並設定抽屜歸屬與抽屜內排序</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>路由</th>
|
||||||
|
<th>名稱</th>
|
||||||
|
<th>狀態</th>
|
||||||
|
<th>抽屜歸屬</th>
|
||||||
|
<th>排序</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pages-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="loading">載入中...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('portal_index') }}" class="back-link">返回首頁</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
const tbody = document.getElementById('pages-tbody');
|
const pagesTbody = document.getElementById('pages-tbody');
|
||||||
|
const drawersTbody = document.getElementById('drawers-tbody');
|
||||||
|
const createDrawerBtn = document.getElementById('create-drawer-btn');
|
||||||
|
const newDrawerNameInput = document.getElementById('new-drawer-name');
|
||||||
|
const newDrawerOrderInput = document.getElementById('new-drawer-order');
|
||||||
|
const newDrawerAdminOnlyInput = document.getElementById('new-drawer-admin-only');
|
||||||
const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content || '';
|
const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content || '';
|
||||||
|
|
||||||
|
let drawersCache = [];
|
||||||
|
let pagesCache = [];
|
||||||
|
|
||||||
function withCsrfHeaders(headers = {}) {
|
function withCsrfHeaders(headers = {}) {
|
||||||
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
|
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPages() {
|
function escapeHtml(value) {
|
||||||
try {
|
const div = document.createElement('div');
|
||||||
const response = await fetch('/admin/api/pages');
|
div.textContent = value ?? '';
|
||||||
const data = await response.json();
|
return div.innerHTML;
|
||||||
|
}
|
||||||
if (!data.success) {
|
|
||||||
throw new Error(data.error || 'Failed to load pages');
|
function escapeAttr(value) {
|
||||||
}
|
return escapeHtml(String(value ?? '')).replace(/\"/g, '"');
|
||||||
|
}
|
||||||
renderPages(data.pages);
|
|
||||||
} catch (error) {
|
async function parseJsonResponse(response) {
|
||||||
console.error('Error loading pages:', error);
|
try {
|
||||||
tbody.innerHTML = `<tr><td colspan="3" class="loading">載入失敗: ${error.message}</td></tr>`;
|
return await response.json();
|
||||||
}
|
} catch (error) {
|
||||||
}
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
function renderPages(pages) {
|
}
|
||||||
if (pages.length === 0) {
|
throw error;
|
||||||
tbody.innerHTML = '<tr><td colspan="3" class="loading">尚無頁面設定</td></tr>';
|
}
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
async function apiFetch(url, options = {}) {
|
||||||
tbody.innerHTML = pages.map(page => `
|
const config = { ...options };
|
||||||
<tr>
|
config.headers = withCsrfHeaders(config.headers || {});
|
||||||
<td class="route-cell">${escapeHtml(page.route)}</td>
|
const response = await fetch(url, config);
|
||||||
<td>${escapeHtml(page.name)}</td>
|
const data = await parseJsonResponse(response);
|
||||||
<td>
|
if (!response.ok || data.success === false) {
|
||||||
<span class="status-badge status-${page.status}"
|
throw new Error(data.error || `HTTP ${response.status}`);
|
||||||
onclick="toggleStatus('${escapeHtml(page.route)}', '${page.status}')">
|
}
|
||||||
${page.status === 'released' ? 'Released' : 'Dev'}
|
return data;
|
||||||
</span>
|
}
|
||||||
</td>
|
|
||||||
</tr>
|
function drawerOptionsHtml(selectedDrawerId) {
|
||||||
`).join('');
|
const normalized = selectedDrawerId || '';
|
||||||
}
|
const options = ['<option value="">未分類</option>'];
|
||||||
|
drawersCache.forEach((drawer) => {
|
||||||
async function toggleStatus(route, currentStatus) {
|
const selected = drawer.id === normalized ? 'selected' : '';
|
||||||
const newStatus = currentStatus === 'released' ? 'dev' : 'released';
|
options.push(
|
||||||
|
`<option value="${escapeAttr(drawer.id)}" ${selected}>${escapeHtml(drawer.name)}</option>`
|
||||||
try {
|
);
|
||||||
const response = await fetch(`/admin/api/pages${route}`, {
|
});
|
||||||
method: 'PUT',
|
return options.join('');
|
||||||
headers: withCsrfHeaders({ 'Content-Type': 'application/json' }),
|
}
|
||||||
body: JSON.stringify({ status: newStatus })
|
|
||||||
|
function renderDrawers(drawers) {
|
||||||
|
if (!drawers || drawers.length === 0) {
|
||||||
|
drawersTbody.innerHTML = '<tr><td colspan="5" class="loading">尚無抽屜</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawersTbody.innerHTML = drawers.map((drawer) => `
|
||||||
|
<tr data-drawer-id="${escapeAttr(drawer.id)}">
|
||||||
|
<td class="route-cell">${escapeHtml(drawer.id)}</td>
|
||||||
|
<td><input class="input drawer-name-input" type="text" value="${escapeAttr(drawer.name)}"></td>
|
||||||
|
<td><input class="input order-input drawer-order-input" type="number" min="1" value="${escapeAttr(String(drawer.order ?? ''))}"></td>
|
||||||
|
<td>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input class="drawer-admin-only-input" type="checkbox" ${drawer.admin_only ? 'checked' : ''}>
|
||||||
|
僅管理員
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button class="action-btn" data-action="save-drawer">儲存</button>
|
||||||
|
<button class="action-btn danger" data-action="delete-drawer">刪除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPages(pages) {
|
||||||
|
if (!pages || pages.length === 0) {
|
||||||
|
pagesTbody.innerHTML = '<tr><td colspan="5" class="loading">尚無頁面設定</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesTbody.innerHTML = pages.map((page) => `
|
||||||
|
<tr data-route="${escapeAttr(page.route)}">
|
||||||
|
<td class="route-cell">${escapeHtml(page.route)}</td>
|
||||||
|
<td>${escapeHtml(page.name)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="status-badge status-${page.status}" data-action="toggle-status">
|
||||||
|
${page.status === 'released' ? 'Released' : 'Dev'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select class="input drawer-select">
|
||||||
|
${drawerOptionsHtml(page.drawer_id)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
class="input order-input page-order-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value="${escapeAttr(String(page.order ?? ''))}"
|
||||||
|
placeholder="未設定"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDrawers() {
|
||||||
|
const data = await apiFetch('/admin/api/drawers');
|
||||||
|
drawersCache = data.drawers || [];
|
||||||
|
renderDrawers(drawersCache);
|
||||||
|
renderPages(pagesCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPages() {
|
||||||
|
const data = await apiFetch('/admin/api/pages');
|
||||||
|
pagesCache = data.pages || [];
|
||||||
|
renderPages(pagesCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await loadDrawers();
|
||||||
|
await loadPages();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDrawer() {
|
||||||
|
const name = newDrawerNameInput.value.trim();
|
||||||
|
const orderRaw = newDrawerOrderInput.value.trim();
|
||||||
|
const adminOnly = newDrawerAdminOnlyInput.checked;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
window.MES?.toast?.error?.('請輸入抽屜名稱');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
admin_only: adminOnly,
|
||||||
|
};
|
||||||
|
if (orderRaw !== '') {
|
||||||
|
payload.order = Number(orderRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch('/admin/api/drawers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
window.MES?.toast?.success?.(`已新增抽屜:${name}`);
|
||||||
const data = await response.json();
|
newDrawerNameInput.value = '';
|
||||||
|
newDrawerOrderInput.value = '';
|
||||||
if (!data.success) {
|
newDrawerAdminOnlyInput.checked = false;
|
||||||
throw new Error(data.error || 'Failed to update status');
|
await refreshAll();
|
||||||
}
|
} catch (error) {
|
||||||
|
window.MES?.toast?.error?.(`新增抽屜失敗: ${error.message}`);
|
||||||
window.MES?.toast?.success?.(`已更新: ${route} → ${newStatus}`);
|
}
|
||||||
loadPages();
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating status:', error);
|
async function saveDrawerFromRow(row) {
|
||||||
window.MES?.toast?.error?.(`更新失敗: ${error.message}`);
|
const drawerId = row.dataset.drawerId;
|
||||||
}
|
const name = row.querySelector('.drawer-name-input')?.value.trim() || '';
|
||||||
}
|
const orderRaw = row.querySelector('.drawer-order-input')?.value.trim() || '';
|
||||||
|
const adminOnly = row.querySelector('.drawer-admin-only-input')?.checked || false;
|
||||||
function escapeHtml(str) {
|
|
||||||
const div = document.createElement('div');
|
if (!drawerId) {
|
||||||
div.textContent = str;
|
return;
|
||||||
return div.innerHTML;
|
}
|
||||||
}
|
|
||||||
|
const payload = { name, admin_only: adminOnly };
|
||||||
// Load pages on page load
|
if (orderRaw !== '') {
|
||||||
loadPages();
|
payload.order = Number(orderRaw);
|
||||||
</script>
|
}
|
||||||
{% endblock %}
|
|
||||||
|
try {
|
||||||
|
await apiFetch(`/admin/api/drawers/${encodeURIComponent(drawerId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
window.MES?.toast?.success?.(`已更新抽屜:${drawerId}`);
|
||||||
|
await refreshAll();
|
||||||
|
} catch (error) {
|
||||||
|
window.MES?.toast?.error?.(`更新抽屜失敗: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDrawerById(drawerId) {
|
||||||
|
if (!drawerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`確定刪除抽屜「${drawerId}」?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch(`/admin/api/drawers/${encodeURIComponent(drawerId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
window.MES?.toast?.success?.(`已刪除抽屜:${drawerId}`);
|
||||||
|
await refreshAll();
|
||||||
|
} catch (error) {
|
||||||
|
window.MES?.toast?.error?.(`刪除抽屜失敗: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePage(route, payload, successMessage) {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/admin/api/pages${route}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (successMessage) {
|
||||||
|
window.MES?.toast?.success?.(successMessage);
|
||||||
|
}
|
||||||
|
await loadPages();
|
||||||
|
} catch (error) {
|
||||||
|
window.MES?.toast?.error?.(`更新頁面失敗: ${error.message}`);
|
||||||
|
await loadPages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePageStatus(route, currentStatus) {
|
||||||
|
const nextStatus = currentStatus === 'released' ? 'dev' : 'released';
|
||||||
|
await updatePage(route, { status: nextStatus }, `已更新: ${route} → ${nextStatus}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesTbody.addEventListener('click', async (event) => {
|
||||||
|
const button = event.target.closest('button[data-action="toggle-status"]');
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = button.closest('tr[data-route]');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route = row.dataset.route;
|
||||||
|
const currentStatus = button.classList.contains('status-released') ? 'released' : 'dev';
|
||||||
|
if (route) {
|
||||||
|
await togglePageStatus(route, currentStatus);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pagesTbody.addEventListener('change', async (event) => {
|
||||||
|
const row = event.target.closest('tr[data-route]');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route = row.dataset.route;
|
||||||
|
if (!route) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.classList.contains('drawer-select')) {
|
||||||
|
const drawerId = event.target.value || null;
|
||||||
|
await updatePage(route, { drawer_id: drawerId }, `已更新抽屜歸屬: ${route}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.classList.contains('page-order-input')) {
|
||||||
|
const value = event.target.value.trim();
|
||||||
|
const payload = { order: value === '' ? null : Number(value) };
|
||||||
|
await updatePage(route, payload, `已更新排序: ${route}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
drawersTbody.addEventListener('click', async (event) => {
|
||||||
|
const button = event.target.closest('button[data-action]');
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = button.closest('tr[data-drawer-id]');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawerId = row.dataset.drawerId;
|
||||||
|
if (button.dataset.action === 'save-drawer') {
|
||||||
|
await saveDrawerFromRow(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (button.dataset.action === 'delete-drawer') {
|
||||||
|
await deleteDrawerById(drawerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createDrawerBtn.addEventListener('click', createDrawer);
|
||||||
|
newDrawerNameInput.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
createDrawer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshAll().catch((error) => {
|
||||||
|
pagesTbody.innerHTML = `<tr><td colspan="5" class="loading">載入失敗: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
|
drawersTbody.innerHTML = `<tr><td colspan="5" class="loading">載入失敗: ${escapeHtml(error.message)}</td></tr>`;
|
||||||
|
window.MES?.toast?.error?.(`載入失敗: ${error.message}`);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -342,8 +342,6 @@
|
|||||||
<div class="admin-status">
|
<div class="admin-status">
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<span class="admin-name">{{ admin_user.displayName }}</span>
|
<span class="admin-name">{{ admin_user.displayName }}</span>
|
||||||
<a href="{{ url_for('admin.pages') }}">頁面管理</a>
|
|
||||||
<a href="{{ url_for('admin.performance') }}">效能監控</a>
|
|
||||||
<a href="{{ url_for('auth.logout') }}">登出</a>
|
<a href="{{ url_for('auth.logout') }}">登出</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ url_for('auth.login') }}">管理員登入</a>
|
<a href="{{ url_for('auth.login') }}">管理員登入</a>
|
||||||
@@ -354,69 +352,49 @@
|
|||||||
|
|
||||||
<div class="main-layout">
|
<div class="main-layout">
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
<div class="sidebar-group-title">報表類</div>
|
{% for drawer in drawers | default([]) %}
|
||||||
{% if can_view_page('/wip-overview') %}
|
{% if not drawer.admin_only or is_admin %}
|
||||||
<button class="sidebar-item" data-target="wipOverviewFrame">WIP 即時概況</button>
|
{% set nav_ns = namespace(has_visible_pages=false) %}
|
||||||
{% endif %}
|
{% for page in drawer.pages %}
|
||||||
{% if can_view_page('/resource') %}
|
{% if can_view_page(page.route) %}
|
||||||
<button class="sidebar-item" data-target="resourceFrame">設備即時概況</button>
|
{% set nav_ns.has_visible_pages = true %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_view_page('/resource-history') %}
|
{% endfor %}
|
||||||
<button class="sidebar-item" data-target="resourceHistoryFrame">設備歷史績效</button>
|
{% if nav_ns.has_visible_pages %}
|
||||||
{% endif %}
|
<div class="sidebar-group-title">{{ drawer.name }}</div>
|
||||||
|
{% for page in drawer.pages %}
|
||||||
<div class="sidebar-group-title">查詢類</div>
|
{% if can_view_page(page.route) %}
|
||||||
{% if can_view_page('/tables') %}
|
<button
|
||||||
<button class="sidebar-item" data-target="tableFrame">數據表查詢工具</button>
|
class="sidebar-item"
|
||||||
{% endif %}
|
data-target="{{ page.frame_id }}"
|
||||||
{% if can_view_page('/excel-query') %}
|
{% if page.tool_src %}data-tool-src="{{ page.tool_src }}"{% endif %}
|
||||||
<button class="sidebar-item" data-target="excelQueryFrame">Excel 批次查詢</button>
|
>{{ page.name }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_view_page('/job-query') %}
|
{% endfor %}
|
||||||
<button class="sidebar-item" data-target="jobQueryFrame">設備維修查詢</button>
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_view_page('/query-tool') %}
|
{% endfor %}
|
||||||
<button class="sidebar-item" data-target="queryToolFrame">批次追蹤工具</button>
|
{% if not is_admin %}
|
||||||
{% endif %}
|
|
||||||
{% if can_view_page('/tmtt-defect') %}
|
|
||||||
<button class="sidebar-item" data-target="tmttDefectFrame">TMTT不良分析</button>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="sidebar-group-title">開發工具</div>
|
|
||||||
{% if is_admin %}
|
|
||||||
<button class="sidebar-item" data-target="toolFrame" data-tool-src="/admin/pages">頁面管理</button>
|
|
||||||
<button class="sidebar-item" data-target="toolFrame" data-tool-src="/admin/performance">效能監控</button>
|
|
||||||
{% else %}
|
|
||||||
<a class="sidebar-item" href="{{ url_for('auth.login') }}">管理員登入</a>
|
<a class="sidebar-item" href="{{ url_for('auth.login') }}">管理員登入</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
{% if can_view_page('/wip-overview') %}
|
{% set frame_ns = namespace(has_tool_pages=false) %}
|
||||||
<iframe id="wipOverviewFrame" data-src="/wip-overview" title="WIP 即時概況"></iframe>
|
{% for drawer in drawers | default([]) %}
|
||||||
{% endif %}
|
{% if not drawer.admin_only or is_admin %}
|
||||||
{% if can_view_page('/resource') %}
|
{% for page in drawer.pages %}
|
||||||
<iframe id="resourceFrame" data-src="/resource" title="設備即時概況"></iframe>
|
{% if can_view_page(page.route) %}
|
||||||
{% endif %}
|
{% if page.tool_src %}
|
||||||
{% if can_view_page('/tables') %}
|
{% set frame_ns.has_tool_pages = true %}
|
||||||
<iframe id="tableFrame" data-src="/tables" title="數據表查詢工具"></iframe>
|
{% else %}
|
||||||
{% endif %}
|
<iframe id="{{ page.frame_id }}" data-src="{{ page.route }}" title="{{ page.name }}"></iframe>
|
||||||
{% if can_view_page('/excel-query') %}
|
{% endif %}
|
||||||
<iframe id="excelQueryFrame" data-src="/excel-query" title="Excel 批次查詢"></iframe>
|
{% endif %}
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
{% if can_view_page('/resource-history') %}
|
{% endif %}
|
||||||
<iframe id="resourceHistoryFrame" data-src="/resource-history" title="設備歷史績效"></iframe>
|
{% endfor %}
|
||||||
{% endif %}
|
{% if is_admin and frame_ns.has_tool_pages %}
|
||||||
{% if can_view_page('/job-query') %}
|
|
||||||
<iframe id="jobQueryFrame" data-src="/job-query" title="設備維修查詢"></iframe>
|
|
||||||
{% endif %}
|
|
||||||
{% if can_view_page('/query-tool') %}
|
|
||||||
<iframe id="queryToolFrame" data-src="/query-tool" title="批次追蹤工具"></iframe>
|
|
||||||
{% endif %}
|
|
||||||
{% if can_view_page('/tmtt-defect') %}
|
|
||||||
<iframe id="tmttDefectFrame" data-src="/tmtt-defect" title="TMTT不良分析"></iframe>
|
|
||||||
{% endif %}
|
|
||||||
{% if is_admin %}
|
|
||||||
<iframe id="toolFrame" title="開發工具"></iframe>
|
<iframe id="toolFrame" title="開發工具"></iframe>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -432,7 +410,6 @@
|
|||||||
<script>
|
<script>
|
||||||
const sidebarItems = document.querySelectorAll('.sidebar-item[data-target]');
|
const sidebarItems = document.querySelectorAll('.sidebar-item[data-target]');
|
||||||
const frames = document.querySelectorAll('iframe');
|
const frames = document.querySelectorAll('iframe');
|
||||||
const toolFrame = document.getElementById('toolFrame');
|
|
||||||
|
|
||||||
function setFrameHeight() {
|
function setFrameHeight() {
|
||||||
const headerHeight = document.querySelector('.header').offsetHeight;
|
const headerHeight = document.querySelector('.header').offsetHeight;
|
||||||
@@ -476,7 +453,7 @@
|
|||||||
|
|
||||||
// Auto-activate first available item
|
// Auto-activate first available item
|
||||||
if (sidebarItems.length > 0) {
|
if (sidebarItems.length > 0) {
|
||||||
activateTab(sidebarItems[0].dataset.target);
|
activateTab(sidebarItems[0].dataset.target, sidebarItems[0].dataset.toolSrc || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', setFrameHeight);
|
window.addEventListener('resize', setFrameHeight);
|
||||||
|
|||||||
@@ -280,8 +280,8 @@ class TestPermissionMiddleware:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
class TestAdminAPI:
|
class TestAdminAPI:
|
||||||
"""Tests for admin API endpoints."""
|
"""Tests for admin API endpoints."""
|
||||||
|
|
||||||
def test_get_pages_without_login(self, client):
|
def test_get_pages_without_login(self, client):
|
||||||
"""Test get pages API requires login."""
|
"""Test get pages API requires login."""
|
||||||
@@ -289,22 +289,64 @@ class TestAdminAPI:
|
|||||||
# Should redirect
|
# Should redirect
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
|
|
||||||
def test_get_pages_with_login(self, client):
|
def test_get_pages_with_login(self, client):
|
||||||
"""Test get pages API with login."""
|
"""Test get pages API with login."""
|
||||||
with client.session_transaction() as sess:
|
with client.session_transaction() as sess:
|
||||||
sess["admin"] = {"username": "admin"}
|
sess["admin"] = {"username": "admin"}
|
||||||
|
|
||||||
response = client.get("/admin/api/pages")
|
response = client.get("/admin/api/pages")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
data = json.loads(response.data)
|
data = json.loads(response.data)
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert "pages" in data
|
assert "pages" in data
|
||||||
|
|
||||||
def test_update_page_status(self, client, temp_page_status):
|
def test_get_drawers_without_login(self, client):
|
||||||
"""Test updating page status via API."""
|
"""Test drawer API requires login."""
|
||||||
with client.session_transaction() as sess:
|
response = client.get("/admin/api/drawers", follow_redirects=False)
|
||||||
sess["admin"] = {"username": "admin"}
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
def test_mutate_drawers_without_login(self, client):
|
||||||
|
"""Test drawer mutations require login."""
|
||||||
|
response = client.post(
|
||||||
|
"/admin/api/drawers",
|
||||||
|
data=json.dumps({"name": "Unauthorized Drawer"}),
|
||||||
|
content_type="application/json",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code in (302, 401)
|
||||||
|
|
||||||
|
response = client.delete("/admin/api/drawers/reports", follow_redirects=False)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
def test_get_drawers_with_login(self, client):
|
||||||
|
"""Test list drawers API with login."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["admin"] = {"username": "admin"}
|
||||||
|
|
||||||
|
response = client.get("/admin/api/drawers")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "drawers" in data
|
||||||
|
assert any(drawer["id"] == "reports" for drawer in data["drawers"])
|
||||||
|
|
||||||
|
def test_create_drawer_duplicate_name_conflict(self, client):
|
||||||
|
"""Test creating duplicate drawer name returns 409."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["admin"] = {"username": "admin"}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/admin/api/drawers",
|
||||||
|
data=json.dumps({"name": "報表類", "order": 99}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_update_page_status(self, client, temp_page_status):
|
||||||
|
"""Test updating page status via API."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["admin"] = {"username": "admin"}
|
||||||
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
"/admin/api/pages/wip-overview",
|
"/admin/api/pages/wip-overview",
|
||||||
@@ -329,10 +371,48 @@ class TestAdminAPI:
|
|||||||
"/admin/api/pages/wip-overview",
|
"/admin/api/pages/wip-overview",
|
||||||
data=json.dumps({"status": "invalid"}),
|
data=json.dumps({"status": "invalid"}),
|
||||||
content_type="application/json"
|
content_type="application/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_update_page_drawer_assignment(self, client):
|
||||||
|
"""Test assigning page drawer via page update API."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["admin"] = {"username": "admin"}
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
"/admin/api/pages/wip-overview",
|
||||||
|
data=json.dumps({"drawer_id": "queries", "order": 3}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
page_registry._cache = None
|
||||||
|
pages = page_registry.get_all_pages()
|
||||||
|
page = next(item for item in pages if item["route"] == "/wip-overview")
|
||||||
|
assert page["drawer_id"] == "queries"
|
||||||
|
assert page["order"] == 3
|
||||||
|
|
||||||
|
def test_update_page_invalid_drawer_assignment(self, client):
|
||||||
|
"""Test assigning a non-existent drawer returns bad request."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["admin"] = {"username": "admin"}
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
"/admin/api/pages/wip-overview",
|
||||||
|
data=json.dumps({"drawer_id": "missing-drawer"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_delete_drawer_with_assigned_pages_conflict(self, client):
|
||||||
|
"""Test deleting a non-empty drawer returns conflict."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["admin"] = {"username": "admin"}
|
||||||
|
|
||||||
|
response = client.delete("/admin/api/drawers/reports")
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
class TestContextProcessor:
|
class TestContextProcessor:
|
||||||
"""Tests for template context processor."""
|
"""Tests for template context processor."""
|
||||||
|
|||||||
@@ -1,194 +1,243 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Unit tests for page_registry module."""
|
"""Unit tests for page_registry module."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import pytest
|
import os
|
||||||
import tempfile
|
import sys
|
||||||
from pathlib import Path
|
import threading
|
||||||
from unittest.mock import patch
|
|
||||||
|
import pytest
|
||||||
import sys
|
|
||||||
import os
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
|
||||||
|
from mes_dashboard.services import page_registry
|
||||||
from mes_dashboard.services import page_registry
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
@pytest.fixture
|
def temp_data_file(tmp_path):
|
||||||
def temp_data_file(tmp_path):
|
"""Create a temporary legacy data file for migration tests."""
|
||||||
"""Create a temporary data file for testing."""
|
data_file = tmp_path / "page_status.json"
|
||||||
data_file = tmp_path / "page_status.json"
|
initial_data = {
|
||||||
initial_data = {
|
"pages": [
|
||||||
"pages": [
|
{"route": "/", "name": "Home", "status": "released"},
|
||||||
{"route": "/", "name": "Home", "status": "released"},
|
{"route": "/wip-overview", "name": "WIP Overview", "status": "released"},
|
||||||
{"route": "/dev-page", "name": "Dev Page", "status": "dev"},
|
{"route": "/tables", "name": "Tables", "status": "dev"},
|
||||||
],
|
{"route": "/dev-page", "name": "Dev Page", "status": "dev"},
|
||||||
"api_public": True
|
],
|
||||||
}
|
"api_public": True,
|
||||||
data_file.write_text(json.dumps(initial_data), encoding="utf-8")
|
}
|
||||||
return data_file
|
data_file.write_text(json.dumps(initial_data), encoding="utf-8")
|
||||||
|
return data_file
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_registry(temp_data_file):
|
@pytest.fixture
|
||||||
"""Mock page_registry to use temp file."""
|
def mock_registry(temp_data_file):
|
||||||
original_data_file = page_registry.DATA_FILE
|
"""Mock page_registry to use temp file."""
|
||||||
original_cache = page_registry._cache
|
original_data_file = page_registry.DATA_FILE
|
||||||
|
original_cache = page_registry._cache
|
||||||
page_registry.DATA_FILE = temp_data_file
|
|
||||||
page_registry._cache = None # Clear cache
|
page_registry.DATA_FILE = temp_data_file
|
||||||
|
page_registry._cache = None
|
||||||
yield temp_data_file
|
|
||||||
|
yield temp_data_file
|
||||||
# Restore original
|
|
||||||
page_registry.DATA_FILE = original_data_file
|
page_registry.DATA_FILE = original_data_file
|
||||||
page_registry._cache = original_cache
|
page_registry._cache = original_cache
|
||||||
|
|
||||||
|
|
||||||
class TestGetPageStatus:
|
class TestSchemaMigration:
|
||||||
"""Tests for get_page_status function."""
|
"""Tests for first-run drawers migration."""
|
||||||
|
|
||||||
def test_get_released_page_status(self, mock_registry):
|
def test_migration_adds_drawers_and_assignments(self, mock_registry):
|
||||||
"""Test getting status of released page."""
|
drawers = page_registry.get_all_drawers()
|
||||||
status = page_registry.get_page_status("/")
|
drawer_ids = [drawer["id"] for drawer in drawers]
|
||||||
assert status == "released"
|
assert drawer_ids == ["reports", "queries", "dev-tools"]
|
||||||
|
|
||||||
def test_get_dev_page_status(self, mock_registry):
|
pages = page_registry.get_all_pages()
|
||||||
"""Test getting status of dev page."""
|
page_by_route = {page["route"]: page for page in pages}
|
||||||
status = page_registry.get_page_status("/dev-page")
|
|
||||||
assert status == "dev"
|
assert page_by_route["/wip-overview"]["drawer_id"] == "reports"
|
||||||
|
assert page_by_route["/wip-overview"]["order"] == 1
|
||||||
def test_get_unregistered_page_status(self, mock_registry):
|
assert page_by_route["/tables"]["drawer_id"] == "queries"
|
||||||
"""Test getting status of unregistered page returns None."""
|
assert page_by_route["/tables"]["order"] == 1
|
||||||
status = page_registry.get_page_status("/not-registered")
|
|
||||||
assert status is None
|
# Admin tools should be backfilled from legacy hardcoded sidebar mapping.
|
||||||
|
assert page_by_route["/admin/pages"]["drawer_id"] == "dev-tools"
|
||||||
|
assert page_by_route["/admin/performance"]["drawer_id"] == "dev-tools"
|
||||||
class TestIsPageRegistered:
|
|
||||||
"""Tests for is_page_registered function."""
|
def test_subsequent_load_does_not_reset_drawers(self, mock_registry):
|
||||||
|
page_registry.get_all_drawers()
|
||||||
def test_registered_page(self, mock_registry):
|
page_registry.create_drawer("custom", order=10, admin_only=False)
|
||||||
"""Test checking registered page."""
|
|
||||||
assert page_registry.is_page_registered("/") is True
|
page_registry.reload_cache()
|
||||||
|
drawers = page_registry.get_all_drawers()
|
||||||
def test_unregistered_page(self, mock_registry):
|
assert any(drawer["id"] == "custom" for drawer in drawers)
|
||||||
"""Test checking unregistered page."""
|
|
||||||
assert page_registry.is_page_registered("/not-here") is False
|
|
||||||
|
class TestGetPageStatus:
|
||||||
|
"""Tests for get_page_status function."""
|
||||||
class TestSetPageStatus:
|
|
||||||
"""Tests for set_page_status function."""
|
def test_get_released_page_status(self, mock_registry):
|
||||||
|
status = page_registry.get_page_status("/")
|
||||||
def test_update_existing_page(self, mock_registry):
|
assert status == "released"
|
||||||
"""Test updating existing page status."""
|
|
||||||
page_registry.set_page_status("/", "dev")
|
def test_get_dev_page_status(self, mock_registry):
|
||||||
assert page_registry.get_page_status("/") == "dev"
|
status = page_registry.get_page_status("/dev-page")
|
||||||
|
assert status == "dev"
|
||||||
def test_add_new_page(self, mock_registry):
|
|
||||||
"""Test adding new page."""
|
def test_get_unregistered_page_status(self, mock_registry):
|
||||||
page_registry.set_page_status("/new-page", "released", "New Page")
|
status = page_registry.get_page_status("/not-registered")
|
||||||
assert page_registry.get_page_status("/new-page") == "released"
|
assert status is None
|
||||||
|
|
||||||
def test_invalid_status_raises_error(self, mock_registry):
|
|
||||||
"""Test setting invalid status raises ValueError."""
|
class TestSetPageStatus:
|
||||||
with pytest.raises(ValueError, match="Invalid status"):
|
"""Tests for set_page_status function."""
|
||||||
page_registry.set_page_status("/", "invalid")
|
|
||||||
|
def test_update_existing_page_status(self, mock_registry):
|
||||||
def test_update_page_name(self, mock_registry):
|
page_registry.set_page_status("/dev-page", "released")
|
||||||
"""Test updating page name."""
|
assert page_registry.get_page_status("/dev-page") == "released"
|
||||||
page_registry.set_page_status("/", "released", "New Name")
|
|
||||||
pages = page_registry.get_all_pages()
|
def test_set_page_drawer_and_order(self, mock_registry):
|
||||||
home = next(p for p in pages if p["route"] == "/")
|
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=9)
|
||||||
assert home["name"] == "New Name"
|
pages = page_registry.get_all_pages()
|
||||||
|
dev_page = next(page for page in pages if page["route"] == "/dev-page")
|
||||||
|
assert dev_page["drawer_id"] == "queries"
|
||||||
class TestGetAllPages:
|
assert dev_page["order"] == 9
|
||||||
"""Tests for get_all_pages function."""
|
|
||||||
|
def test_clear_page_drawer_and_order(self, mock_registry):
|
||||||
def test_get_all_pages(self, mock_registry):
|
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=9)
|
||||||
"""Test getting all pages."""
|
page_registry.set_page_status("/dev-page", "dev", drawer_id=None, order=None)
|
||||||
pages = page_registry.get_all_pages()
|
pages = page_registry.get_all_pages()
|
||||||
assert len(pages) == 2
|
dev_page = next(page for page in pages if page["route"] == "/dev-page")
|
||||||
routes = [p["route"] for p in pages]
|
assert "drawer_id" not in dev_page
|
||||||
assert "/" in routes
|
assert "order" not in dev_page
|
||||||
assert "/dev-page" in routes
|
|
||||||
|
def test_set_invalid_drawer_raises_error(self, mock_registry):
|
||||||
|
with pytest.raises(ValueError, match="Drawer not found"):
|
||||||
class TestIsApiPublic:
|
page_registry.set_page_status("/dev-page", "dev", drawer_id="not-exists")
|
||||||
"""Tests for is_api_public function."""
|
|
||||||
|
def test_invalid_status_raises_error(self, mock_registry):
|
||||||
def test_api_public_true(self, mock_registry):
|
with pytest.raises(ValueError, match="Invalid status"):
|
||||||
"""Test API public flag when true."""
|
page_registry.set_page_status("/", "invalid")
|
||||||
assert page_registry.is_api_public() is True
|
|
||||||
|
|
||||||
def test_api_public_false(self, mock_registry, temp_data_file):
|
class TestDrawerCrud:
|
||||||
"""Test API public flag when false."""
|
"""Tests for drawer CRUD functions."""
|
||||||
data = json.loads(temp_data_file.read_text())
|
|
||||||
data["api_public"] = False
|
def test_create_drawer(self, mock_registry):
|
||||||
temp_data_file.write_text(json.dumps(data))
|
created = page_registry.create_drawer("Custom Drawer", order=4, admin_only=True)
|
||||||
page_registry._cache = None # Clear cache
|
assert created["name"] == "Custom Drawer"
|
||||||
|
assert created["order"] == 4
|
||||||
assert page_registry.is_api_public() is False
|
assert created["admin_only"] is True
|
||||||
|
|
||||||
|
def test_create_duplicate_drawer_name_raises_conflict(self, mock_registry):
|
||||||
class TestReloadCache:
|
with pytest.raises(page_registry.DrawerConflictError):
|
||||||
"""Tests for reload_cache function."""
|
page_registry.create_drawer("報表類", order=4)
|
||||||
|
|
||||||
def test_reload_cache(self, mock_registry, temp_data_file):
|
def test_update_drawer(self, mock_registry):
|
||||||
"""Test reloading cache from disk."""
|
updated = page_registry.update_drawer(
|
||||||
# First load
|
"reports",
|
||||||
assert page_registry.get_page_status("/") == "released"
|
name="報表中心",
|
||||||
|
order=7,
|
||||||
# Modify file directly
|
admin_only=True,
|
||||||
data = json.loads(temp_data_file.read_text())
|
)
|
||||||
data["pages"][0]["status"] = "dev"
|
assert updated["name"] == "報表中心"
|
||||||
temp_data_file.write_text(json.dumps(data))
|
assert updated["order"] == 7
|
||||||
|
assert updated["admin_only"] is True
|
||||||
# Cache still has old value
|
|
||||||
assert page_registry.get_page_status("/") == "released"
|
def test_delete_drawer_rejects_assigned_pages(self, mock_registry):
|
||||||
|
with pytest.raises(page_registry.DrawerConflictError, match="assigned pages"):
|
||||||
# After reload, should have new value
|
page_registry.delete_drawer("reports")
|
||||||
page_registry.reload_cache()
|
|
||||||
assert page_registry.get_page_status("/") == "dev"
|
def test_delete_empty_drawer(self, mock_registry):
|
||||||
|
created = page_registry.create_drawer("Temporary", order=8)
|
||||||
|
page_registry.delete_drawer(created["id"])
|
||||||
class TestConcurrency:
|
drawers = page_registry.get_all_drawers()
|
||||||
"""Tests for thread safety."""
|
assert all(drawer["id"] != created["id"] for drawer in drawers)
|
||||||
|
|
||||||
def test_concurrent_access(self, mock_registry):
|
|
||||||
"""Test concurrent read/write operations."""
|
class TestNavigationConfig:
|
||||||
import threading
|
"""Tests for navigation config generation."""
|
||||||
|
|
||||||
errors = []
|
def test_navigation_config_grouped_and_sorted(self, mock_registry):
|
||||||
|
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=5)
|
||||||
def reader():
|
nav = page_registry.get_navigation_config()
|
||||||
try:
|
|
||||||
for _ in range(100):
|
assert [drawer["id"] for drawer in nav] == ["reports", "queries", "dev-tools"]
|
||||||
page_registry.get_page_status("/")
|
|
||||||
except Exception as e:
|
reports = next(drawer for drawer in nav if drawer["id"] == "reports")
|
||||||
errors.append(e)
|
assert [page["route"] for page in reports["pages"]] == ["/wip-overview"]
|
||||||
|
assert reports["pages"][0]["frame_id"] == "wipOverviewFrame"
|
||||||
def writer():
|
assert reports["pages"][0]["tool_src"] is None
|
||||||
try:
|
|
||||||
for i in range(100):
|
queries = next(drawer for drawer in nav if drawer["id"] == "queries")
|
||||||
status = "released" if i % 2 == 0 else "dev"
|
assert queries["pages"][0]["route"] == "/tables"
|
||||||
page_registry.set_page_status("/", status)
|
assert queries["pages"][-1]["route"] == "/dev-page"
|
||||||
except Exception as e:
|
|
||||||
errors.append(e)
|
dev_tools = next(drawer for drawer in nav if drawer["id"] == "dev-tools")
|
||||||
|
assert all(page["frame_id"] == "toolFrame" for page in dev_tools["pages"])
|
||||||
threads = [
|
assert dev_tools["pages"][0]["tool_src"] == "/admin/pages"
|
||||||
threading.Thread(target=reader) for _ in range(3)
|
|
||||||
] + [
|
|
||||||
threading.Thread(target=writer) for _ in range(2)
|
class TestIsApiPublic:
|
||||||
]
|
"""Tests for is_api_public function."""
|
||||||
|
|
||||||
for t in threads:
|
def test_api_public_true(self, mock_registry):
|
||||||
t.start()
|
assert page_registry.is_api_public() is True
|
||||||
for t in threads:
|
|
||||||
t.join()
|
def test_api_public_false(self, mock_registry, temp_data_file):
|
||||||
|
data = json.loads(temp_data_file.read_text())
|
||||||
assert len(errors) == 0, f"Errors occurred: {errors}"
|
data["api_public"] = False
|
||||||
|
temp_data_file.write_text(json.dumps(data))
|
||||||
|
page_registry._cache = None
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v"])
|
assert page_registry.is_api_public() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestReloadCache:
|
||||||
|
"""Tests for reload_cache function."""
|
||||||
|
|
||||||
|
def test_reload_cache(self, mock_registry, temp_data_file):
|
||||||
|
assert page_registry.get_page_status("/") == "released"
|
||||||
|
|
||||||
|
data = json.loads(temp_data_file.read_text())
|
||||||
|
home = next(page for page in data["pages"] if page["route"] == "/")
|
||||||
|
home["status"] = "dev"
|
||||||
|
temp_data_file.write_text(json.dumps(data))
|
||||||
|
|
||||||
|
assert page_registry.get_page_status("/") == "released"
|
||||||
|
page_registry.reload_cache()
|
||||||
|
assert page_registry.get_page_status("/") == "dev"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConcurrency:
|
||||||
|
"""Tests for thread safety."""
|
||||||
|
|
||||||
|
def test_concurrent_access(self, mock_registry):
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def reader():
|
||||||
|
try:
|
||||||
|
for _ in range(100):
|
||||||
|
page_registry.get_page_status("/")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
def writer():
|
||||||
|
try:
|
||||||
|
for index in range(100):
|
||||||
|
status = "released" if index % 2 == 0 else "dev"
|
||||||
|
page_registry.set_page_status("/", status)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=reader) for _ in range(3)] + [
|
||||||
|
threading.Thread(target=writer) for _ in range(2)
|
||||||
|
]
|
||||||
|
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
assert len(errors) == 0, f"Errors occurred: {errors}"
|
||||||
|
|||||||
@@ -100,6 +100,108 @@ class TestTemplateIntegration(unittest.TestCase):
|
|||||||
self.assertIn('mes-toast-container', html)
|
self.assertIn('mes-toast-container', html)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPortalDynamicDrawerRendering(unittest.TestCase):
|
||||||
|
"""Test dynamic portal drawer rendering."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
db._ENGINE = None
|
||||||
|
self.app = create_app('testing')
|
||||||
|
self.app.config['TESTING'] = True
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
_login_as_admin(self.client)
|
||||||
|
|
||||||
|
def test_portal_uses_navigation_config_for_sidebar_and_iframes(self):
|
||||||
|
drawers = [
|
||||||
|
{
|
||||||
|
"id": "custom",
|
||||||
|
"name": "自訂分類",
|
||||||
|
"order": 1,
|
||||||
|
"admin_only": False,
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"route": "/wip-overview",
|
||||||
|
"name": "自訂首頁",
|
||||||
|
"status": "released",
|
||||||
|
"order": 1,
|
||||||
|
"frame_id": "customFrame",
|
||||||
|
"tool_src": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dev-tools",
|
||||||
|
"name": "開發工具",
|
||||||
|
"order": 2,
|
||||||
|
"admin_only": True,
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"route": "/admin/pages",
|
||||||
|
"name": "頁面管理",
|
||||||
|
"status": "dev",
|
||||||
|
"order": 1,
|
||||||
|
"frame_id": "toolFrame",
|
||||||
|
"tool_src": "/admin/pages",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
with patch("mes_dashboard.app.get_navigation_config", return_value=drawers):
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
self.assertIn("自訂分類", html)
|
||||||
|
self.assertIn('data-target="customFrame"', html)
|
||||||
|
self.assertIn('id="customFrame"', html)
|
||||||
|
self.assertIn('data-tool-src="/admin/pages"', html)
|
||||||
|
self.assertIn('id="toolFrame"', html)
|
||||||
|
|
||||||
|
def test_portal_hides_admin_only_drawer_for_non_admin(self):
|
||||||
|
client = self.app.test_client()
|
||||||
|
drawers = [
|
||||||
|
{
|
||||||
|
"id": "custom",
|
||||||
|
"name": "自訂分類",
|
||||||
|
"order": 1,
|
||||||
|
"admin_only": False,
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"route": "/wip-overview",
|
||||||
|
"name": "自訂首頁",
|
||||||
|
"status": "released",
|
||||||
|
"order": 1,
|
||||||
|
"frame_id": "customFrame",
|
||||||
|
"tool_src": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dev-tools",
|
||||||
|
"name": "開發工具",
|
||||||
|
"order": 2,
|
||||||
|
"admin_only": True,
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"route": "/admin/pages",
|
||||||
|
"name": "頁面管理",
|
||||||
|
"status": "dev",
|
||||||
|
"order": 1,
|
||||||
|
"frame_id": "toolFrame",
|
||||||
|
"tool_src": "/admin/pages",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
with patch("mes_dashboard.app.get_navigation_config", return_value=drawers):
|
||||||
|
response = client.get("/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
html = response.data.decode("utf-8")
|
||||||
|
self.assertIn("自訂分類", html)
|
||||||
|
self.assertNotIn("開發工具", html)
|
||||||
|
self.assertNotIn('data-tool-src="/admin/pages"', html)
|
||||||
|
|
||||||
|
|
||||||
class TestToastCSSIntegration(unittest.TestCase):
|
class TestToastCSSIntegration(unittest.TestCase):
|
||||||
"""Test that Toast CSS styles are included in pages."""
|
"""Test that Toast CSS styles are included in pages."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user