diff --git a/MEMBER_EDITOR_PLAN.md b/MEMBER_EDITOR_PLAN.md new file mode 100644 index 0000000..51e5dd1 --- /dev/null +++ b/MEMBER_EDITOR_PLAN.md @@ -0,0 +1,548 @@ +# Member Editor Implementation Plan + +## Overview + +Create a Member Editor admin interface at `/a/members` following the proven pattern established by the Tag Editor. All backend API infrastructure already exists; this is purely a web UI implementation. + +## Current State + +### ✅ Already Implemented + +| Component | Status | Location | +|-----------|--------|----------| +| Member Model | ✅ Complete | `common/models/member.py` | +| API Schemas | ✅ Complete | `common/schemas/members.py` | +| API CRUD Endpoints | ✅ Complete | `api/routes/members.py` | +| YAML Import | ✅ Complete | `collector/member_import.py` | +| Public Members Page | ✅ Complete | `web/routes/members.py` | +| Admin Foundation | ✅ Complete | `web/routes/admin.py` | + +### ❌ Missing - To Be Implemented + +1. Admin web routes for Member CRUD at `/a/members` +2. Admin template `admin/members.html` +3. Navigation card in admin index + +## Architecture Reference + +The Member Editor will follow the **exact same pattern** as the Tag Editor: + +``` +User visits /a/members + ↓ +Displays members table with actions + ↓ +User clicks: Create | Edit | Delete + ↓ +Modal opens with form + ↓ +Form submits via POST to /a/members/{action} + ↓ +Backend calls API endpoint + ↓ +Redirects back to /a/members with flash message +``` + +## Implementation Tasks + +### Task 1: Add Admin Web Routes + +**File:** `src/meshcore_hub/web/routes/admin.py` + +Add the following routes following the Tag Editor pattern: + +#### 1.1 Main Members Page (GET) +```python +@router.get("/members", response_class=HTMLResponse) +async def admin_members( + request: Request, + message: Optional[str] = Query(None), + error: Optional[str] = Query(None) +) -> HTMLResponse +``` + +**Responsibilities:** +- Check admin enabled via `_check_admin_enabled(request)` +- Get auth context via `_get_auth_context(request)` +- Fetch all members from `/api/v1/members?limit=1000` +- Sort members by name +- Render `admin/members.html` template + +#### 1.2 Create Member (POST) +```python +@router.post("/members", response_class=RedirectResponse) +async def admin_create_member( + request: Request, + name: str = Form(...), + member_id: str = Form(...), + callsign: Optional[str] = Form(None), + role: Optional[str] = Form(None), + description: Optional[str] = Form(None), + contact: Optional[str] = Form(None) +) -> RedirectResponse +``` + +**Responsibilities:** +- Check admin enabled and require auth +- POST to `/api/v1/members` with form data +- Handle success (201) → redirect with success message +- Handle errors (409 duplicate, 400 validation) → redirect with error +- Use `_build_redirect_url()` helper + +#### 1.3 Update Member (POST) +```python +@router.post("/members/update", response_class=RedirectResponse) +async def admin_update_member( + request: Request, + id: str = Form(...), + name: Optional[str] = Form(None), + member_id: Optional[str] = Form(None), + callsign: Optional[str] = Form(None), + role: Optional[str] = Form(None), + description: Optional[str] = Form(None), + contact: Optional[str] = Form(None) +) -> RedirectResponse +``` + +**Responsibilities:** +- Check admin enabled and require auth +- Build update payload (only non-None fields) +- PUT to `/api/v1/members/{id}` with update data +- Handle success (200) → redirect with success message +- Handle errors (404, 409, 400) → redirect with error + +#### 1.4 Delete Member (POST) +```python +@router.post("/members/delete", response_class=RedirectResponse) +async def admin_delete_member( + request: Request, + id: str = Form(...) +) -> RedirectResponse +``` + +**Responsibilities:** +- Check admin enabled and require auth +- DELETE to `/api/v1/members/{id}` +- Handle success (204) → redirect with success message +- Handle errors (404) → redirect with error + +### Task 2: Create Admin Template + +**File:** `src/meshcore_hub/web/templates/admin/members.html` + +Structure based on `admin/node_tags.html`: + +#### 2.1 Page Layout +```html +{% extends "base.html" %} + +{% block title %}Members - Admin{% endblock %} + +{% block breadcrumb %} + +{% endblock %} + +{% block content %} + + + + +{% endblock %} +``` + +#### 2.2 Flash Messages Section +```html +{% if message %} +
{{ message }}
+{% endif %} + +{% if error %} +
{{ error }}
+{% endif %} +``` + +#### 2.3 Members Table Card +```html +
+
+

Network Members

+ + + + + + + + + + + + + + {% for member in members %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
Member IDNameCallsignRoleContactActions
{{ member.member_id }}{{ member.name }} + {% if member.callsign %} + {{ member.callsign }} + {% endif %} + {{ member.role or '-' }}{{ member.contact or '-' }} + + +
+ No members configured yet +
+
+
+``` + +#### 2.4 Add Member Form Card +```html +
+
+

Add New Member

+ +
+
+
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+``` + +#### 2.5 Edit Modal +```html + + + + +``` + +#### 2.6 Delete Modal +```html + + + + +``` + +#### 2.7 JavaScript Event Handlers +```html + +``` + +### Task 3: Update Admin Index + +**File:** `src/meshcore_hub/web/templates/admin/index.html` + +Add a new navigation card for the Member Editor after the existing Node Tags card: + +```html + +
+

+ + + + Members +

+

Manage network members and operators

+
+ +
+
+
+``` + +## Field Descriptions + +### Member Model Fields + +| Field | Type | Required | Description | Example | +|-------|------|----------|-------------|---------| +| `member_id` | string | Yes | Unique identifier for member | `walshie86` | +| `name` | string | Yes | Full display name | `John Smith` | +| `callsign` | string | No | Amateur radio callsign | `VK4ABC` | +| `role` | string | No | Member's role in network | `Network Coordinator` | +| `description` | text | No | Longer description of member | `Manages Brisbane nodes` | +| `contact` | string | No | Contact information | `john@example.com` | + +### Validation Rules + +- `member_id`: 1-50 chars, alphanumeric + underscore only +- `name`: 1-255 chars +- `callsign`: Max 20 chars +- `role`: Max 100 chars +- `contact`: Max 255 chars +- `description`: No limit (TEXT field) + +## Testing Checklist + +### Manual Testing + +- [ ] Access `/a/members` - displays empty state +- [ ] Create new member with all fields +- [ ] Create new member with only required fields +- [ ] Edit member - update single field +- [ ] Edit member - update all fields +- [ ] Delete member - confirm deletion +- [ ] Delete member - cancel deletion +- [ ] Try duplicate member_id - shows error +- [ ] Try empty required fields - shows validation error +- [ ] Verify flash messages appear on success/error +- [ ] Check mobile responsive layout +- [ ] Verify authentication required for POST actions +- [ ] Verify admin disabled shows 404 + +### API Integration Testing + +- [ ] Create via web → verify in API GET +- [ ] Update via web → verify in API GET +- [ ] Delete via web → verify 404 in API GET +- [ ] Check timestamps update correctly + +### UI/UX Testing + +- [ ] Table sorts properly +- [ ] Modals open/close correctly +- [ ] Form validation works +- [ ] Error messages are clear +- [ ] Success messages are clear +- [ ] Layout works on mobile +- [ ] Layout works on tablet +- [ ] Layout works on desktop + +## Acceptance Criteria + +✅ The Member Editor is complete when: + +1. **Create**: Admin can create new members via form +2. **Read**: Admin can view all members in a table +3. **Update**: Admin can edit member fields via modal +4. **Delete**: Admin can delete members with confirmation +5. **Navigation**: Admin index has working Members card +6. **Authentication**: All state-changing operations require auth +7. **Validation**: Form validation matches API schemas +8. **Error Handling**: Clear error messages for failures +9. **Success Feedback**: Flash messages confirm successful actions +10. **Mobile Responsive**: Works on all screen sizes + +## Future Enhancements (Out of Scope) + +- Bulk import members from web UI +- Export members to YAML +- Link member to multiple nodes +- Member activity history +- Search/filter members +- Pagination for large member lists + +## Implementation Order + +1. ✅ Review existing code (Tag Editor + Member API) +2. ⬜ Add admin web routes to `admin.py` +3. ⬜ Create `admin/members.html` template +4. ⬜ Update admin index navigation +5. ⬜ Test CRUD operations +6. ⬜ Test error cases +7. ⬜ Test responsive layout +8. ⬜ Commit and push changes + +## Estimated Complexity + +- **Routes**: Simple (follow existing pattern) +- **Template**: Medium (adapt from node_tags.html) +- **Testing**: Medium (comprehensive testing required) + +**Total Effort**: ~2-3 hours of focused development + +## Related Documentation + +- Tag Editor Reference: `src/meshcore_hub/web/routes/admin.py` (lines for node_tags routes) +- Tag Editor Template: `src/meshcore_hub/web/templates/admin/node_tags.html` +- Member API: `src/meshcore_hub/api/routes/members.py` +- Member Schemas: `src/meshcore_hub/common/schemas/members.py` +- Member Model: `src/meshcore_hub/common/models/member.py` diff --git a/src/meshcore_hub/web/routes/admin.py b/src/meshcore_hub/web/routes/admin.py index 90d0139..13d052c 100644 --- a/src/meshcore_hub/web/routes/admin.py +++ b/src/meshcore_hub/web/routes/admin.py @@ -391,3 +391,201 @@ async def admin_delete_all_tags( redirect_url = _build_redirect_url(public_key, error="Failed to delete tags") return RedirectResponse(url=redirect_url, status_code=303) + + +def _build_members_redirect_url( + message: Optional[str] = None, + error: Optional[str] = None, +) -> str: + """Build a properly encoded redirect URL for members page with optional message/error.""" + params: dict[str, str] = {} + if message: + params["message"] = message + if error: + params["error"] = error + if params: + return f"/a/members?{urlencode(params)}" + return "/a/members" + + +@router.get("/members", response_class=HTMLResponse) +async def admin_members( + request: Request, + message: Optional[str] = Query(None), + error: Optional[str] = Query(None), +) -> HTMLResponse: + """Admin page for managing members.""" + _check_admin_enabled(request) + + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + context.update(_get_auth_context(request)) + + # Check if user is authenticated + if not _is_authenticated(request): + return templates.TemplateResponse( + "admin/access_denied.html", context, status_code=403 + ) + + # Flash messages from redirects + context["message"] = message + context["error"] = error + + # Fetch all members + members = [] + try: + response = await request.app.state.http_client.get( + "/api/v1/members", + params={"limit": 500}, + ) + if response.status_code == 200: + data = response.json() + members = data.get("items", []) + # Sort members alphabetically by name + members.sort(key=lambda m: m.get("name", "").lower()) + except Exception as e: + logger.exception("Failed to fetch members: %s", e) + context["error"] = "Failed to fetch members" + + context["members"] = members + + return templates.TemplateResponse("admin/members.html", context) + + +@router.post("/members", response_class=RedirectResponse) +async def admin_create_member( + request: Request, + name: str = Form(...), + member_id: str = Form(...), + callsign: Optional[str] = Form(None), + role: Optional[str] = Form(None), + description: Optional[str] = Form(None), + contact: Optional[str] = Form(None), +) -> RedirectResponse: + """Create a new member.""" + _check_admin_enabled(request) + _require_auth(request) + + try: + # Build request payload + payload = { + "name": name, + "member_id": member_id, + } + if callsign: + payload["callsign"] = callsign + if role: + payload["role"] = role + if description: + payload["description"] = description + if contact: + payload["contact"] = contact + + response = await request.app.state.http_client.post( + "/api/v1/members", + json=payload, + ) + if response.status_code == 201: + redirect_url = _build_members_redirect_url( + message=f"Member '{name}' created successfully" + ) + elif response.status_code == 409: + redirect_url = _build_members_redirect_url( + error=f"Member ID '{member_id}' already exists" + ) + else: + redirect_url = _build_members_redirect_url( + error=_get_error_detail(response) + ) + except Exception as e: + logger.exception("Failed to create member: %s", e) + redirect_url = _build_members_redirect_url(error="Failed to create member") + + return RedirectResponse(url=redirect_url, status_code=303) + + +@router.post("/members/update", response_class=RedirectResponse) +async def admin_update_member( + request: Request, + id: str = Form(...), + name: Optional[str] = Form(None), + member_id: Optional[str] = Form(None), + callsign: Optional[str] = Form(None), + role: Optional[str] = Form(None), + description: Optional[str] = Form(None), + contact: Optional[str] = Form(None), +) -> RedirectResponse: + """Update an existing member.""" + _check_admin_enabled(request) + _require_auth(request) + + try: + # Build update payload (only include non-None fields) + payload: dict[str, str | None] = {} + if name is not None: + payload["name"] = name + if member_id is not None: + payload["member_id"] = member_id + if callsign is not None: + payload["callsign"] = callsign if callsign else None + if role is not None: + payload["role"] = role if role else None + if description is not None: + payload["description"] = description if description else None + if contact is not None: + payload["contact"] = contact if contact else None + + response = await request.app.state.http_client.put( + f"/api/v1/members/{id}", + json=payload, + ) + if response.status_code == 200: + redirect_url = _build_members_redirect_url( + message="Member updated successfully" + ) + elif response.status_code == 404: + redirect_url = _build_members_redirect_url(error="Member not found") + elif response.status_code == 409: + redirect_url = _build_members_redirect_url( + error=f"Member ID '{member_id}' already exists" + ) + else: + redirect_url = _build_members_redirect_url( + error=_get_error_detail(response) + ) + except Exception as e: + logger.exception("Failed to update member: %s", e) + redirect_url = _build_members_redirect_url(error="Failed to update member") + + return RedirectResponse(url=redirect_url, status_code=303) + + +@router.post("/members/delete", response_class=RedirectResponse) +async def admin_delete_member( + request: Request, + id: str = Form(...), +) -> RedirectResponse: + """Delete a member.""" + _check_admin_enabled(request) + _require_auth(request) + + try: + response = await request.app.state.http_client.delete( + f"/api/v1/members/{id}", + ) + if response.status_code == 204: + redirect_url = _build_members_redirect_url( + message="Member deleted successfully" + ) + elif response.status_code == 404: + redirect_url = _build_members_redirect_url(error="Member not found") + else: + redirect_url = _build_members_redirect_url( + error=_get_error_detail(response) + ) + except Exception as e: + logger.exception("Failed to delete member: %s", e) + redirect_url = _build_members_redirect_url(error="Failed to delete member") + + return RedirectResponse(url=redirect_url, status_code=303) diff --git a/src/meshcore_hub/web/templates/admin/index.html b/src/meshcore_hub/web/templates/admin/index.html index b38c148..9087600 100644 --- a/src/meshcore_hub/web/templates/admin/index.html +++ b/src/meshcore_hub/web/templates/admin/index.html @@ -21,7 +21,8 @@ {% if auth_username or auth_user %} - + {{ auth_username or auth_user }} @@ -29,7 +30,8 @@ {% if auth_email %} - + {{ auth_email }} @@ -38,11 +40,26 @@
+ +
+

+ + + + Members +

+

Manage network members and operators.

+
+

- - + + Node Tags

diff --git a/src/meshcore_hub/web/templates/admin/members.html b/src/meshcore_hub/web/templates/admin/members.html new file mode 100644 index 0000000..293e459 --- /dev/null +++ b/src/meshcore_hub/web/templates/admin/members.html @@ -0,0 +1,282 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Members Admin{% endblock %} + +{% block content %} +
+ + +{% if message %} +
+ + + + {{ message }} +
+{% endif %} + +{% if error %} +
+ + + + {{ error }} +
+{% endif %} + + +
+
+
+

Network Members ({{ members|length }})

+ +
+ + {% if members %} +
+ + + + + + + + + + + + {% for member in members %} + + + + + + + + {% endfor %} + +
Member IDNameCallsignContactActions
{{ member.member_id }}{{ member.name }} + {% if member.callsign %} + {{ member.callsign }} + {% else %} + - + {% endif %} + {{ member.contact or '-' }} +
+ + +
+
+
+ {% else %} +
+

No members configured yet.

+

Click "Add Member" to create the first member.

+
+ {% endif %} +
+
+ + + + + + + + + + + + + + + + + + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %}